Subida del módulo y tema de PrestaShop

This commit is contained in:
Kaloyan
2026-04-09 18:31:51 +02:00
parent 12c253296f
commit 16b3ff9424
39262 changed files with 7418797 additions and 0 deletions

View File

@@ -0,0 +1,19 @@
Copyright (c) 2020-present Fabien Potencier
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is furnished
to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
THE SOFTWARE.

View File

@@ -0,0 +1,27 @@
<?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.
*/
if (!function_exists('trigger_deprecation')) {
/**
* Triggers a silenced deprecation notice.
*
* @param string $package The name of the Composer package that is triggering the deprecation
* @param string $version The version of the package that introduced the deprecation
* @param string $message The message of the deprecation
* @param mixed ...$args Values to insert in the message using printf() formatting
*
* @author Nicolas Grekas <p@tchwork.com>
*/
function trigger_deprecation(string $package, string $version, string $message, mixed ...$args): void
{
@trigger_error(($package || $version ? "Since $package $version: " : '').($args ? vsprintf($message, $args) : $message), \E_USER_DEPRECATED);
}
}

View File

@@ -0,0 +1,214 @@
<?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\Dotenv\Command;
use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Completion\CompletionInput;
use Symfony\Component\Console\Completion\CompletionSuggestions;
use Symfony\Component\Console\Formatter\OutputFormatter;
use Symfony\Component\Console\Input\InputArgument;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Style\SymfonyStyle;
use Symfony\Component\Dotenv\Dotenv;
/**
* A console command to debug current dotenv files with variables and values.
*
* @author Christopher Hertel <mail@christopher-hertel.de>
*/
#[AsCommand(name: 'debug:dotenv', description: 'List all dotenv files with variables and values')]
final class DebugCommand extends Command
{
/**
* @deprecated since Symfony 6.1
*/
protected static $defaultName = 'debug:dotenv';
/**
* @deprecated since Symfony 6.1
*/
protected static $defaultDescription = 'List all dotenv files with variables and values';
private string $kernelEnvironment;
private string $projectDirectory;
public function __construct(string $kernelEnvironment, string $projectDirectory)
{
$this->kernelEnvironment = $kernelEnvironment;
$this->projectDirectory = $projectDirectory;
parent::__construct();
}
protected function configure(): void
{
$this
->setDefinition([
new InputArgument('filter', InputArgument::OPTIONAL, 'The name of an environment variable or a filter.', null, $this->getAvailableVars(...)),
])
->setHelp(<<<'EOT'
The <info>%command.full_name%</info> command displays all the environment variables configured by dotenv:
<info>php %command.full_name%</info>
To get specific variables, specify its full or partial name:
<info>php %command.full_name% FOO_BAR</info>
EOT
);
}
protected function execute(InputInterface $input, OutputInterface $output): int
{
$io = new SymfonyStyle($input, $output);
$io->title('Dotenv Variables & Files');
if (!\array_key_exists('SYMFONY_DOTENV_VARS', $_SERVER)) {
$io->error('Dotenv component is not initialized.');
return 1;
}
$filePath = $_SERVER['SYMFONY_DOTENV_PATH'] ?? $this->projectDirectory.\DIRECTORY_SEPARATOR.'.env';
$envFiles = $this->getEnvFiles($filePath);
$availableFiles = array_filter($envFiles, 'is_file');
if (\in_array(sprintf('%s.local.php', $filePath), $availableFiles, true)) {
$io->warning(sprintf('Due to existing dump file (%s.local.php) all other dotenv files are skipped.', $this->getRelativeName($filePath)));
}
if (is_file($filePath) && is_file(sprintf('%s.dist', $filePath))) {
$io->warning(sprintf('The file %s.dist gets skipped due to the existence of %1$s.', $this->getRelativeName($filePath)));
}
$io->section('Scanned Files (in descending priority)');
$io->listing(array_map(fn (string $envFile) => \in_array($envFile, $availableFiles, true)
? sprintf('<fg=green>✓</> %s', $this->getRelativeName($envFile))
: sprintf('<fg=red></> %s', $this->getRelativeName($envFile)), $envFiles));
$nameFilter = $input->getArgument('filter');
$variables = $this->getVariables($availableFiles, $nameFilter);
$io->section('Variables');
if ($variables || null === $nameFilter) {
$io->table(
array_merge(['Variable', 'Value'], array_map($this->getRelativeName(...), $availableFiles)),
$variables
);
$io->comment('Note that values might be different between web and CLI.');
} else {
$io->warning(sprintf('No variables match the given filter "%s".', $nameFilter));
}
return 0;
}
public function complete(CompletionInput $input, CompletionSuggestions $suggestions): void
{
if ($input->mustSuggestArgumentValuesFor('filter')) {
$suggestions->suggestValues($this->getAvailableVars());
}
}
private function getVariables(array $envFiles, ?string $nameFilter): array
{
$variables = [];
$fileValues = [];
$dotenvVars = array_flip(explode(',', $_SERVER['SYMFONY_DOTENV_VARS'] ?? ''));
foreach ($envFiles as $envFile) {
$fileValues[$envFile] = $this->loadValues($envFile);
$variables += $fileValues[$envFile];
}
foreach ($variables as $var => $varDetails) {
if (null !== $nameFilter && 0 !== stripos($var, $nameFilter)) {
unset($variables[$var]);
continue;
}
$realValue = $_SERVER[$var] ?? '';
$varDetails = [$var, '<fg=green>'.OutputFormatter::escape($realValue).'</>'];
$varSeen = !isset($dotenvVars[$var]);
foreach ($envFiles as $envFile) {
if (null === $value = $fileValues[$envFile][$var] ?? null) {
$varDetails[] = '<fg=yellow>n/a</>';
continue;
}
$shortenedValue = OutputFormatter::escape($this->getHelper('formatter')->truncate($value, 30));
$varDetails[] = $value === $realValue && !$varSeen ? '<fg=green>'.$shortenedValue.'</>' : $shortenedValue;
$varSeen = $varSeen || $value === $realValue;
}
$variables[$var] = $varDetails;
}
ksort($variables);
return $variables;
}
private function getAvailableVars(): array
{
$filePath = $_SERVER['SYMFONY_DOTENV_PATH'] ?? $this->projectDirectory.\DIRECTORY_SEPARATOR.'.env';
$envFiles = $this->getEnvFiles($filePath);
return array_keys($this->getVariables(array_filter($envFiles, 'is_file'), null));
}
private function getEnvFiles(string $filePath): array
{
$files = [
sprintf('%s.local.php', $filePath),
sprintf('%s.%s.local', $filePath, $this->kernelEnvironment),
sprintf('%s.%s', $filePath, $this->kernelEnvironment),
];
if ('test' !== $this->kernelEnvironment) {
$files[] = sprintf('%s.local', $filePath);
}
if (!is_file($filePath) && is_file(sprintf('%s.dist', $filePath))) {
$files[] = sprintf('%s.dist', $filePath);
} else {
$files[] = $filePath;
}
return $files;
}
private function getRelativeName(string $filePath): string
{
if (str_starts_with($filePath, $this->projectDirectory)) {
return substr($filePath, \strlen($this->projectDirectory) + 1);
}
return basename($filePath);
}
private function loadValues(string $filePath): array
{
if (str_ends_with($filePath, '.php')) {
return include $filePath;
}
return (new Dotenv())->parse(file_get_contents($filePath));
}
}

View File

@@ -0,0 +1,117 @@
<?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\Dotenv\Command;
use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputArgument;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Input\InputOption;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\DependencyInjection\Attribute\Autoconfigure;
use Symfony\Component\Dotenv\Dotenv;
/**
* A console command to compile .env files into a PHP-optimized file called .env.local.php.
*
* @internal
*/
#[Autoconfigure(bind: ['$projectDir' => '%kernel.project_dir%', '$defaultEnv' => '%kernel.environment%'])]
#[AsCommand(name: 'dotenv:dump', description: 'Compile .env files to .env.local.php')]
final class DotenvDumpCommand extends Command
{
private string $projectDir;
private ?string $defaultEnv;
public function __construct(string $projectDir, ?string $defaultEnv = null)
{
$this->projectDir = $projectDir;
$this->defaultEnv = $defaultEnv;
parent::__construct();
}
protected function configure(): void
{
$this
->setDefinition([
new InputArgument('env', null === $this->defaultEnv ? InputArgument::REQUIRED : InputArgument::OPTIONAL, 'The application environment to dump .env files for - e.g. "prod".'),
])
->addOption('empty', null, InputOption::VALUE_NONE, 'Ignore the content of .env files')
->setHelp(<<<'EOT'
The <info>%command.name%</info> command compiles .env files into a PHP-optimized file called .env.local.php.
<info>%command.full_name%</info>
EOT
)
;
}
protected function execute(InputInterface $input, OutputInterface $output): int
{
$config = [];
if (is_file($projectDir = $this->projectDir)) {
$config = ['dotenv_path' => basename($projectDir)];
$projectDir = \dirname($projectDir);
}
$composerFile = $projectDir.'/composer.json';
$config += (is_file($composerFile) ? json_decode(file_get_contents($composerFile), true) : [])['extra']['runtime'] ?? [];
$dotenvPath = $projectDir.'/'.($config['dotenv_path'] ?? '.env');
$env = $input->getArgument('env') ?? $this->defaultEnv;
$envKey = $config['env_var_name'] ?? 'APP_ENV';
if ($input->getOption('empty')) {
$vars = [$envKey => $env];
} else {
$vars = $this->loadEnv($dotenvPath, $env, $config);
$env = $vars[$envKey];
}
$vars = var_export($vars, true);
$vars = <<<EOF
<?php
// This file was generated by running "php bin/console dotenv:dump $env"
return $vars;
EOF;
file_put_contents($dotenvPath.'.local.php', $vars, \LOCK_EX);
$output->writeln(sprintf('Successfully dumped .env files in <info>.env.local.php</> for the <info>%s</> environment.', $env));
return 0;
}
private function loadEnv(string $dotenvPath, string $env, array $config): array
{
$envKey = $config['env_var_name'] ?? 'APP_ENV';
$testEnvs = $config['test_envs'] ?? ['test'];
$dotenv = new Dotenv($envKey);
$globalsBackup = [$_SERVER, $_ENV];
unset($_SERVER[$envKey]);
$_ENV = [$envKey => $env];
$_SERVER['SYMFONY_DOTENV_VARS'] = implode(',', array_keys($_SERVER));
try {
$dotenv->loadEnv($dotenvPath, null, 'dev', $testEnvs);
unset($_ENV['SYMFONY_DOTENV_VARS']);
return $_ENV;
} finally {
[$_SERVER, $_ENV] = $globalsBackup;
}
}
}

View File

@@ -0,0 +1,565 @@
<?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\Dotenv;
use Symfony\Component\Dotenv\Exception\FormatException;
use Symfony\Component\Dotenv\Exception\FormatExceptionContext;
use Symfony\Component\Dotenv\Exception\PathException;
use Symfony\Component\Process\Exception\ExceptionInterface as ProcessException;
use Symfony\Component\Process\Process;
/**
* Manages .env files.
*
* @author Fabien Potencier <fabien@symfony.com>
* @author Kévin Dunglas <dunglas@gmail.com>
*/
final class Dotenv
{
public const VARNAME_REGEX = '(?i:_?[A-Z][A-Z0-9_]*+)';
public const STATE_VARNAME = 0;
public const STATE_VALUE = 1;
private string $path;
private int $cursor;
private int $lineno;
private string $data;
private int $end;
private array $values = [];
private string $envKey;
private string $debugKey;
private array $prodEnvs = ['prod'];
private bool $usePutenv = false;
public function __construct(string $envKey = 'APP_ENV', string $debugKey = 'APP_DEBUG')
{
$this->envKey = $envKey;
$this->debugKey = $debugKey;
}
/**
* @return $this
*/
public function setProdEnvs(array $prodEnvs): static
{
$this->prodEnvs = $prodEnvs;
return $this;
}
/**
* @param bool $usePutenv If `putenv()` should be used to define environment variables or not.
* Beware that `putenv()` is not thread safe, that's why it's not enabled by default
*
* @return $this
*/
public function usePutenv(bool $usePutenv = true): static
{
$this->usePutenv = $usePutenv;
return $this;
}
/**
* Loads one or several .env files.
*
* @param string $path A file to load
* @param string ...$extraPaths A list of additional files to load
*
* @throws FormatException when a file has a syntax error
* @throws PathException when a file does not exist or is not readable
*/
public function load(string $path, string ...$extraPaths): void
{
$this->doLoad(false, \func_get_args());
}
/**
* Loads a .env file and the corresponding .env.local, .env.$env and .env.$env.local files if they exist.
*
* .env.local is always ignored in test env because tests should produce the same results for everyone.
* .env.dist is loaded when it exists and .env is not found.
*
* @param string $path A file to load
* @param string|null $envKey The name of the env vars that defines the app env
* @param string $defaultEnv The app env to use when none is defined
* @param array $testEnvs A list of app envs for which .env.local should be ignored
* @param bool $overrideExistingVars Whether existing environment variables set by the system should be overridden
*
* @throws FormatException when a file has a syntax error
* @throws PathException when a file does not exist or is not readable
*/
public function loadEnv(string $path, ?string $envKey = null, string $defaultEnv = 'dev', array $testEnvs = ['test'], bool $overrideExistingVars = false): void
{
$k = $envKey ?? $this->envKey;
if (is_file($path) || !is_file($p = "$path.dist")) {
$this->doLoad($overrideExistingVars, [$path]);
} else {
$this->doLoad($overrideExistingVars, [$p]);
}
if (null === $env = $_SERVER[$k] ?? $_ENV[$k] ?? null) {
$this->populate([$k => $env = $defaultEnv], $overrideExistingVars);
}
if (!\in_array($env, $testEnvs, true) && is_file($p = "$path.local")) {
$this->doLoad($overrideExistingVars, [$p]);
$env = $_SERVER[$k] ?? $_ENV[$k] ?? $env;
}
if ('local' === $env) {
return;
}
if (is_file($p = "$path.$env")) {
$this->doLoad($overrideExistingVars, [$p]);
}
if (is_file($p = "$path.$env.local")) {
$this->doLoad($overrideExistingVars, [$p]);
}
}
/**
* Loads env vars from .env.local.php if the file exists or from the other .env files otherwise.
*
* This method also configures the APP_DEBUG env var according to the current APP_ENV.
*
* See method loadEnv() for rules related to .env files.
*/
public function bootEnv(string $path, string $defaultEnv = 'dev', array $testEnvs = ['test'], bool $overrideExistingVars = false): void
{
$p = $path.'.local.php';
$env = is_file($p) ? include $p : null;
$k = $this->envKey;
if (\is_array($env) && ($overrideExistingVars || !isset($env[$k]) || ($_SERVER[$k] ?? $_ENV[$k] ?? $env[$k]) === $env[$k])) {
$this->populate($env, $overrideExistingVars);
} else {
$this->loadEnv($path, $k, $defaultEnv, $testEnvs, $overrideExistingVars);
}
$_SERVER += $_ENV;
$k = $this->debugKey;
$debug = $_SERVER[$k] ?? !\in_array($_SERVER[$this->envKey], $this->prodEnvs, true);
$_SERVER[$k] = $_ENV[$k] = (int) $debug || (!\is_bool($debug) && filter_var($debug, \FILTER_VALIDATE_BOOL)) ? '1' : '0';
}
/**
* Loads one or several .env files and enables override existing vars.
*
* @param string $path A file to load
* @param string ...$extraPaths A list of additional files to load
*
* @throws FormatException when a file has a syntax error
* @throws PathException when a file does not exist or is not readable
*/
public function overload(string $path, string ...$extraPaths): void
{
$this->doLoad(true, \func_get_args());
}
/**
* Sets values as environment variables (via putenv, $_ENV, and $_SERVER).
*
* @param array $values An array of env variables
* @param bool $overrideExistingVars Whether existing environment variables set by the system should be overridden
*/
public function populate(array $values, bool $overrideExistingVars = false): void
{
$updateLoadedVars = false;
$loadedVars = array_flip(explode(',', $_SERVER['SYMFONY_DOTENV_VARS'] ?? $_ENV['SYMFONY_DOTENV_VARS'] ?? ''));
foreach ($values as $name => $value) {
$notHttpName = !str_starts_with($name, 'HTTP_');
if (isset($_SERVER[$name]) && $notHttpName && !isset($_ENV[$name])) {
$_ENV[$name] = $_SERVER[$name];
}
// don't check existence with getenv() because of thread safety issues
if (!isset($loadedVars[$name]) && !$overrideExistingVars && isset($_ENV[$name])) {
continue;
}
if ($this->usePutenv) {
putenv("$name=$value");
}
$_ENV[$name] = $value;
if ($notHttpName) {
$_SERVER[$name] = $value;
}
if (!isset($loadedVars[$name])) {
$loadedVars[$name] = $updateLoadedVars = true;
}
}
if ($updateLoadedVars) {
unset($loadedVars['']);
$loadedVars = implode(',', array_keys($loadedVars));
$_ENV['SYMFONY_DOTENV_VARS'] = $_SERVER['SYMFONY_DOTENV_VARS'] = $loadedVars;
if ($this->usePutenv) {
putenv('SYMFONY_DOTENV_VARS='.$loadedVars);
}
}
}
/**
* Parses the contents of an .env file.
*
* @param string $data The data to be parsed
* @param string $path The original file name where data where stored (used for more meaningful error messages)
*
* @throws FormatException when a file has a syntax error
*/
public function parse(string $data, string $path = '.env'): array
{
$this->path = $path;
$this->data = str_replace(["\r\n", "\r"], "\n", $data);
$this->lineno = 1;
$this->cursor = 0;
$this->end = \strlen($this->data);
$state = self::STATE_VARNAME;
$this->values = [];
$name = '';
$this->skipEmptyLines();
while ($this->cursor < $this->end) {
switch ($state) {
case self::STATE_VARNAME:
$name = $this->lexVarname();
$state = self::STATE_VALUE;
break;
case self::STATE_VALUE:
$this->values[$name] = $this->lexValue();
$state = self::STATE_VARNAME;
break;
}
}
if (self::STATE_VALUE === $state) {
$this->values[$name] = '';
}
try {
return $this->values;
} finally {
$this->values = [];
unset($this->path, $this->cursor, $this->lineno, $this->data, $this->end);
}
}
private function lexVarname(): string
{
// var name + optional export
if (!preg_match('/(export[ \t]++)?('.self::VARNAME_REGEX.')/A', $this->data, $matches, 0, $this->cursor)) {
throw $this->createFormatException('Invalid character in variable name');
}
$this->moveCursor($matches[0]);
if ($this->cursor === $this->end || "\n" === $this->data[$this->cursor] || '#' === $this->data[$this->cursor]) {
if ($matches[1]) {
throw $this->createFormatException('Unable to unset an environment variable');
}
throw $this->createFormatException('Missing = in the environment variable declaration');
}
if (' ' === $this->data[$this->cursor] || "\t" === $this->data[$this->cursor]) {
throw $this->createFormatException('Whitespace characters are not supported after the variable name');
}
if ('=' !== $this->data[$this->cursor]) {
throw $this->createFormatException('Missing = in the environment variable declaration');
}
++$this->cursor;
return $matches[2];
}
private function lexValue(): string
{
if (preg_match('/[ \t]*+(?:#.*)?$/Am', $this->data, $matches, 0, $this->cursor)) {
$this->moveCursor($matches[0]);
$this->skipEmptyLines();
return '';
}
if (' ' === $this->data[$this->cursor] || "\t" === $this->data[$this->cursor]) {
throw $this->createFormatException('Whitespace are not supported before the value');
}
$loadedVars = array_flip(explode(',', $_SERVER['SYMFONY_DOTENV_VARS'] ?? $_ENV['SYMFONY_DOTENV_VARS'] ?? ''));
unset($loadedVars['']);
$v = '';
do {
if ("'" === $this->data[$this->cursor]) {
$len = 0;
do {
if ($this->cursor + ++$len === $this->end) {
$this->cursor += $len;
throw $this->createFormatException('Missing quote to end the value');
}
} while ("'" !== $this->data[$this->cursor + $len]);
$v .= substr($this->data, 1 + $this->cursor, $len - 1);
$this->cursor += 1 + $len;
} elseif ('"' === $this->data[$this->cursor]) {
$value = '';
if (++$this->cursor === $this->end) {
throw $this->createFormatException('Missing quote to end the value');
}
while ('"' !== $this->data[$this->cursor] || ('\\' === $this->data[$this->cursor - 1] && '\\' !== $this->data[$this->cursor - 2])) {
$value .= $this->data[$this->cursor];
++$this->cursor;
if ($this->cursor === $this->end) {
throw $this->createFormatException('Missing quote to end the value');
}
}
++$this->cursor;
$value = str_replace(['\\"', '\r', '\n'], ['"', "\r", "\n"], $value);
$resolvedValue = $value;
$resolvedValue = $this->resolveCommands($resolvedValue, $loadedVars);
$resolvedValue = $this->resolveVariables($resolvedValue, $loadedVars);
$resolvedValue = str_replace('\\\\', '\\', $resolvedValue);
$v .= $resolvedValue;
} else {
$value = '';
$prevChr = $this->data[$this->cursor - 1];
while ($this->cursor < $this->end && !\in_array($this->data[$this->cursor], ["\n", '"', "'"], true) && !((' ' === $prevChr || "\t" === $prevChr) && '#' === $this->data[$this->cursor])) {
if ('\\' === $this->data[$this->cursor] && isset($this->data[$this->cursor + 1]) && ('"' === $this->data[$this->cursor + 1] || "'" === $this->data[$this->cursor + 1])) {
++$this->cursor;
}
$value .= $prevChr = $this->data[$this->cursor];
if ('$' === $this->data[$this->cursor] && isset($this->data[$this->cursor + 1]) && '(' === $this->data[$this->cursor + 1]) {
++$this->cursor;
$value .= '('.$this->lexNestedExpression().')';
}
++$this->cursor;
}
$value = rtrim($value);
$resolvedValue = $value;
$resolvedValue = $this->resolveCommands($resolvedValue, $loadedVars);
$resolvedValue = $this->resolveVariables($resolvedValue, $loadedVars);
$resolvedValue = str_replace('\\\\', '\\', $resolvedValue);
if ($resolvedValue === $value && preg_match('/\s+/', $value)) {
throw $this->createFormatException('A value containing spaces must be surrounded by quotes');
}
$v .= $resolvedValue;
if ($this->cursor < $this->end && '#' === $this->data[$this->cursor]) {
break;
}
}
} while ($this->cursor < $this->end && "\n" !== $this->data[$this->cursor]);
$this->skipEmptyLines();
return $v;
}
private function lexNestedExpression(): string
{
++$this->cursor;
$value = '';
while ("\n" !== $this->data[$this->cursor] && ')' !== $this->data[$this->cursor]) {
$value .= $this->data[$this->cursor];
if ('(' === $this->data[$this->cursor]) {
$value .= $this->lexNestedExpression().')';
}
++$this->cursor;
if ($this->cursor === $this->end) {
throw $this->createFormatException('Missing closing parenthesis.');
}
}
if ("\n" === $this->data[$this->cursor]) {
throw $this->createFormatException('Missing closing parenthesis.');
}
return $value;
}
private function skipEmptyLines(): void
{
if (preg_match('/(?:\s*+(?:#[^\n]*+)?+)++/A', $this->data, $match, 0, $this->cursor)) {
$this->moveCursor($match[0]);
}
}
private function resolveCommands(string $value, array $loadedVars): string
{
if (!str_contains($value, '$')) {
return $value;
}
$regex = '/
(\\\\)? # escaped with a backslash?
\$
(?<cmd>
\( # require opening parenthesis
([^()]|\g<cmd>)+ # allow any number of non-parens, or balanced parens (by nesting the <cmd> expression recursively)
\) # require closing paren
)
/x';
return preg_replace_callback($regex, function ($matches) use ($loadedVars) {
if ('\\' === $matches[1]) {
return substr($matches[0], 1);
}
if ('\\' === \DIRECTORY_SEPARATOR) {
throw new \LogicException('Resolving commands is not supported on Windows.');
}
if (!class_exists(Process::class)) {
throw new \LogicException('Resolving commands requires the Symfony Process component. Try running "composer require symfony/process".');
}
$process = Process::fromShellCommandline('echo '.$matches[0]);
$env = [];
foreach ($this->values as $name => $value) {
if (isset($loadedVars[$name]) || (!isset($_ENV[$name]) && !(isset($_SERVER[$name]) && !str_starts_with($name, 'HTTP_')))) {
$env[$name] = $value;
}
}
$process->setEnv($env);
try {
$process->mustRun();
} catch (ProcessException) {
throw $this->createFormatException(sprintf('Issue expanding a command (%s)', $process->getErrorOutput()));
}
return preg_replace('/[\r\n]+$/', '', $process->getOutput());
}, $value);
}
private function resolveVariables(string $value, array $loadedVars): string
{
if (!str_contains($value, '$')) {
return $value;
}
$regex = '/
(?<!\\\\)
(?P<backslashes>\\\\*) # escaped with a backslash?
\$
(?!\() # no opening parenthesis
(?P<opening_brace>\{)? # optional brace
(?P<name>'.self::VARNAME_REGEX.')? # var name
(?P<default_value>:[-=][^\}]*+)? # optional default value
(?P<closing_brace>\})? # optional closing brace
/x';
$value = preg_replace_callback($regex, function ($matches) use ($loadedVars) {
// odd number of backslashes means the $ character is escaped
if (1 === \strlen($matches['backslashes']) % 2) {
return substr($matches[0], 1);
}
// unescaped $ not followed by variable name
if (!isset($matches['name'])) {
return $matches[0];
}
if ('{' === $matches['opening_brace'] && !isset($matches['closing_brace'])) {
throw $this->createFormatException('Unclosed braces on variable expansion');
}
$name = $matches['name'];
if (isset($loadedVars[$name]) && isset($this->values[$name])) {
$value = $this->values[$name];
} elseif (isset($_ENV[$name])) {
$value = $_ENV[$name];
} elseif (isset($_SERVER[$name]) && !str_starts_with($name, 'HTTP_')) {
$value = $_SERVER[$name];
} elseif (isset($this->values[$name])) {
$value = $this->values[$name];
} else {
$value = (string) getenv($name);
}
if ('' === $value && isset($matches['default_value']) && '' !== $matches['default_value']) {
$unsupportedChars = strpbrk($matches['default_value'], '\'"{$');
if (false !== $unsupportedChars) {
throw $this->createFormatException(sprintf('Unsupported character "%s" found in the default value of variable "$%s".', $unsupportedChars[0], $name));
}
$value = substr($matches['default_value'], 2);
if ('=' === $matches['default_value'][1]) {
$this->values[$name] = $value;
}
}
if (!$matches['opening_brace'] && isset($matches['closing_brace'])) {
$value .= '}';
}
return $matches['backslashes'].$value;
}, $value);
return $value;
}
private function moveCursor(string $text): void
{
$this->cursor += \strlen($text);
$this->lineno += substr_count($text, "\n");
}
private function createFormatException(string $message): FormatException
{
return new FormatException($message, new FormatExceptionContext($this->data, $this->path, $this->lineno, $this->cursor));
}
private function doLoad(bool $overrideExistingVars, array $paths): void
{
foreach ($paths as $path) {
if (!is_readable($path) || is_dir($path)) {
throw new PathException($path);
}
$data = file_get_contents($path);
if ("\xEF\xBB\xBF" === substr($data, 0, 3)) {
throw new FormatException('Loading files starting with a byte-order-mark (BOM) is not supported.', new FormatExceptionContext($data, $path, 1, 0));
}
$this->populate($this->parse($data, $path), $overrideExistingVars);
}
}
}

View File

@@ -0,0 +1,21 @@
<?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\Dotenv\Exception;
/**
* Interface for exceptions.
*
* @author Fabien Potencier <fabien@symfony.com>
*/
interface ExceptionInterface extends \Throwable
{
}

View File

@@ -0,0 +1,34 @@
<?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\Dotenv\Exception;
/**
* Thrown when a file has a syntax error.
*
* @author Fabien Potencier <fabien@symfony.com>
*/
final class FormatException extends \LogicException implements ExceptionInterface
{
private FormatExceptionContext $context;
public function __construct(string $message, FormatExceptionContext $context, int $code = 0, ?\Throwable $previous = null)
{
$this->context = $context;
parent::__construct(sprintf("%s in \"%s\" at line %d.\n%s", $message, $context->getPath(), $context->getLineno(), $context->getDetails()), $code, $previous);
}
public function getContext(): FormatExceptionContext
{
return $this->context;
}
}

View File

@@ -0,0 +1,49 @@
<?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\Dotenv\Exception;
/**
* @author Fabien Potencier <fabien@symfony.com>
*/
final class FormatExceptionContext
{
private string $data;
private string $path;
private int $lineno;
private int $cursor;
public function __construct(string $data, string $path, int $lineno, int $cursor)
{
$this->data = $data;
$this->path = $path;
$this->lineno = $lineno;
$this->cursor = $cursor;
}
public function getPath(): string
{
return $this->path;
}
public function getLineno(): int
{
return $this->lineno;
}
public function getDetails(): string
{
$before = str_replace("\n", '\n', substr($this->data, max(0, $this->cursor - 20), min(20, $this->cursor)));
$after = str_replace("\n", '\n', substr($this->data, $this->cursor, 20));
return '...'.$before.$after."...\n".str_repeat(' ', \strlen($before) + 2).'^ line '.$this->lineno.' offset '.$this->cursor;
}
}

View File

@@ -0,0 +1,25 @@
<?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\Dotenv\Exception;
/**
* Thrown when a file does not exist or is not readable.
*
* @author Fabien Potencier <fabien@symfony.com>
*/
final class PathException extends \RuntimeException implements ExceptionInterface
{
public function __construct(string $path, int $code = 0, ?\Throwable $previous = null)
{
parent::__construct(sprintf('Unable to read the "%s" environment file.', $path), $code, $previous);
}
}

View File

@@ -0,0 +1,19 @@
Copyright (c) 2016-present Fabien Potencier
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is furnished
to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
THE SOFTWARE.

View File

@@ -0,0 +1,71 @@
<?php
/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Symfony\Contracts\HttpClient;
use Symfony\Contracts\HttpClient\Exception\TransportExceptionInterface;
/**
* The interface of chunks returned by ResponseStreamInterface::current().
*
* When the chunk is first, last or timeout, the content MUST be empty.
* When an unchecked timeout or a network error occurs, a TransportExceptionInterface
* MUST be thrown by the destructor unless one was already thrown by another method.
*
* @author Nicolas Grekas <p@tchwork.com>
*/
interface ChunkInterface
{
/**
* Tells when the idle timeout has been reached.
*
* @throws TransportExceptionInterface on a network error
*/
public function isTimeout(): bool;
/**
* Tells when headers just arrived.
*
* @throws TransportExceptionInterface on a network error or when the idle timeout is reached
*/
public function isFirst(): bool;
/**
* Tells when the body just completed.
*
* @throws TransportExceptionInterface on a network error or when the idle timeout is reached
*/
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.
*
* @throws TransportExceptionInterface on a network error or when the idle timeout is reached
*/
public function getContent(): string;
/**
* Returns the offset of the chunk in the response body.
*/
public function getOffset(): int;
/**
* In case of error, returns the message that describes it.
*/
public function getError(): ?string;
}

View File

@@ -0,0 +1,21 @@
<?php
/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Symfony\Contracts\HttpClient\Exception;
/**
* When a 4xx response is returned.
*
* @author Nicolas Grekas <p@tchwork.com>
*/
interface ClientExceptionInterface extends HttpExceptionInterface
{
}

View File

@@ -0,0 +1,21 @@
<?php
/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Symfony\Contracts\HttpClient\Exception;
/**
* When a content-type cannot be decoded to the expected representation.
*
* @author Nicolas Grekas <p@tchwork.com>
*/
interface DecodingExceptionInterface extends ExceptionInterface
{
}

View File

@@ -0,0 +1,21 @@
<?php
/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Symfony\Contracts\HttpClient\Exception;
/**
* The base interface for all exceptions in the contract.
*
* @author Nicolas Grekas <p@tchwork.com>
*/
interface ExceptionInterface extends \Throwable
{
}

View File

@@ -0,0 +1,24 @@
<?php
/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Symfony\Contracts\HttpClient\Exception;
use Symfony\Contracts\HttpClient\ResponseInterface;
/**
* Base interface for HTTP-related exceptions.
*
* @author Anton Chernikov <anton_ch1989@mail.ru>
*/
interface HttpExceptionInterface extends ExceptionInterface
{
public function getResponse(): ResponseInterface;
}

View File

@@ -0,0 +1,21 @@
<?php
/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Symfony\Contracts\HttpClient\Exception;
/**
* When a 3xx response is returned and the "max_redirects" option has been reached.
*
* @author Nicolas Grekas <p@tchwork.com>
*/
interface RedirectionExceptionInterface extends HttpExceptionInterface
{
}

View File

@@ -0,0 +1,21 @@
<?php
/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Symfony\Contracts\HttpClient\Exception;
/**
* When a 5xx response is returned.
*
* @author Nicolas Grekas <p@tchwork.com>
*/
interface ServerExceptionInterface extends HttpExceptionInterface
{
}

View File

@@ -0,0 +1,21 @@
<?php
/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Symfony\Contracts\HttpClient\Exception;
/**
* When an idle timeout occurs.
*
* @author Nicolas Grekas <p@tchwork.com>
*/
interface TimeoutExceptionInterface extends TransportExceptionInterface
{
}

View File

@@ -0,0 +1,21 @@
<?php
/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Symfony\Contracts\HttpClient\Exception;
/**
* When any error happens at the transport level.
*
* @author Nicolas Grekas <p@tchwork.com>
*/
interface TransportExceptionInterface extends ExceptionInterface
{
}

View File

@@ -0,0 +1,99 @@
<?php
/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Symfony\Contracts\HttpClient;
use Symfony\Contracts\HttpClient\Exception\TransportExceptionInterface;
use Symfony\Contracts\HttpClient\Test\HttpClientTestCase;
/**
* Provides flexible methods for requesting HTTP resources synchronously or asynchronously.
*
* @see HttpClientTestCase for a reference test suite
*
* @author Nicolas Grekas <p@tchwork.com>
*/
interface HttpClientInterface
{
public const OPTIONS_DEFAULTS = [
'auth_basic' => null, // array|string - an array containing the username as first value, and optionally the
// password as the second one; or string like username:password - enabling HTTP Basic
// authentication (RFC 7617)
'auth_bearer' => null, // string - a token enabling HTTP Bearer authorization (RFC 6750)
'query' => [], // string[] - associative array of query string values to merge with the request's URL
'headers' => [], // iterable|string[]|string[][] - headers names provided as keys or as part of values
'body' => '', // array|string|resource|\Traversable|\Closure - the callback SHOULD yield a string
// smaller than the amount requested as argument; the empty string signals EOF; if
// an array is passed, it is meant as a form payload of field names and values
'json' => null, // mixed - if set, implementations MUST set the "body" option to the JSON-encoded
// value and set the "content-type" header to a JSON-compatible value if it is not
// explicitly defined in the headers option - typically "application/json"
'user_data' => null, // mixed - any extra data to attach to the request (scalar, callable, object...) that
// MUST be available via $response->getInfo('user_data') - not used internally
'max_redirects' => 20, // int - the maximum number of redirects to follow; a value lower than or equal to 0
// means redirects should not be followed; "Authorization" and "Cookie" headers MUST
// NOT follow except for the initial host name
'http_version' => null, // string - defaults to the best supported version, typically 1.1 or 2.0
'base_uri' => null, // string - the URI to resolve relative URLs, following rules in RFC 3986, section 2
'buffer' => true, // bool|resource|\Closure - whether the content of the response should be buffered or not,
// or a stream resource where the response body should be written,
// or a closure telling if/where the response should be buffered based on its headers
'on_progress' => null, // callable(int $dlNow, int $dlSize, array $info) - throwing any exceptions MUST abort
// the request; it MUST be called on DNS resolution, on arrival of headers and on
// completion; it SHOULD be called on upload/download of data and at least 1/s
'resolve' => [], // string[] - a map of host to IP address that SHOULD replace DNS resolution
'proxy' => null, // string - by default, the proxy-related env vars handled by curl SHOULD be honored
'no_proxy' => null, // string - a comma separated list of hosts that do not require a proxy to be reached
'timeout' => null, // float - the idle timeout (in seconds) - defaults to ini_get('default_socket_timeout')
'max_duration' => 0, // float - the maximum execution time (in seconds) for the request+response as a whole;
// a value lower than or equal to 0 means it is unlimited
'bindto' => '0', // string - the interface or the local socket to bind to
'verify_peer' => true, // see https://php.net/context.ssl for the following options
'verify_host' => true,
'cafile' => null,
'capath' => null,
'local_cert' => null,
'local_pk' => null,
'passphrase' => null,
'ciphers' => null,
'peer_fingerprint' => null,
'capture_peer_cert_chain' => false,
'crypto_method' => \STREAM_CRYPTO_METHOD_TLSv1_2_CLIENT, // STREAM_CRYPTO_METHOD_TLSv*_CLIENT - minimum TLS version
'extra' => [], // array - additional options that can be ignored if unsupported, unlike regular options
];
/**
* Requests an HTTP resource.
*
* Responses MUST be lazy, but their status code MUST be
* checked even if none of their public methods are called.
*
* Implementations are not required to support all options described above; they can also
* support more custom options; but in any case, they MUST throw a TransportExceptionInterface
* when an unsupported option is passed.
*
* @throws TransportExceptionInterface When an unsupported option is passed
*/
public function request(string $method, string $url, array $options = []): ResponseInterface;
/**
* Yields responses chunk by chunk as they complete.
*
* @param ResponseInterface|iterable<array-key, ResponseInterface> $responses One or more responses created by the current HTTP client
* @param float|null $timeout The idle timeout before yielding timeout chunks
*/
public function stream(ResponseInterface|iterable $responses, ?float $timeout = null): ResponseStreamInterface;
/**
* Returns a new instance of the client with new default options.
*/
public function withOptions(array $options): static;
}

View File

@@ -0,0 +1,19 @@
Copyright (c) 2018-present Fabien Potencier
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is furnished
to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
THE SOFTWARE.

View File

@@ -0,0 +1,109 @@
<?php
/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Symfony\Contracts\HttpClient;
use Symfony\Contracts\HttpClient\Exception\ClientExceptionInterface;
use Symfony\Contracts\HttpClient\Exception\DecodingExceptionInterface;
use Symfony\Contracts\HttpClient\Exception\ExceptionInterface;
use Symfony\Contracts\HttpClient\Exception\RedirectionExceptionInterface;
use Symfony\Contracts\HttpClient\Exception\ServerExceptionInterface;
use Symfony\Contracts\HttpClient\Exception\TransportExceptionInterface;
/**
* A (lazily retrieved) HTTP response.
*
* @author Nicolas Grekas <p@tchwork.com>
*/
interface ResponseInterface
{
/**
* Gets the HTTP status code of the response.
*
* @throws TransportExceptionInterface when a network error occurs
*/
public function getStatusCode(): int;
/**
* Gets the HTTP headers of the response.
*
* @param bool $throw Whether an exception should be thrown on 3/4/5xx status codes
*
* @return string[][] The headers of the response keyed by header names in lowercase
*
* @throws TransportExceptionInterface When a network error occurs
* @throws RedirectionExceptionInterface On a 3xx when $throw is true and the "max_redirects" option has been reached
* @throws ClientExceptionInterface On a 4xx when $throw is true
* @throws ServerExceptionInterface On a 5xx when $throw is true
*/
public function getHeaders(bool $throw = true): array;
/**
* Gets the response body as a string.
*
* @param bool $throw Whether an exception should be thrown on 3/4/5xx status codes
*
* @throws TransportExceptionInterface When a network error occurs
* @throws RedirectionExceptionInterface On a 3xx when $throw is true and the "max_redirects" option has been reached
* @throws ClientExceptionInterface On a 4xx when $throw is true
* @throws ServerExceptionInterface On a 5xx when $throw is true
*/
public function getContent(bool $throw = true): string;
/**
* Gets the response body decoded as array, typically from a JSON payload.
*
* @param bool $throw Whether an exception should be thrown on 3/4/5xx status codes
*
* @throws DecodingExceptionInterface When the body cannot be decoded to an array
* @throws TransportExceptionInterface When a network error occurs
* @throws RedirectionExceptionInterface On a 3xx when $throw is true and the "max_redirects" option has been reached
* @throws ClientExceptionInterface On a 4xx when $throw is true
* @throws ServerExceptionInterface On a 5xx when $throw is true
*/
public function toArray(bool $throw = true): array;
/**
* Closes the response stream and all related buffers.
*
* No further chunk will be yielded after this method has been called.
*/
public function cancel(): void;
/**
* Returns info coming from the transport layer.
*
* This method SHOULD NOT throw any ExceptionInterface and SHOULD be non-blocking.
* The returned info is "live": it can be empty and can change from one call to
* another, as the request/response progresses.
*
* The following info MUST be returned:
* - canceled (bool) - true if the response was canceled using ResponseInterface::cancel(), false otherwise
* - error (string|null) - the error message when the transfer was aborted, null otherwise
* - http_code (int) - the last response code or 0 when it is not known yet
* - http_method (string) - the HTTP verb of the last request
* - redirect_count (int) - the number of redirects followed while executing the request
* - redirect_url (string|null) - the resolved location of redirect responses, null otherwise
* - response_headers (array) - an array modelled after the special $http_response_header variable
* - start_time (float) - the time when the request was sent or 0.0 when it's pending
* - url (string) - the last effective URL of the request
* - user_data (mixed) - the value of the "user_data" request option, null if not set
*
* When the "capture_peer_cert_chain" option is true, the "peer_certificate_chain"
* attribute SHOULD list the peer certificates as an array of OpenSSL X.509 resources.
*
* Other info SHOULD be named after curl_getinfo()'s associative return value.
*
* @return mixed An array of all available info, or one of them when $type is
* provided, or null when an unsupported type is requested
*/
public function getInfo(?string $type = null): mixed;
}

View File

@@ -0,0 +1,26 @@
<?php
/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Symfony\Contracts\HttpClient;
/**
* Yields response chunks, returned by HttpClientInterface::stream().
*
* @author Nicolas Grekas <p@tchwork.com>
*
* @extends \Iterator<ResponseInterface, ChunkInterface>
*/
interface ResponseStreamInterface extends \Iterator
{
public function key(): ResponseInterface;
public function current(): ChunkInterface;
}

View File

@@ -0,0 +1,170 @@
<?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;
use Amp\CancelledException;
use Amp\Http\Client\DelegateHttpClient;
use Amp\Http\Client\InterceptedHttpClient;
use Amp\Http\Client\PooledHttpClient;
use Amp\Http\Client\Request;
use Amp\Http\Tunnel\Http1TunnelConnector;
use Amp\Promise;
use Psr\Log\LoggerAwareInterface;
use Psr\Log\LoggerAwareTrait;
use Symfony\Component\HttpClient\Exception\TransportException;
use Symfony\Component\HttpClient\Internal\AmpClientState;
use Symfony\Component\HttpClient\Response\AmpResponse;
use Symfony\Component\HttpClient\Response\ResponseStream;
use Symfony\Contracts\HttpClient\HttpClientInterface;
use Symfony\Contracts\HttpClient\ResponseInterface;
use Symfony\Contracts\HttpClient\ResponseStreamInterface;
use Symfony\Contracts\Service\ResetInterface;
if (!interface_exists(DelegateHttpClient::class)) {
throw new \LogicException('You cannot use "Symfony\Component\HttpClient\AmpHttpClient" as the "amphp/http-client" package is not installed. Try running "composer require amphp/http-client:^4.2.1".');
}
if (!interface_exists(Promise::class)) {
throw new \LogicException('You cannot use "Symfony\Component\HttpClient\AmpHttpClient" as the installed "amphp/http-client" is not compatible with this version of "symfony/http-client". Try downgrading "amphp/http-client" to "^4.2.1".');
}
/**
* A portable implementation of the HttpClientInterface contracts based on Amp's HTTP client.
*
* @author Nicolas Grekas <p@tchwork.com>
*/
final class AmpHttpClient implements HttpClientInterface, LoggerAwareInterface, ResetInterface
{
use HttpClientTrait;
use LoggerAwareTrait;
private array $defaultOptions = self::OPTIONS_DEFAULTS;
private static array $emptyDefaults = self::OPTIONS_DEFAULTS;
private AmpClientState $multi;
/**
* @param array $defaultOptions Default requests' options
* @param callable|null $clientConfigurator A callable that builds a {@see DelegateHttpClient} from a {@see PooledHttpClient};
* passing null builds an {@see InterceptedHttpClient} with 2 retries on failures
* @param int $maxHostConnections The maximum number of connections to a single host
* @param int $maxPendingPushes The maximum number of pushed responses to accept in the queue
*
* @see HttpClientInterface::OPTIONS_DEFAULTS for available options
*/
public function __construct(array $defaultOptions = [], callable $clientConfigurator = null, int $maxHostConnections = 6, int $maxPendingPushes = 50)
{
$this->defaultOptions['buffer'] ??= self::shouldBuffer(...);
if ($defaultOptions) {
[, $this->defaultOptions] = self::prepareRequest(null, null, $defaultOptions, $this->defaultOptions);
}
$this->multi = new AmpClientState($clientConfigurator, $maxHostConnections, $maxPendingPushes, $this->logger);
}
/**
* @see HttpClientInterface::OPTIONS_DEFAULTS for available options
*/
public function request(string $method, string $url, array $options = []): ResponseInterface
{
[$url, $options] = self::prepareRequest($method, $url, $options, $this->defaultOptions);
$options['proxy'] = self::getProxy($options['proxy'], $url, $options['no_proxy']);
if (null !== $options['proxy'] && !class_exists(Http1TunnelConnector::class)) {
throw new \LogicException('You cannot use the "proxy" option as the "amphp/http-tunnel" package is not installed. Try running "composer require amphp/http-tunnel".');
}
if ($options['bindto']) {
if (str_starts_with($options['bindto'], 'if!')) {
throw new TransportException(__CLASS__.' cannot bind to network interfaces, use e.g. CurlHttpClient instead.');
}
if (str_starts_with($options['bindto'], 'host!')) {
$options['bindto'] = substr($options['bindto'], 5);
}
}
if (('' !== $options['body'] || 'POST' === $method || isset($options['normalized_headers']['content-length'])) && !isset($options['normalized_headers']['content-type'])) {
$options['headers'][] = 'Content-Type: application/x-www-form-urlencoded';
}
if (!isset($options['normalized_headers']['user-agent'])) {
$options['headers'][] = 'User-Agent: Symfony HttpClient/Amp';
}
if (0 < $options['max_duration']) {
$options['timeout'] = min($options['max_duration'], $options['timeout']);
}
if ($options['resolve']) {
$this->multi->dnsCache = $options['resolve'] + $this->multi->dnsCache;
}
if ($options['peer_fingerprint'] && !isset($options['peer_fingerprint']['pin-sha256'])) {
throw new TransportException(__CLASS__.' supports only "pin-sha256" fingerprints.');
}
$request = new Request(implode('', $url), $method);
if ($options['http_version']) {
$request->setProtocolVersions(match ((float) $options['http_version']) {
1.0 => ['1.0'],
1.1 => $request->setProtocolVersions(['1.1', '1.0']),
default => ['2', '1.1', '1.0'],
});
}
foreach ($options['headers'] as $v) {
$h = explode(': ', $v, 2);
$request->addHeader($h[0], $h[1]);
}
$request->setTcpConnectTimeout(1000 * $options['timeout']);
$request->setTlsHandshakeTimeout(1000 * $options['timeout']);
$request->setTransferTimeout(1000 * $options['max_duration']);
if (method_exists($request, 'setInactivityTimeout')) {
$request->setInactivityTimeout(0);
}
if ('' !== $request->getUri()->getUserInfo() && !$request->hasHeader('authorization')) {
$auth = explode(':', $request->getUri()->getUserInfo(), 2);
$auth = array_map('rawurldecode', $auth) + [1 => ''];
$request->setHeader('Authorization', 'Basic '.base64_encode(implode(':', $auth)));
}
return new AmpResponse($this->multi, $request, $options, $this->logger);
}
public function stream(ResponseInterface|iterable $responses, float $timeout = null): ResponseStreamInterface
{
if ($responses instanceof AmpResponse) {
$responses = [$responses];
}
return new ResponseStream(AmpResponse::stream($responses, $timeout));
}
public function reset()
{
$this->multi->dnsCache = [];
foreach ($this->multi->pushedResponses as $authority => $pushedResponses) {
foreach ($pushedResponses as [$pushedUrl, $pushDeferred]) {
$pushDeferred->fail(new CancelledException());
$this->logger?->debug(sprintf('Unused pushed response: "%s"', $pushedUrl));
}
}
$this->multi->pushedResponses = [];
}
}

View File

@@ -0,0 +1,41 @@
<?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;
use Symfony\Component\HttpClient\Response\AsyncResponse;
use Symfony\Component\HttpClient\Response\ResponseStream;
use Symfony\Contracts\HttpClient\ResponseInterface;
use Symfony\Contracts\HttpClient\ResponseStreamInterface;
/**
* Eases with processing responses while streaming them.
*
* @author Nicolas Grekas <p@tchwork.com>
*/
trait AsyncDecoratorTrait
{
use DecoratorTrait;
/**
* @return AsyncResponse
*/
abstract public function request(string $method, string $url, array $options = []): ResponseInterface;
public function stream(ResponseInterface|iterable $responses, float $timeout = null): ResponseStreamInterface
{
if ($responses instanceof AsyncResponse) {
$responses = [$responses];
}
return new ResponseStream(AsyncResponse::stream($responses, $timeout, static::class));
}
}

View File

@@ -0,0 +1,144 @@
<?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;
use Symfony\Component\HttpClient\Response\MockResponse;
use Symfony\Component\HttpClient\Response\ResponseStream;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpKernel\HttpCache\HttpCache;
use Symfony\Component\HttpKernel\HttpCache\StoreInterface;
use Symfony\Component\HttpKernel\HttpClientKernel;
use Symfony\Contracts\HttpClient\HttpClientInterface;
use Symfony\Contracts\HttpClient\ResponseInterface;
use Symfony\Contracts\HttpClient\ResponseStreamInterface;
use Symfony\Contracts\Service\ResetInterface;
/**
* Adds caching on top of an HTTP client.
*
* The implementation buffers responses in memory and doesn't stream directly from the network.
* You can disable/enable this layer by setting option "no_cache" under "extra" to true/false.
* By default, caching is enabled unless the "buffer" option is set to false.
*
* @author Nicolas Grekas <p@tchwork.com>
*/
class CachingHttpClient implements HttpClientInterface, ResetInterface
{
use HttpClientTrait;
private HttpClientInterface $client;
private HttpCache $cache;
private array $defaultOptions = self::OPTIONS_DEFAULTS;
public function __construct(HttpClientInterface $client, StoreInterface $store, array $defaultOptions = [])
{
if (!class_exists(HttpClientKernel::class)) {
throw new \LogicException(sprintf('Using "%s" requires that the HttpKernel component version 4.3 or higher is installed, try running "composer require symfony/http-kernel:^5.4".', __CLASS__));
}
$this->client = $client;
$kernel = new HttpClientKernel($client);
$this->cache = new HttpCache($kernel, $store, null, $defaultOptions);
unset($defaultOptions['debug']);
unset($defaultOptions['default_ttl']);
unset($defaultOptions['private_headers']);
unset($defaultOptions['allow_reload']);
unset($defaultOptions['allow_revalidate']);
unset($defaultOptions['stale_while_revalidate']);
unset($defaultOptions['stale_if_error']);
unset($defaultOptions['trace_level']);
unset($defaultOptions['trace_header']);
if ($defaultOptions) {
[, $this->defaultOptions] = self::prepareRequest(null, null, $defaultOptions, $this->defaultOptions);
}
}
public function request(string $method, string $url, array $options = []): ResponseInterface
{
[$url, $options] = $this->prepareRequest($method, $url, $options, $this->defaultOptions, true);
$url = implode('', $url);
if (!empty($options['body']) || !empty($options['extra']['no_cache']) || !\in_array($method, ['GET', 'HEAD', 'OPTIONS'])) {
return $this->client->request($method, $url, $options);
}
$request = Request::create($url, $method);
$request->attributes->set('http_client_options', $options);
foreach ($options['normalized_headers'] as $name => $values) {
if ('cookie' !== $name) {
foreach ($values as $value) {
$request->headers->set($name, substr($value, 2 + \strlen($name)), false);
}
continue;
}
foreach ($values as $cookies) {
foreach (explode('; ', substr($cookies, \strlen('Cookie: '))) as $cookie) {
if ('' !== $cookie) {
$cookie = explode('=', $cookie, 2);
$request->cookies->set($cookie[0], $cookie[1] ?? '');
}
}
}
}
$response = $this->cache->handle($request);
$response = new MockResponse($response->getContent(), [
'http_code' => $response->getStatusCode(),
'response_headers' => $response->headers->allPreserveCase(),
]);
return MockResponse::fromRequest($method, $url, $options, $response);
}
public function stream(ResponseInterface|iterable $responses, float $timeout = null): ResponseStreamInterface
{
if ($responses instanceof ResponseInterface) {
$responses = [$responses];
}
$mockResponses = [];
$clientResponses = [];
foreach ($responses as $response) {
if ($response instanceof MockResponse) {
$mockResponses[] = $response;
} else {
$clientResponses[] = $response;
}
}
if (!$mockResponses) {
return $this->client->stream($clientResponses, $timeout);
}
if (!$clientResponses) {
return new ResponseStream(MockResponse::stream($mockResponses, $timeout));
}
return new ResponseStream((function () use ($mockResponses, $clientResponses, $timeout) {
yield from MockResponse::stream($mockResponses, $timeout);
yield $this->client->stream($clientResponses, $timeout);
})());
}
public function reset()
{
if ($this->client instanceof ResetInterface) {
$this->client->reset();
}
}
}

View File

@@ -0,0 +1,66 @@
<?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;
use Symfony\Contracts\HttpClient\ChunkInterface;
/**
* @author Nicolas Grekas <p@tchwork.com>
*
* @internal
*/
class DataChunk implements ChunkInterface
{
private int $offset = 0;
private string $content = '';
public function __construct(int $offset = 0, string $content = '')
{
$this->offset = $offset;
$this->content = $content;
}
public function isTimeout(): bool
{
return false;
}
public function isFirst(): bool
{
return false;
}
public function isLast(): bool
{
return false;
}
public function getInformationalStatus(): ?array
{
return null;
}
public function getContent(): string
{
return $this->content;
}
public function getOffset(): int
{
return $this->offset;
}
public function getError(): ?string
{
return null;
}
}

View File

@@ -0,0 +1,113 @@
<?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;
use Symfony\Component\HttpClient\Exception\TimeoutException;
use Symfony\Component\HttpClient\Exception\TransportException;
use Symfony\Contracts\HttpClient\ChunkInterface;
/**
* @author Nicolas Grekas <p@tchwork.com>
*
* @internal
*/
class ErrorChunk implements ChunkInterface
{
private bool $didThrow = false;
private int $offset;
private string $errorMessage;
private ?\Throwable $error = null;
public function __construct(int $offset, \Throwable|string $error)
{
$this->offset = $offset;
if (\is_string($error)) {
$this->errorMessage = $error;
} else {
$this->error = $error;
$this->errorMessage = $error->getMessage();
}
}
public function isTimeout(): bool
{
$this->didThrow = true;
if (null !== $this->error) {
throw new TransportException($this->errorMessage, 0, $this->error);
}
return true;
}
public function isFirst(): bool
{
$this->didThrow = true;
throw null !== $this->error ? new TransportException($this->errorMessage, 0, $this->error) : new TimeoutException($this->errorMessage);
}
public function isLast(): bool
{
$this->didThrow = true;
throw null !== $this->error ? new TransportException($this->errorMessage, 0, $this->error) : new TimeoutException($this->errorMessage);
}
public function getInformationalStatus(): ?array
{
$this->didThrow = true;
throw null !== $this->error ? new TransportException($this->errorMessage, 0, $this->error) : new TimeoutException($this->errorMessage);
}
public function getContent(): string
{
$this->didThrow = true;
throw null !== $this->error ? new TransportException($this->errorMessage, 0, $this->error) : new TimeoutException($this->errorMessage);
}
public function getOffset(): int
{
return $this->offset;
}
public function getError(): ?string
{
return $this->errorMessage;
}
public function didThrow(bool $didThrow = null): bool
{
if (null !== $didThrow && $this->didThrow !== $didThrow) {
return !$this->didThrow = $didThrow;
}
return $this->didThrow;
}
public function __sleep(): array
{
throw new \BadMethodCallException('Cannot serialize '.__CLASS__);
}
public function __wakeup()
{
throw new \BadMethodCallException('Cannot unserialize '.__CLASS__);
}
public function __destruct()
{
if (!$this->didThrow) {
$this->didThrow = true;
throw null !== $this->error ? new TransportException($this->errorMessage, 0, $this->error) : new TimeoutException($this->errorMessage);
}
}
}

View File

@@ -0,0 +1,25 @@
<?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 FirstChunk extends DataChunk
{
public function isFirst(): bool
{
return true;
}
}

View File

@@ -0,0 +1,32 @@
<?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 array $status;
public function __construct(int $statusCode, array $headers)
{
$this->status = [$statusCode, $headers];
}
public function getInformationalStatus(): ?array
{
return $this->status;
}
}

View File

@@ -0,0 +1,25 @@
<?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 LastChunk extends DataChunk
{
public function isLast(): bool
{
return true;
}
}

View File

@@ -0,0 +1,79 @@
<?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;
use Symfony\Contracts\HttpClient\ChunkInterface;
/**
* @author Antoine Bluchet <soyuka@gmail.com>
* @author Nicolas Grekas <p@tchwork.com>
*/
final class ServerSentEvent extends DataChunk implements ChunkInterface
{
private string $data = '';
private string $id = '';
private string $type = 'message';
private float $retry = 0;
public function __construct(string $content)
{
parent::__construct(-1, $content);
// remove BOM
if (str_starts_with($content, "\xEF\xBB\xBF")) {
$content = substr($content, 3);
}
foreach (preg_split("/(?:\r\n|[\r\n])/", $content) as $line) {
if (0 === $i = strpos($line, ':')) {
continue;
}
$i = false === $i ? \strlen($line) : $i;
$field = substr($line, 0, $i);
$i += 1 + (' ' === ($line[1 + $i] ?? ''));
switch ($field) {
case 'id': $this->id = substr($line, $i); break;
case 'event': $this->type = substr($line, $i); break;
case 'data': $this->data .= ('' === $this->data ? '' : "\n").substr($line, $i); break;
case 'retry':
$retry = substr($line, $i);
if ('' !== $retry && \strlen($retry) === strspn($retry, '0123456789')) {
$this->retry = $retry / 1000.0;
}
break;
}
}
}
public function getId(): string
{
return $this->id;
}
public function getType(): string
{
return $this->type;
}
public function getData(): string
{
return $this->data;
}
public function getRetry(): float
{
return $this->retry;
}
}

View File

@@ -0,0 +1,542 @@
<?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;
use Psr\Log\LoggerAwareInterface;
use Psr\Log\LoggerInterface;
use Symfony\Component\HttpClient\Exception\InvalidArgumentException;
use Symfony\Component\HttpClient\Exception\TransportException;
use Symfony\Component\HttpClient\Internal\CurlClientState;
use Symfony\Component\HttpClient\Internal\PushedResponse;
use Symfony\Component\HttpClient\Response\CurlResponse;
use Symfony\Component\HttpClient\Response\ResponseStream;
use Symfony\Contracts\HttpClient\HttpClientInterface;
use Symfony\Contracts\HttpClient\ResponseInterface;
use Symfony\Contracts\HttpClient\ResponseStreamInterface;
use Symfony\Contracts\Service\ResetInterface;
/**
* A performant implementation of the HttpClientInterface contracts based on the curl extension.
*
* This provides fully concurrent HTTP requests, with transparent
* HTTP/2 push when a curl version that supports it is installed.
*
* @author Nicolas Grekas <p@tchwork.com>
*/
final class CurlHttpClient implements HttpClientInterface, LoggerAwareInterface, ResetInterface
{
use HttpClientTrait;
private array $defaultOptions = self::OPTIONS_DEFAULTS + [
'auth_ntlm' => null, // array|string - an array containing the username as first value, and optionally the
// password as the second one; or string like username:password - enabling NTLM auth
'extra' => [
'curl' => [], // A list of extra curl options indexed by their corresponding CURLOPT_*
],
];
private static array $emptyDefaults = self::OPTIONS_DEFAULTS + ['auth_ntlm' => null];
private ?LoggerInterface $logger = null;
/**
* An internal object to share state between the client and its responses.
*/
private CurlClientState $multi;
/**
* @param array $defaultOptions Default request's options
* @param int $maxHostConnections The maximum number of connections to a single host
* @param int $maxPendingPushes The maximum number of pushed responses to accept in the queue
*
* @see HttpClientInterface::OPTIONS_DEFAULTS for available options
*/
public function __construct(array $defaultOptions = [], int $maxHostConnections = 6, int $maxPendingPushes = 50)
{
if (!\extension_loaded('curl')) {
throw new \LogicException('You cannot use the "Symfony\Component\HttpClient\CurlHttpClient" as the "curl" extension is not installed.');
}
$this->defaultOptions['buffer'] ??= self::shouldBuffer(...);
if ($defaultOptions) {
[, $this->defaultOptions] = self::prepareRequest(null, null, $defaultOptions, $this->defaultOptions);
}
$this->multi = new CurlClientState($maxHostConnections, $maxPendingPushes);
}
public function setLogger(LoggerInterface $logger): void
{
$this->logger = $this->multi->logger = $logger;
}
/**
* @see HttpClientInterface::OPTIONS_DEFAULTS for available options
*/
public function request(string $method, string $url, array $options = []): ResponseInterface
{
[$url, $options] = self::prepareRequest($method, $url, $options, $this->defaultOptions);
$scheme = $url['scheme'];
$authority = $url['authority'];
$host = parse_url($authority, \PHP_URL_HOST);
$port = parse_url($authority, \PHP_URL_PORT) ?: ('http:' === $scheme ? 80 : 443);
$proxy = self::getProxyUrl($options['proxy'], $url);
$url = implode('', $url);
if (!isset($options['normalized_headers']['user-agent'])) {
$options['headers'][] = 'User-Agent: Symfony HttpClient/Curl';
}
$curlopts = [
\CURLOPT_URL => $url,
\CURLOPT_TCP_NODELAY => true,
\CURLOPT_PROTOCOLS => \CURLPROTO_HTTP | \CURLPROTO_HTTPS,
\CURLOPT_REDIR_PROTOCOLS => \CURLPROTO_HTTP | \CURLPROTO_HTTPS,
\CURLOPT_FOLLOWLOCATION => true,
\CURLOPT_MAXREDIRS => 0 < $options['max_redirects'] ? $options['max_redirects'] : 0,
\CURLOPT_COOKIEFILE => '', // Keep track of cookies during redirects
\CURLOPT_TIMEOUT => 0,
\CURLOPT_PROXY => $proxy,
\CURLOPT_NOPROXY => $options['no_proxy'] ?? $_SERVER['no_proxy'] ?? $_SERVER['NO_PROXY'] ?? '',
\CURLOPT_SSL_VERIFYPEER => $options['verify_peer'],
\CURLOPT_SSL_VERIFYHOST => $options['verify_host'] ? 2 : 0,
\CURLOPT_CAINFO => $options['cafile'],
\CURLOPT_CAPATH => $options['capath'],
\CURLOPT_SSL_CIPHER_LIST => $options['ciphers'],
\CURLOPT_SSLCERT => $options['local_cert'],
\CURLOPT_SSLKEY => $options['local_pk'],
\CURLOPT_KEYPASSWD => $options['passphrase'],
\CURLOPT_CERTINFO => $options['capture_peer_cert_chain'],
];
if (1.0 === (float) $options['http_version']) {
$curlopts[\CURLOPT_HTTP_VERSION] = \CURL_HTTP_VERSION_1_0;
} elseif (1.1 === (float) $options['http_version']) {
$curlopts[\CURLOPT_HTTP_VERSION] = \CURL_HTTP_VERSION_1_1;
} elseif (\defined('CURL_VERSION_HTTP2') && (\CURL_VERSION_HTTP2 & CurlClientState::$curlVersion['features']) && ('https:' === $scheme || 2.0 === (float) $options['http_version'])) {
$curlopts[\CURLOPT_HTTP_VERSION] = \CURL_HTTP_VERSION_2_0;
}
if (isset($options['auth_ntlm'])) {
$curlopts[\CURLOPT_HTTPAUTH] = \CURLAUTH_NTLM;
$curlopts[\CURLOPT_HTTP_VERSION] = \CURL_HTTP_VERSION_1_1;
if (\is_array($options['auth_ntlm'])) {
$count = \count($options['auth_ntlm']);
if ($count <= 0 || $count > 2) {
throw new InvalidArgumentException(sprintf('Option "auth_ntlm" must contain 1 or 2 elements, %d given.', $count));
}
$options['auth_ntlm'] = implode(':', $options['auth_ntlm']);
}
if (!\is_string($options['auth_ntlm'])) {
throw new InvalidArgumentException(sprintf('Option "auth_ntlm" must be a string or an array, "%s" given.', get_debug_type($options['auth_ntlm'])));
}
$curlopts[\CURLOPT_USERPWD] = $options['auth_ntlm'];
}
if (!\ZEND_THREAD_SAFE) {
$curlopts[\CURLOPT_DNS_USE_GLOBAL_CACHE] = false;
}
if (\defined('CURLOPT_HEADEROPT') && \defined('CURLHEADER_SEPARATE')) {
$curlopts[\CURLOPT_HEADEROPT] = \CURLHEADER_SEPARATE;
}
// curl's resolve feature varies by host:port but ours varies by host only, let's handle this with our own DNS map
if (isset($this->multi->dnsCache->hostnames[$host])) {
$options['resolve'] += [$host => $this->multi->dnsCache->hostnames[$host]];
}
if ($options['resolve'] || $this->multi->dnsCache->evictions) {
// First reset any old DNS cache entries then add the new ones
$resolve = $this->multi->dnsCache->evictions;
$this->multi->dnsCache->evictions = [];
if ($resolve && 0x072A00 > CurlClientState::$curlVersion['version_number']) {
// DNS cache removals require curl 7.42 or higher
$this->multi->reset();
}
foreach ($options['resolve'] as $host => $ip) {
$resolve[] = null === $ip ? "-$host:$port" : "$host:$port:$ip";
$this->multi->dnsCache->hostnames[$host] = $ip;
$this->multi->dnsCache->removals["-$host:$port"] = "-$host:$port";
}
$curlopts[\CURLOPT_RESOLVE] = $resolve;
}
if ('POST' === $method) {
// Use CURLOPT_POST to have browser-like POST-to-GET redirects for 301, 302 and 303
$curlopts[\CURLOPT_POST] = true;
} elseif ('HEAD' === $method) {
$curlopts[\CURLOPT_NOBODY] = true;
} else {
$curlopts[\CURLOPT_CUSTOMREQUEST] = $method;
}
if ('\\' !== \DIRECTORY_SEPARATOR && $options['timeout'] < 1) {
$curlopts[\CURLOPT_NOSIGNAL] = true;
}
if (\extension_loaded('zlib') && !isset($options['normalized_headers']['accept-encoding'])) {
$options['headers'][] = 'Accept-Encoding: gzip'; // Expose only one encoding, some servers mess up when more are provided
}
$body = $options['body'];
foreach ($options['headers'] as $i => $header) {
if (\is_string($body) && '' !== $body && 0 === stripos($header, 'Content-Length: ')) {
// Let curl handle Content-Length headers
unset($options['headers'][$i]);
continue;
}
if (':' === $header[-2] && \strlen($header) - 2 === strpos($header, ': ')) {
// curl requires a special syntax to send empty headers
$curlopts[\CURLOPT_HTTPHEADER][] = substr_replace($header, ';', -2);
} else {
$curlopts[\CURLOPT_HTTPHEADER][] = $header;
}
}
// Prevent curl from sending its default Accept and Expect headers
foreach (['accept', 'expect'] as $header) {
if (!isset($options['normalized_headers'][$header][0])) {
$curlopts[\CURLOPT_HTTPHEADER][] = $header.':';
}
}
if (!\is_string($body)) {
if (\is_resource($body)) {
$curlopts[\CURLOPT_INFILE] = $body;
} else {
$eof = false;
$buffer = '';
$curlopts[\CURLOPT_READFUNCTION] = static function ($ch, $fd, $length) use ($body, &$buffer, &$eof) {
return self::readRequestBody($length, $body, $buffer, $eof);
};
}
if (isset($options['normalized_headers']['content-length'][0])) {
$curlopts[\CURLOPT_INFILESIZE] = (int) substr($options['normalized_headers']['content-length'][0], \strlen('Content-Length: '));
}
if (!isset($options['normalized_headers']['transfer-encoding'])) {
$curlopts[\CURLOPT_HTTPHEADER][] = 'Transfer-Encoding:'.(isset($curlopts[\CURLOPT_INFILESIZE]) ? '' : ' chunked');
}
if ('POST' !== $method) {
$curlopts[\CURLOPT_UPLOAD] = true;
if (!isset($options['normalized_headers']['content-type']) && 0 !== ($curlopts[\CURLOPT_INFILESIZE] ?? null)) {
$curlopts[\CURLOPT_HTTPHEADER][] = 'Content-Type: application/x-www-form-urlencoded';
}
}
} elseif ('' !== $body || 'POST' === $method) {
$curlopts[\CURLOPT_POSTFIELDS] = $body;
}
if ($options['peer_fingerprint']) {
if (!isset($options['peer_fingerprint']['pin-sha256'])) {
throw new TransportException(__CLASS__.' supports only "pin-sha256" fingerprints.');
}
$curlopts[\CURLOPT_PINNEDPUBLICKEY] = 'sha256//'.implode(';sha256//', $options['peer_fingerprint']['pin-sha256']);
}
if ($options['bindto']) {
if (file_exists($options['bindto'])) {
$curlopts[\CURLOPT_UNIX_SOCKET_PATH] = $options['bindto'];
} elseif (!str_starts_with($options['bindto'], 'if!') && preg_match('/^(.*):(\d+)$/', $options['bindto'], $matches)) {
$curlopts[\CURLOPT_INTERFACE] = $matches[1];
$curlopts[\CURLOPT_LOCALPORT] = $matches[2];
} else {
$curlopts[\CURLOPT_INTERFACE] = $options['bindto'];
}
}
if (0 < $options['max_duration']) {
$curlopts[\CURLOPT_TIMEOUT_MS] = 1000 * $options['max_duration'];
}
if (!empty($options['extra']['curl']) && \is_array($options['extra']['curl'])) {
$this->validateExtraCurlOptions($options['extra']['curl']);
$curlopts += $options['extra']['curl'];
}
if ($pushedResponse = $this->multi->pushedResponses[$url] ?? null) {
unset($this->multi->pushedResponses[$url]);
if (self::acceptPushForRequest($method, $options, $pushedResponse)) {
$this->logger?->debug(sprintf('Accepting pushed response: "%s %s"', $method, $url));
// Reinitialize the pushed response with request's options
$ch = $pushedResponse->handle;
$pushedResponse = $pushedResponse->response;
$pushedResponse->__construct($this->multi, $url, $options, $this->logger);
} else {
$this->logger?->debug(sprintf('Rejecting pushed response: "%s"', $url));
$pushedResponse = null;
}
}
if (!$pushedResponse) {
$ch = curl_init();
$this->logger?->info(sprintf('Request: "%s %s"', $method, $url));
$curlopts += [\CURLOPT_SHARE => $this->multi->share];
}
foreach ($curlopts as $opt => $value) {
if (null !== $value && !curl_setopt($ch, $opt, $value) && \CURLOPT_CERTINFO !== $opt && (!\defined('CURLOPT_HEADEROPT') || \CURLOPT_HEADEROPT !== $opt)) {
$constantName = $this->findConstantName($opt);
throw new TransportException(sprintf('Curl option "%s" is not supported.', $constantName ?? $opt));
}
}
return $pushedResponse ?? new CurlResponse($this->multi, $ch, $options, $this->logger, $method, self::createRedirectResolver($options, $host, $port), CurlClientState::$curlVersion['version_number'], $url);
}
public function stream(ResponseInterface|iterable $responses, float $timeout = null): ResponseStreamInterface
{
if ($responses instanceof CurlResponse) {
$responses = [$responses];
}
if ($this->multi->handle instanceof \CurlMultiHandle) {
$active = 0;
while (\CURLM_CALL_MULTI_PERFORM === curl_multi_exec($this->multi->handle, $active)) {
}
}
return new ResponseStream(CurlResponse::stream($responses, $timeout));
}
public function reset()
{
$this->multi->reset();
}
/**
* 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', 'local_cert', 'local_pk'] 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.
*/
private static function readRequestBody(int $length, \Closure $body, string &$buffer, bool &$eof): string
{
if (!$eof && \strlen($buffer) < $length) {
if (!\is_string($data = $body($length))) {
throw new TransportException(sprintf('The return value of the "body" option callback must be a string, "%s" returned.', get_debug_type($data)));
}
$buffer .= $data;
$eof = '' === $data;
}
$data = substr($buffer, 0, $length);
$buffer = substr($buffer, $length);
return $data;
}
/**
* Resolves relative URLs on redirects and deals with authentication headers.
*
* Work around CVE-2018-1000007: Authorization and Cookie headers should not follow redirects - fixed in Curl 7.64
*/
private static function createRedirectResolver(array $options, string $host, int $port): \Closure
{
$redirectHeaders = [];
if (0 < $options['max_redirects']) {
$redirectHeaders['host'] = $host;
$redirectHeaders['port'] = $port;
$redirectHeaders['with_auth'] = $redirectHeaders['no_auth'] = array_filter($options['headers'], static function ($h) {
return 0 !== stripos($h, 'Host:');
});
if (isset($options['normalized_headers']['authorization'][0]) || isset($options['normalized_headers']['cookie'][0])) {
$redirectHeaders['no_auth'] = array_filter($options['headers'], static function ($h) {
return 0 !== stripos($h, 'Authorization:') && 0 !== stripos($h, 'Cookie:');
});
}
}
return static function ($ch, string $location, bool $noContent) use (&$redirectHeaders, $options) {
try {
$location = self::parseUrl($location);
} catch (InvalidArgumentException) {
return null;
}
if ($noContent && $redirectHeaders) {
$filterContentHeaders = static function ($h) {
return 0 !== stripos($h, 'Content-Length:') && 0 !== stripos($h, 'Content-Type:') && 0 !== stripos($h, 'Transfer-Encoding:');
};
$redirectHeaders['no_auth'] = array_filter($redirectHeaders['no_auth'], $filterContentHeaders);
$redirectHeaders['with_auth'] = array_filter($redirectHeaders['with_auth'], $filterContentHeaders);
}
if ($redirectHeaders && $host = parse_url('http:'.$location['authority'], \PHP_URL_HOST)) {
$port = parse_url('http:'.$location['authority'], \PHP_URL_PORT) ?: ('http:' === $location['scheme'] ? 80 : 443);
$requestHeaders = $redirectHeaders['host'] === $host && $redirectHeaders['port'] === $port ? $redirectHeaders['with_auth'] : $redirectHeaders['no_auth'];
curl_setopt($ch, \CURLOPT_HTTPHEADER, $requestHeaders);
} elseif ($noContent && $redirectHeaders) {
curl_setopt($ch, \CURLOPT_HTTPHEADER, $redirectHeaders['with_auth']);
}
$url = self::parseUrl(curl_getinfo($ch, \CURLINFO_EFFECTIVE_URL));
$url = self::resolveUrl($location, $url);
curl_setopt($ch, \CURLOPT_PROXY, self::getProxyUrl($options['proxy'], $url));
return implode('', $url);
};
}
private function findConstantName(int $opt): ?string
{
$constants = array_filter(get_defined_constants(), static function ($v, $k) use ($opt) {
return $v === $opt && 'C' === $k[0] && (str_starts_with($k, 'CURLOPT_') || str_starts_with($k, 'CURLINFO_'));
}, \ARRAY_FILTER_USE_BOTH);
return key($constants);
}
/**
* Prevents overriding options that are set internally throughout the request.
*/
private function validateExtraCurlOptions(array $options): void
{
$curloptsToConfig = [
// options used in CurlHttpClient
\CURLOPT_HTTPAUTH => 'auth_ntlm',
\CURLOPT_USERPWD => 'auth_ntlm',
\CURLOPT_RESOLVE => 'resolve',
\CURLOPT_NOSIGNAL => 'timeout',
\CURLOPT_HTTPHEADER => 'headers',
\CURLOPT_INFILE => 'body',
\CURLOPT_READFUNCTION => 'body',
\CURLOPT_INFILESIZE => 'body',
\CURLOPT_POSTFIELDS => 'body',
\CURLOPT_UPLOAD => 'body',
\CURLOPT_INTERFACE => 'bindto',
\CURLOPT_TIMEOUT_MS => 'max_duration',
\CURLOPT_TIMEOUT => 'max_duration',
\CURLOPT_MAXREDIRS => 'max_redirects',
\CURLOPT_POSTREDIR => 'max_redirects',
\CURLOPT_PROXY => 'proxy',
\CURLOPT_NOPROXY => 'no_proxy',
\CURLOPT_SSL_VERIFYPEER => 'verify_peer',
\CURLOPT_SSL_VERIFYHOST => 'verify_host',
\CURLOPT_CAINFO => 'cafile',
\CURLOPT_CAPATH => 'capath',
\CURLOPT_SSL_CIPHER_LIST => 'ciphers',
\CURLOPT_SSLCERT => 'local_cert',
\CURLOPT_SSLKEY => 'local_pk',
\CURLOPT_KEYPASSWD => 'passphrase',
\CURLOPT_CERTINFO => 'capture_peer_cert_chain',
\CURLOPT_USERAGENT => 'normalized_headers',
\CURLOPT_REFERER => 'headers',
// options used in CurlResponse
\CURLOPT_NOPROGRESS => 'on_progress',
\CURLOPT_PROGRESSFUNCTION => 'on_progress',
];
if (\defined('CURLOPT_UNIX_SOCKET_PATH')) {
$curloptsToConfig[\CURLOPT_UNIX_SOCKET_PATH] = 'bindto';
}
if (\defined('CURLOPT_PINNEDPUBLICKEY')) {
$curloptsToConfig[\CURLOPT_PINNEDPUBLICKEY] = 'peer_fingerprint';
}
$curloptsToCheck = [
\CURLOPT_PRIVATE,
\CURLOPT_HEADERFUNCTION,
\CURLOPT_WRITEFUNCTION,
\CURLOPT_VERBOSE,
\CURLOPT_STDERR,
\CURLOPT_RETURNTRANSFER,
\CURLOPT_URL,
\CURLOPT_FOLLOWLOCATION,
\CURLOPT_HEADER,
\CURLOPT_CONNECTTIMEOUT,
\CURLOPT_CONNECTTIMEOUT_MS,
\CURLOPT_HTTP_VERSION,
\CURLOPT_PORT,
\CURLOPT_DNS_USE_GLOBAL_CACHE,
\CURLOPT_PROTOCOLS,
\CURLOPT_REDIR_PROTOCOLS,
\CURLOPT_COOKIEFILE,
\CURLINFO_REDIRECT_COUNT,
];
if (\defined('CURLOPT_HTTP09_ALLOWED')) {
$curloptsToCheck[] = \CURLOPT_HTTP09_ALLOWED;
}
if (\defined('CURLOPT_HEADEROPT')) {
$curloptsToCheck[] = \CURLOPT_HEADEROPT;
}
$methodOpts = [
\CURLOPT_POST,
\CURLOPT_PUT,
\CURLOPT_CUSTOMREQUEST,
\CURLOPT_HTTPGET,
\CURLOPT_NOBODY,
];
foreach ($options as $opt => $optValue) {
if (isset($curloptsToConfig[$opt])) {
$constName = $this->findConstantName($opt) ?? $opt;
throw new InvalidArgumentException(sprintf('Cannot set "%s" with "extra.curl", use option "%s" instead.', $constName, $curloptsToConfig[$opt]));
}
if (\in_array($opt, $methodOpts)) {
throw new InvalidArgumentException('The HTTP method cannot be overridden using "extra.curl".');
}
if (\in_array($opt, $curloptsToCheck)) {
$constName = $this->findConstantName($opt) ?? $opt;
throw new InvalidArgumentException(sprintf('Cannot set "%s" with "extra.curl".', $constName));
}
}
}
}

View File

@@ -0,0 +1,262 @@
<?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\DataCollector;
use Symfony\Component\HttpClient\HttpClientTrait;
use Symfony\Component\HttpClient\TraceableHttpClient;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpKernel\DataCollector\DataCollector;
use Symfony\Component\HttpKernel\DataCollector\LateDataCollectorInterface;
use Symfony\Component\VarDumper\Caster\ImgStub;
/**
* @author Jérémy Romey <jeremy@free-agent.fr>
*/
final class HttpClientDataCollector extends DataCollector implements LateDataCollectorInterface
{
use HttpClientTrait;
/**
* @var TraceableHttpClient[]
*/
private array $clients = [];
public function registerClient(string $name, TraceableHttpClient $client)
{
$this->clients[$name] = $client;
}
public function collect(Request $request, Response $response, \Throwable $exception = null)
{
$this->lateCollect();
}
public function lateCollect()
{
$this->data['request_count'] = $this->data['request_count'] ?? 0;
$this->data['error_count'] = $this->data['error_count'] ?? 0;
$this->data += ['clients' => []];
foreach ($this->clients as $name => $client) {
[$errorCount, $traces] = $this->collectOnClient($client);
$this->data['clients'] += [
$name => [
'traces' => [],
'error_count' => 0,
],
];
$this->data['clients'][$name]['traces'] = array_merge($this->data['clients'][$name]['traces'], $traces);
$this->data['request_count'] += \count($traces);
$this->data['error_count'] += $errorCount;
$this->data['clients'][$name]['error_count'] += $errorCount;
$client->reset();
}
}
public function getClients(): array
{
return $this->data['clients'] ?? [];
}
public function getRequestCount(): int
{
return $this->data['request_count'] ?? 0;
}
public function getErrorCount(): int
{
return $this->data['error_count'] ?? 0;
}
public function getName(): string
{
return 'http_client';
}
public function reset()
{
$this->data = [
'clients' => [],
'request_count' => 0,
'error_count' => 0,
];
}
private function collectOnClient(TraceableHttpClient $client): array
{
$traces = $client->getTracedRequests();
$errorCount = 0;
$baseInfo = [
'response_headers' => 1,
'retry_count' => 1,
'redirect_count' => 1,
'redirect_url' => 1,
'user_data' => 1,
'error' => 1,
'url' => 1,
];
foreach ($traces as $i => $trace) {
if (400 <= ($trace['info']['http_code'] ?? 0)) {
++$errorCount;
}
$info = $trace['info'];
$traces[$i]['http_code'] = $info['http_code'] ?? 0;
unset($info['filetime'], $info['http_code'], $info['ssl_verify_result'], $info['content_type']);
if (($info['http_method'] ?? null) === $trace['method']) {
unset($info['http_method']);
}
if (($info['url'] ?? null) === $trace['url']) {
unset($info['url']);
}
foreach ($info as $k => $v) {
if (!$v || (is_numeric($v) && 0 > $v)) {
unset($info[$k]);
}
}
if (\is_string($content = $trace['content'])) {
$contentType = 'application/octet-stream';
foreach ($info['response_headers'] ?? [] as $h) {
if (0 === stripos($h, 'content-type: ')) {
$contentType = substr($h, \strlen('content-type: '));
break;
}
}
if (str_starts_with($contentType, 'image/') && class_exists(ImgStub::class)) {
$content = new ImgStub($content, $contentType, '');
} else {
$content = [$content];
}
$content = ['response_content' => $content];
} elseif (\is_array($content)) {
$content = ['response_json' => $content];
} else {
$content = [];
}
if (isset($info['retry_count'])) {
$content['retries'] = $info['previous_info'];
unset($info['previous_info']);
}
$debugInfo = array_diff_key($info, $baseInfo);
$info = ['info' => $debugInfo] + array_diff_key($info, $debugInfo) + $content;
unset($traces[$i]['info']); // break PHP reference used by TraceableHttpClient
$traces[$i]['info'] = $this->cloneVar($info);
$traces[$i]['options'] = $this->cloneVar($trace['options']);
$traces[$i]['curlCommand'] = $this->getCurlCommand($trace);
}
return [$errorCount, $traces];
}
private function getCurlCommand(array $trace): ?string
{
if (!isset($trace['info']['debug'])) {
return null;
}
$url = $trace['info']['original_url'] ?? $trace['info']['url'] ?? $trace['url'];
$command = ['curl', '--compressed'];
if (isset($trace['options']['resolve'])) {
$port = parse_url($url, \PHP_URL_PORT) ?: (str_starts_with('http:', $url) ? 80 : 443);
foreach ($trace['options']['resolve'] as $host => $ip) {
if (null !== $ip) {
$command[] = '--resolve '.escapeshellarg("$host:$port:$ip");
}
}
}
$dataArg = [];
if ($json = $trace['options']['json'] ?? null) {
if (!$this->argMaxLengthIsSafe($payload = self::jsonEncode($json))) {
return null;
}
$dataArg[] = '--data '.escapeshellarg($payload);
} elseif ($body = $trace['options']['body'] ?? null) {
if (\is_string($body)) {
if (!$this->argMaxLengthIsSafe($body)) {
return null;
}
try {
$dataArg[] = '--data '.escapeshellarg($body);
} catch (\ValueError) {
return null;
}
} elseif (\is_array($body)) {
$body = explode('&', self::normalizeBody($body));
foreach ($body as $value) {
if (!$this->argMaxLengthIsSafe($payload = urldecode($value))) {
return null;
}
$dataArg[] = '--data '.escapeshellarg($payload);
}
} else {
return null;
}
}
$dataArg = empty($dataArg) ? null : implode(' ', $dataArg);
foreach (explode("\n", $trace['info']['debug']) as $line) {
$line = substr($line, 0, -1);
if (str_starts_with('< ', $line)) {
// End of the request, beginning of the response. Stop parsing.
break;
}
if ('' === $line || preg_match('/^[*<]|(Host: )/', $line)) {
continue;
}
if (preg_match('/^> ([A-Z]+)/', $line, $match)) {
$command[] = sprintf('--request %s', $match[1]);
$command[] = sprintf('--url %s', escapeshellarg($url));
continue;
}
$command[] = '--header '.escapeshellarg($line);
}
if (null !== $dataArg) {
$command[] = $dataArg;
}
return implode(" \\\n ", $command);
}
/**
* Let's be defensive : we authorize only size of 8kio on Windows for escapeshellarg() argument to avoid a fatal error.
*
* @see https://github.com/php/php-src/blob/9458f5f2c8a8e3d6c65cc181747a5a75654b7c6e/ext/standard/exec.c#L397
*/
private function argMaxLengthIsSafe(string $payload): bool
{
return \strlen($payload) < ('\\' === \DIRECTORY_SEPARATOR ? 8100 : 256000);
}
}

View File

@@ -0,0 +1,57 @@
<?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;
use Symfony\Contracts\HttpClient\HttpClientInterface;
use Symfony\Contracts\HttpClient\ResponseInterface;
use Symfony\Contracts\HttpClient\ResponseStreamInterface;
use Symfony\Contracts\Service\ResetInterface;
/**
* Eases with writing decorators.
*
* @author Nicolas Grekas <p@tchwork.com>
*/
trait DecoratorTrait
{
private HttpClientInterface $client;
public function __construct(HttpClientInterface $client = null)
{
$this->client = $client ?? HttpClient::create();
}
public function request(string $method, string $url, array $options = []): ResponseInterface
{
return $this->client->request($method, $url, $options);
}
public function stream(ResponseInterface|iterable $responses, float $timeout = null): ResponseStreamInterface
{
return $this->client->stream($responses, $timeout);
}
public function withOptions(array $options): static
{
$clone = clone $this;
$clone->client = $this->client->withOptions($options);
return $clone;
}
public function reset()
{
if ($this->client instanceof ResetInterface) {
$this->client->reset();
}
}
}

View File

@@ -0,0 +1,37 @@
<?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\DependencyInjection;
use Symfony\Component\DependencyInjection\Compiler\CompilerPassInterface;
use Symfony\Component\DependencyInjection\ContainerBuilder;
use Symfony\Component\DependencyInjection\ContainerInterface;
use Symfony\Component\DependencyInjection\Reference;
use Symfony\Component\HttpClient\TraceableHttpClient;
final class HttpClientPass implements CompilerPassInterface
{
public function process(ContainerBuilder $container)
{
if (!$container->hasDefinition('data_collector.http_client')) {
return;
}
foreach ($container->findTaggedServiceIds('http_client.client') as $id => $tags) {
$container->register('.debug.'.$id, TraceableHttpClient::class)
->setArguments([new Reference('.debug.'.$id.'.inner'), new Reference('debug.stopwatch', ContainerInterface::IGNORE_ON_INVALID_REFERENCE)])
->addTag('kernel.reset', ['method' => 'reset'])
->setDecoratedService($id, null, 5);
$container->getDefinition('data_collector.http_client')
->addMethodCall('registerClient', [$id, new Reference('.debug.'.$id)]);
}
}
}

View File

@@ -0,0 +1,159 @@
<?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;
use Symfony\Component\HttpClient\Chunk\ServerSentEvent;
use Symfony\Component\HttpClient\Exception\EventSourceException;
use Symfony\Component\HttpClient\Response\AsyncContext;
use Symfony\Component\HttpClient\Response\AsyncResponse;
use Symfony\Contracts\HttpClient\ChunkInterface;
use Symfony\Contracts\HttpClient\Exception\TransportExceptionInterface;
use Symfony\Contracts\HttpClient\HttpClientInterface;
use Symfony\Contracts\HttpClient\ResponseInterface;
use Symfony\Contracts\Service\ResetInterface;
/**
* @author Antoine Bluchet <soyuka@gmail.com>
* @author Nicolas Grekas <p@tchwork.com>
*/
final class EventSourceHttpClient implements HttpClientInterface, ResetInterface
{
use AsyncDecoratorTrait, HttpClientTrait {
AsyncDecoratorTrait::withOptions insteadof HttpClientTrait;
}
private float $reconnectionTime;
public function __construct(HttpClientInterface $client = null, float $reconnectionTime = 10.0)
{
$this->client = $client ?? HttpClient::create();
$this->reconnectionTime = $reconnectionTime;
}
public function connect(string $url, array $options = []): ResponseInterface
{
return $this->request('GET', $url, self::mergeDefaultOptions($options, [
'buffer' => false,
'headers' => [
'Accept' => 'text/event-stream',
'Cache-Control' => 'no-cache',
],
], true));
}
public function request(string $method, string $url, array $options = []): ResponseInterface
{
$state = new class() {
public ?string $buffer = null;
public ?string $lastEventId = null;
public float $reconnectionTime;
public ?float $lastError = null;
};
$state->reconnectionTime = $this->reconnectionTime;
if ($accept = self::normalizeHeaders($options['headers'] ?? [])['accept'] ?? []) {
$state->buffer = \in_array($accept, [['Accept: text/event-stream'], ['accept: text/event-stream']], true) ? '' : null;
if (null !== $state->buffer) {
$options['extra']['trace_content'] = false;
}
}
return new AsyncResponse($this->client, $method, $url, $options, static function (ChunkInterface $chunk, AsyncContext $context) use ($state, $method, $url, $options) {
if (null !== $state->buffer) {
$context->setInfo('reconnection_time', $state->reconnectionTime);
$isTimeout = false;
}
$lastError = $state->lastError;
$state->lastError = null;
try {
$isTimeout = $chunk->isTimeout();
if (null !== $chunk->getInformationalStatus() || $context->getInfo('canceled')) {
yield $chunk;
return;
}
} catch (TransportExceptionInterface) {
$state->lastError = $lastError ?? microtime(true);
if (null === $state->buffer || ($isTimeout && microtime(true) - $state->lastError < $state->reconnectionTime)) {
yield $chunk;
} else {
$options['headers']['Last-Event-ID'] = $state->lastEventId;
$state->buffer = '';
$state->lastError = microtime(true);
$context->getResponse()->cancel();
$context->replaceRequest($method, $url, $options);
if ($isTimeout) {
yield $chunk;
} else {
$context->pause($state->reconnectionTime);
}
}
return;
}
if ($chunk->isFirst()) {
if (preg_match('/^text\/event-stream(;|$)/i', $context->getHeaders()['content-type'][0] ?? '')) {
$state->buffer = '';
} elseif (null !== $lastError || (null !== $state->buffer && 200 === $context->getStatusCode())) {
throw new EventSourceException(sprintf('Response content-type is "%s" while "text/event-stream" was expected for "%s".', $context->getHeaders()['content-type'][0] ?? '', $context->getInfo('url')));
} else {
$context->passthru();
}
if (null === $lastError) {
yield $chunk;
}
return;
}
$rx = '/((?:\r\n|[\r\n]){2,})/';
$content = $state->buffer.$chunk->getContent();
if ($chunk->isLast()) {
$rx = substr_replace($rx, '|$', -2, 0);
}
$events = preg_split($rx, $content, -1, \PREG_SPLIT_DELIM_CAPTURE);
$state->buffer = array_pop($events);
for ($i = 0; isset($events[$i]); $i += 2) {
$event = new ServerSentEvent($events[$i].$events[1 + $i]);
if ('' !== $event->getId()) {
$context->setInfo('last_event_id', $state->lastEventId = $event->getId());
}
if ($event->getRetry()) {
$context->setInfo('reconnection_time', $state->reconnectionTime = $event->getRetry());
}
yield $event;
}
if (preg_match('/^(?::[^\r\n]*+(?:\r\n|[\r\n]))+$/m', $state->buffer)) {
$content = $state->buffer;
$state->buffer = '';
yield $context->createChunk($content);
}
if ($chunk->isLast()) {
yield $chunk;
}
});
}
}

View File

@@ -0,0 +1,24 @@
<?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\Exception;
use Symfony\Contracts\HttpClient\Exception\ClientExceptionInterface;
/**
* Represents a 4xx response.
*
* @author Nicolas Grekas <p@tchwork.com>
*/
final class ClientException extends \RuntimeException implements ClientExceptionInterface
{
use HttpExceptionTrait;
}

View File

@@ -0,0 +1,21 @@
<?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\Exception;
use Symfony\Contracts\HttpClient\Exception\DecodingExceptionInterface;
/**
* @author Nicolas Grekas <p@tchwork.com>
*/
final class EventSourceException extends \RuntimeException implements DecodingExceptionInterface
{
}

View File

@@ -0,0 +1,78 @@
<?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\Exception;
use Symfony\Contracts\HttpClient\ResponseInterface;
/**
* @author Nicolas Grekas <p@tchwork.com>
*
* @internal
*/
trait HttpExceptionTrait
{
private ResponseInterface $response;
public function __construct(ResponseInterface $response)
{
$this->response = $response;
$code = $response->getInfo('http_code');
$url = $response->getInfo('url');
$message = sprintf('HTTP %d returned for "%s".', $code, $url);
$httpCodeFound = false;
$isJson = false;
foreach (array_reverse($response->getInfo('response_headers')) as $h) {
if (str_starts_with($h, 'HTTP/')) {
if ($httpCodeFound) {
break;
}
$message = sprintf('%s returned for "%s".', $h, $url);
$httpCodeFound = true;
}
if (0 === stripos($h, 'content-type:')) {
if (preg_match('/\bjson\b/i', $h)) {
$isJson = true;
}
if ($httpCodeFound) {
break;
}
}
}
// Try to guess a better error message using common API error formats
// The MIME type isn't explicitly checked because some formats inherit from others
// Ex: JSON:API follows RFC 7807 semantics, Hydra can be used in any JSON-LD-compatible format
if ($isJson && $body = json_decode($response->getContent(false), true)) {
if (isset($body['hydra:title']) || isset($body['hydra:description'])) {
// see http://www.hydra-cg.com/spec/latest/core/#description-of-http-status-codes-and-errors
$separator = isset($body['hydra:title'], $body['hydra:description']) ? "\n\n" : '';
$message = ($body['hydra:title'] ?? '').$separator.($body['hydra:description'] ?? '');
} elseif ((isset($body['title']) || isset($body['detail']))
&& (\is_scalar($body['title'] ?? '') && \is_scalar($body['detail'] ?? ''))) {
// see RFC 7807 and https://jsonapi.org/format/#error-objects
$separator = isset($body['title'], $body['detail']) ? "\n\n" : '';
$message = ($body['title'] ?? '').$separator.($body['detail'] ?? '');
}
}
parent::__construct($message, $code);
}
public function getResponse(): ResponseInterface
{
return $this->response;
}
}

View File

@@ -0,0 +1,21 @@
<?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\Exception;
use Symfony\Contracts\HttpClient\Exception\TransportExceptionInterface;
/**
* @author Nicolas Grekas <p@tchwork.com>
*/
final class InvalidArgumentException extends \InvalidArgumentException implements TransportExceptionInterface
{
}

View File

@@ -0,0 +1,23 @@
<?php
/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Symfony\Component\HttpClient\Exception;
use Symfony\Contracts\HttpClient\Exception\DecodingExceptionInterface;
/**
* Thrown by responses' toArray() method when their content cannot be JSON-decoded.
*
* @author Nicolas Grekas <p@tchwork.com>
*/
final class JsonException extends \JsonException implements DecodingExceptionInterface
{
}

View File

@@ -0,0 +1,24 @@
<?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\Exception;
use Symfony\Contracts\HttpClient\Exception\RedirectionExceptionInterface;
/**
* Represents a 3xx response.
*
* @author Nicolas Grekas <p@tchwork.com>
*/
final class RedirectionException extends \RuntimeException implements RedirectionExceptionInterface
{
use HttpExceptionTrait;
}

View File

@@ -0,0 +1,24 @@
<?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\Exception;
use Symfony\Contracts\HttpClient\Exception\ServerExceptionInterface;
/**
* Represents a 5xx response.
*
* @author Nicolas Grekas <p@tchwork.com>
*/
final class ServerException extends \RuntimeException implements ServerExceptionInterface
{
use HttpExceptionTrait;
}

View File

@@ -0,0 +1,21 @@
<?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\Exception;
use Symfony\Contracts\HttpClient\Exception\TimeoutExceptionInterface;
/**
* @author Nicolas Grekas <p@tchwork.com>
*/
final class TimeoutException extends TransportException implements TimeoutExceptionInterface
{
}

View File

@@ -0,0 +1,21 @@
<?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\Exception;
use Symfony\Contracts\HttpClient\Exception\TransportExceptionInterface;
/**
* @author Nicolas Grekas <p@tchwork.com>
*/
class TransportException extends \RuntimeException implements TransportExceptionInterface
{
}

View File

@@ -0,0 +1,79 @@
<?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;
use Amp\Http\Client\Connection\ConnectionLimitingPool;
use Amp\Promise;
use Symfony\Contracts\HttpClient\HttpClientInterface;
/**
* A factory to instantiate the best possible HTTP client for the runtime.
*
* @author Nicolas Grekas <p@tchwork.com>
*/
final class HttpClient
{
/**
* @param array $defaultOptions Default request's options
* @param int $maxHostConnections The maximum number of connections to a single host
* @param int $maxPendingPushes The maximum number of pushed responses to accept in the queue
*
* @see HttpClientInterface::OPTIONS_DEFAULTS for available options
*/
public static function create(array $defaultOptions = [], int $maxHostConnections = 6, int $maxPendingPushes = 50): HttpClientInterface
{
if ($amp = class_exists(ConnectionLimitingPool::class) && interface_exists(Promise::class)) {
if (!\extension_loaded('curl')) {
return new AmpHttpClient($defaultOptions, null, $maxHostConnections, $maxPendingPushes);
}
// Skip curl when HTTP/2 push is unsupported or buggy, see https://bugs.php.net/77535
if (!\defined('CURLMOPT_PUSHFUNCTION')) {
return new AmpHttpClient($defaultOptions, null, $maxHostConnections, $maxPendingPushes);
}
static $curlVersion = null;
$curlVersion ??= curl_version();
// HTTP/2 push crashes before curl 7.61
if (0x073D00 > $curlVersion['version_number'] || !(\CURL_VERSION_HTTP2 & $curlVersion['features'])) {
return new AmpHttpClient($defaultOptions, null, $maxHostConnections, $maxPendingPushes);
}
}
if (\extension_loaded('curl')) {
if ('\\' !== \DIRECTORY_SEPARATOR || isset($defaultOptions['cafile']) || isset($defaultOptions['capath']) || \ini_get('curl.cainfo') || \ini_get('openssl.cafile') || \ini_get('openssl.capath')) {
return new CurlHttpClient($defaultOptions, $maxHostConnections, $maxPendingPushes);
}
@trigger_error('Configure the "curl.cainfo", "openssl.cafile" or "openssl.capath" php.ini setting to enable the CurlHttpClient', \E_USER_WARNING);
}
if ($amp) {
return new AmpHttpClient($defaultOptions, null, $maxHostConnections, $maxPendingPushes);
}
@trigger_error((\extension_loaded('curl') ? 'Upgrade' : 'Install').' the curl extension or run "composer require amphp/http-client:^4.2.1" to perform async HTTP operations, including full HTTP/2 support', \E_USER_NOTICE);
return new NativeHttpClient($defaultOptions, $maxHostConnections);
}
/**
* Creates a client that adds options (e.g. authentication headers) only when the request URL matches the provided base URI.
*/
public static function createForBaseUri(string $baseUri, array $defaultOptions = [], int $maxHostConnections = 6, int $maxPendingPushes = 50): HttpClientInterface
{
$client = self::create([], $maxHostConnections, $maxPendingPushes);
return ScopingHttpClient::forBaseUri($client, $baseUri, $defaultOptions);
}
}

View File

@@ -0,0 +1,703 @@
<?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;
use Symfony\Component\HttpClient\Exception\InvalidArgumentException;
use Symfony\Component\HttpClient\Exception\TransportException;
/**
* Provides the common logic from writing HttpClientInterface implementations.
*
* All private methods are static to prevent implementers from creating memory leaks via circular references.
*
* @author Nicolas Grekas <p@tchwork.com>
*/
trait HttpClientTrait
{
private static int $CHUNK_SIZE = 16372;
public function withOptions(array $options): static
{
$clone = clone $this;
$clone->defaultOptions = self::mergeDefaultOptions($options, $this->defaultOptions);
return $clone;
}
/**
* Validates and normalizes method, URL and options, and merges them with defaults.
*
* @throws InvalidArgumentException When a not-supported option is found
*/
private static function prepareRequest(?string $method, ?string $url, array $options, array $defaultOptions = [], bool $allowExtraOptions = false): array
{
if (null !== $method) {
if (\strlen($method) !== strspn($method, 'ABCDEFGHIJKLMNOPQRSTUVWXYZ')) {
throw new InvalidArgumentException(sprintf('Invalid HTTP method "%s", only uppercase letters are accepted.', $method));
}
if (!$method) {
throw new InvalidArgumentException('The HTTP method cannot be empty.');
}
}
$options = self::mergeDefaultOptions($options, $defaultOptions, $allowExtraOptions);
$buffer = $options['buffer'] ?? true;
if ($buffer instanceof \Closure) {
$options['buffer'] = static function (array $headers) use ($buffer) {
if (!\is_bool($buffer = $buffer($headers))) {
if (!\is_array($bufferInfo = @stream_get_meta_data($buffer))) {
throw new \LogicException(sprintf('The closure passed as option "buffer" must return bool or stream resource, got "%s".', get_debug_type($buffer)));
}
if (false === strpbrk($bufferInfo['mode'], 'acew+')) {
throw new \LogicException(sprintf('The stream returned by the closure passed as option "buffer" must be writeable, got mode "%s".', $bufferInfo['mode']));
}
}
return $buffer;
};
} elseif (!\is_bool($buffer)) {
if (!\is_array($bufferInfo = @stream_get_meta_data($buffer))) {
throw new InvalidArgumentException(sprintf('Option "buffer" must be bool, stream resource or Closure, "%s" given.', get_debug_type($buffer)));
}
if (false === strpbrk($bufferInfo['mode'], 'acew+')) {
throw new InvalidArgumentException(sprintf('The stream in option "buffer" must be writeable, mode "%s" given.', $bufferInfo['mode']));
}
}
if (isset($options['json'])) {
if (isset($options['body']) && '' !== $options['body']) {
throw new InvalidArgumentException('Define either the "json" or the "body" option, setting both is not supported.');
}
$options['body'] = self::jsonEncode($options['json']);
unset($options['json']);
if (!isset($options['normalized_headers']['content-type'])) {
$options['normalized_headers']['content-type'] = ['Content-Type: application/json'];
}
}
if (!isset($options['normalized_headers']['accept'])) {
$options['normalized_headers']['accept'] = ['Accept: */*'];
}
if (isset($options['body'])) {
if (\is_array($options['body']) && (!isset($options['normalized_headers']['content-type'][0]) || !str_contains($options['normalized_headers']['content-type'][0], 'application/x-www-form-urlencoded'))) {
$options['normalized_headers']['content-type'] = ['Content-Type: application/x-www-form-urlencoded'];
}
$options['body'] = self::normalizeBody($options['body']);
if (\is_string($options['body'])
&& (string) \strlen($options['body']) !== substr($h = $options['normalized_headers']['content-length'][0] ?? '', 16)
&& ('' !== $h || '' !== $options['body'])
) {
if ('chunked' === substr($options['normalized_headers']['transfer-encoding'][0] ?? '', \strlen('Transfer-Encoding: '))) {
unset($options['normalized_headers']['transfer-encoding']);
$options['body'] = self::dechunk($options['body']);
}
$options['normalized_headers']['content-length'] = [substr_replace($h ?: 'Content-Length: ', \strlen($options['body']), 16)];
}
}
if (isset($options['peer_fingerprint'])) {
$options['peer_fingerprint'] = self::normalizePeerFingerprint($options['peer_fingerprint']);
}
// Validate on_progress
if (isset($options['on_progress']) && !\is_callable($onProgress = $options['on_progress'])) {
throw new InvalidArgumentException(sprintf('Option "on_progress" must be callable, "%s" given.', get_debug_type($onProgress)));
}
if (\is_array($options['auth_basic'] ?? null)) {
$count = \count($options['auth_basic']);
if ($count <= 0 || $count > 2) {
throw new InvalidArgumentException(sprintf('Option "auth_basic" must contain 1 or 2 elements, "%s" given.', $count));
}
$options['auth_basic'] = implode(':', $options['auth_basic']);
}
if (!\is_string($options['auth_basic'] ?? '')) {
throw new InvalidArgumentException(sprintf('Option "auth_basic" must be string or an array, "%s" given.', get_debug_type($options['auth_basic'])));
}
if (isset($options['auth_bearer'])) {
if (!\is_string($options['auth_bearer'])) {
throw new InvalidArgumentException(sprintf('Option "auth_bearer" must be a string, "%s" given.', get_debug_type($options['auth_bearer'])));
}
if (preg_match('{[^\x21-\x7E]}', $options['auth_bearer'])) {
throw new InvalidArgumentException('Invalid character found in option "auth_bearer": '.json_encode($options['auth_bearer']).'.');
}
}
if (isset($options['auth_basic'], $options['auth_bearer'])) {
throw new InvalidArgumentException('Define either the "auth_basic" or the "auth_bearer" option, setting both is not supported.');
}
if (null !== $url) {
// Merge auth with headers
if (($options['auth_basic'] ?? false) && !($options['normalized_headers']['authorization'] ?? false)) {
$options['normalized_headers']['authorization'] = ['Authorization: Basic '.base64_encode($options['auth_basic'])];
}
// Merge bearer with headers
if (($options['auth_bearer'] ?? false) && !($options['normalized_headers']['authorization'] ?? false)) {
$options['normalized_headers']['authorization'] = ['Authorization: Bearer '.$options['auth_bearer']];
}
unset($options['auth_basic'], $options['auth_bearer']);
// Parse base URI
if (\is_string($options['base_uri'])) {
$options['base_uri'] = self::parseUrl($options['base_uri']);
}
// Validate and resolve URL
$url = self::parseUrl($url, $options['query']);
$url = self::resolveUrl($url, $options['base_uri'], $defaultOptions['query'] ?? []);
}
// Finalize normalization of options
$options['http_version'] = (string) ($options['http_version'] ?? '') ?: null;
if (0 > $options['timeout'] = (float) ($options['timeout'] ?? \ini_get('default_socket_timeout'))) {
$options['timeout'] = 172800.0; // 2 days
}
$options['max_duration'] = isset($options['max_duration']) ? (float) $options['max_duration'] : 0;
$options['headers'] = array_merge(...array_values($options['normalized_headers']));
return [$url, $options];
}
/**
* @throws InvalidArgumentException When an invalid option is found
*/
private static function mergeDefaultOptions(array $options, array $defaultOptions, bool $allowExtraOptions = false): array
{
$options['normalized_headers'] = self::normalizeHeaders($options['headers'] ?? []);
if ($defaultOptions['headers'] ?? false) {
$options['normalized_headers'] += self::normalizeHeaders($defaultOptions['headers']);
}
$options['headers'] = array_merge(...array_values($options['normalized_headers']) ?: [[]]);
if ($resolve = $options['resolve'] ?? false) {
$options['resolve'] = [];
foreach ($resolve as $k => $v) {
$options['resolve'][substr(self::parseUrl('http://'.$k)['authority'], 2)] = (string) $v;
}
}
// Option "query" is never inherited from defaults
$options['query'] ??= [];
$options += $defaultOptions;
if (isset(self::$emptyDefaults)) {
foreach (self::$emptyDefaults as $k => $v) {
if (!isset($options[$k])) {
$options[$k] = $v;
}
}
}
if (isset($defaultOptions['extra'])) {
$options['extra'] += $defaultOptions['extra'];
}
if ($resolve = $defaultOptions['resolve'] ?? false) {
foreach ($resolve as $k => $v) {
$options['resolve'] += [substr(self::parseUrl('http://'.$k)['authority'], 2) => (string) $v];
}
}
if ($allowExtraOptions || !$defaultOptions) {
return $options;
}
// Look for unsupported options
foreach ($options as $name => $v) {
if (\array_key_exists($name, $defaultOptions) || 'normalized_headers' === $name) {
continue;
}
if ('auth_ntlm' === $name) {
if (!\extension_loaded('curl')) {
$msg = 'try installing the "curl" extension to use "%s" instead.';
} else {
$msg = 'try using "%s" instead.';
}
throw new InvalidArgumentException(sprintf('Option "auth_ntlm" is not supported by "%s", '.$msg, __CLASS__, CurlHttpClient::class));
}
$alternatives = [];
foreach ($defaultOptions as $k => $v) {
if (levenshtein($name, $k) <= \strlen($name) / 3 || str_contains($k, $name)) {
$alternatives[] = $k;
}
}
throw new InvalidArgumentException(sprintf('Unsupported option "%s" passed to "%s", did you mean "%s"?', $name, __CLASS__, implode('", "', $alternatives ?: array_keys($defaultOptions))));
}
return $options;
}
/**
* @return string[][]
*
* @throws InvalidArgumentException When an invalid header is found
*/
private static function normalizeHeaders(array $headers): array
{
$normalizedHeaders = [];
foreach ($headers as $name => $values) {
if ($values instanceof \Stringable) {
$values = (string) $values;
}
if (\is_int($name)) {
if (!\is_string($values)) {
throw new InvalidArgumentException(sprintf('Invalid value for header "%s": expected string, "%s" given.', $name, get_debug_type($values)));
}
[$name, $values] = explode(':', $values, 2);
$values = [ltrim($values)];
} elseif (!is_iterable($values)) {
if (\is_object($values)) {
throw new InvalidArgumentException(sprintf('Invalid value for header "%s": expected string, "%s" given.', $name, get_debug_type($values)));
}
$values = (array) $values;
}
$lcName = strtolower($name);
$normalizedHeaders[$lcName] = [];
foreach ($values as $value) {
$normalizedHeaders[$lcName][] = $value = $name.': '.$value;
if (\strlen($value) !== strcspn($value, "\r\n\0")) {
throw new InvalidArgumentException(sprintf('Invalid header: CR/LF/NUL found in "%s".', $value));
}
}
}
return $normalizedHeaders;
}
/**
* @param array|string|resource|\Traversable|\Closure $body
*
* @return string|resource|\Closure
*
* @throws InvalidArgumentException When an invalid body is passed
*/
private static function normalizeBody($body)
{
if (\is_array($body)) {
array_walk_recursive($body, $caster = static function (&$v) use (&$caster) {
if (\is_object($v)) {
if ($vars = get_object_vars($v)) {
array_walk_recursive($vars, $caster);
$v = $vars;
} elseif (method_exists($v, '__toString')) {
$v = (string) $v;
}
}
});
return http_build_query($body, '', '&');
}
if (\is_string($body)) {
return $body;
}
$generatorToCallable = static function (\Generator $body): \Closure {
return static function () use ($body) {
while ($body->valid()) {
$chunk = $body->current();
$body->next();
if ('' !== $chunk) {
return $chunk;
}
}
return '';
};
};
if ($body instanceof \Generator) {
return $generatorToCallable($body);
}
if ($body instanceof \Traversable) {
return $generatorToCallable((static function ($body) { yield from $body; })($body));
}
if ($body instanceof \Closure) {
$r = new \ReflectionFunction($body);
$body = $r->getClosure();
if ($r->isGenerator()) {
$body = $body(self::$CHUNK_SIZE);
return $generatorToCallable($body);
}
return $body;
}
if (!\is_array(@stream_get_meta_data($body))) {
throw new InvalidArgumentException(sprintf('Option "body" must be string, stream resource, iterable or callable, "%s" given.', get_debug_type($body)));
}
return $body;
}
private static function dechunk(string $body): string
{
$h = fopen('php://temp', 'w+');
stream_filter_append($h, 'dechunk', \STREAM_FILTER_WRITE);
fwrite($h, $body);
$body = stream_get_contents($h, -1, 0);
rewind($h);
ftruncate($h, 0);
if (fwrite($h, '-') && '' !== stream_get_contents($h, -1, 0)) {
throw new TransportException('Request body has broken chunked encoding.');
}
return $body;
}
/**
* @throws InvalidArgumentException When an invalid fingerprint is passed
*/
private static function normalizePeerFingerprint(mixed $fingerprint): array
{
if (\is_string($fingerprint)) {
$fingerprint = match (\strlen($fingerprint = str_replace(':', '', $fingerprint))) {
32 => ['md5' => $fingerprint],
40 => ['sha1' => $fingerprint],
44 => ['pin-sha256' => [$fingerprint]],
64 => ['sha256' => $fingerprint],
default => throw new InvalidArgumentException(sprintf('Cannot auto-detect fingerprint algorithm for "%s".', $fingerprint)),
};
} elseif (\is_array($fingerprint)) {
foreach ($fingerprint as $algo => $hash) {
$fingerprint[$algo] = 'pin-sha256' === $algo ? (array) $hash : str_replace(':', '', $hash);
}
} else {
throw new InvalidArgumentException(sprintf('Option "peer_fingerprint" must be string or array, "%s" given.', get_debug_type($fingerprint)));
}
return $fingerprint;
}
/**
* @throws InvalidArgumentException When the value cannot be json-encoded
*/
private static function jsonEncode(mixed $value, int $flags = null, int $maxDepth = 512): string
{
$flags ??= \JSON_HEX_TAG | \JSON_HEX_APOS | \JSON_HEX_AMP | \JSON_HEX_QUOT | \JSON_PRESERVE_ZERO_FRACTION;
try {
$value = json_encode($value, $flags | \JSON_THROW_ON_ERROR, $maxDepth);
} catch (\JsonException $e) {
throw new InvalidArgumentException('Invalid value for "json" option: '.$e->getMessage());
}
return $value;
}
/**
* Resolves a URL against a base URI.
*
* @see https://tools.ietf.org/html/rfc3986#section-5.2.2
*
* @throws InvalidArgumentException When an invalid URL is passed
*/
private static function resolveUrl(array $url, ?array $base, array $queryDefaults = []): array
{
if (null !== $base && '' === ($base['scheme'] ?? '').($base['authority'] ?? '')) {
throw new InvalidArgumentException(sprintf('Invalid "base_uri" option: host or scheme is missing in "%s".', implode('', $base)));
}
if (null === $url['scheme'] && (null === $base || null === $base['scheme'])) {
throw new InvalidArgumentException(sprintf('Invalid URL: scheme is missing in "%s". Did you forget to add "http(s)://"?', implode('', $base ?? $url)));
}
if (null === $base && '' === $url['scheme'].$url['authority']) {
throw new InvalidArgumentException(sprintf('Invalid URL: no "base_uri" option was provided and host or scheme is missing in "%s".', implode('', $url)));
}
if (null !== $url['scheme']) {
$url['path'] = self::removeDotSegments($url['path'] ?? '');
} else {
if (null !== $url['authority']) {
$url['path'] = self::removeDotSegments($url['path'] ?? '');
} else {
if (null === $url['path']) {
$url['path'] = $base['path'];
$url['query'] ??= $base['query'];
} else {
if ('/' !== $url['path'][0]) {
if (null === $base['path']) {
$url['path'] = '/'.$url['path'];
} else {
$segments = explode('/', $base['path']);
array_splice($segments, -1, 1, [$url['path']]);
$url['path'] = implode('/', $segments);
}
}
$url['path'] = self::removeDotSegments($url['path']);
}
$url['authority'] = $base['authority'];
if ($queryDefaults) {
$url['query'] = '?'.self::mergeQueryString(substr($url['query'] ?? '', 1), $queryDefaults, false);
}
}
$url['scheme'] = $base['scheme'];
}
if ('' === ($url['path'] ?? '')) {
$url['path'] = '/';
}
if ('?' === ($url['query'] ?? '')) {
$url['query'] = null;
}
return $url;
}
/**
* Parses a URL and fixes its encoding if needed.
*
* @throws InvalidArgumentException When an invalid URL is passed
*/
private static function parseUrl(string $url, array $query = [], array $allowedSchemes = ['http' => 80, 'https' => 443]): array
{
if (false === $parts = parse_url($url)) {
throw new InvalidArgumentException(sprintf('Malformed URL "%s".', $url));
}
if ($query) {
$parts['query'] = self::mergeQueryString($parts['query'] ?? null, $query, true);
}
$port = $parts['port'] ?? 0;
if (null !== $scheme = $parts['scheme'] ?? null) {
if (!isset($allowedSchemes[$scheme = strtolower($scheme)])) {
throw new InvalidArgumentException(sprintf('Unsupported scheme in "%s".', $url));
}
$port = $allowedSchemes[$scheme] === $port ? 0 : $port;
$scheme .= ':';
}
if (null !== $host = $parts['host'] ?? null) {
if (!\defined('INTL_IDNA_VARIANT_UTS46') && preg_match('/[\x80-\xFF]/', $host)) {
throw new InvalidArgumentException(sprintf('Unsupported IDN "%s", try enabling the "intl" PHP extension or running "composer require symfony/polyfill-intl-idn".', $host));
}
$host = \defined('INTL_IDNA_VARIANT_UTS46') ? idn_to_ascii($host, \IDNA_DEFAULT | \IDNA_USE_STD3_RULES | \IDNA_CHECK_BIDI | \IDNA_CHECK_CONTEXTJ | \IDNA_NONTRANSITIONAL_TO_ASCII, \INTL_IDNA_VARIANT_UTS46) ?: strtolower($host) : strtolower($host);
$host .= $port ? ':'.$port : '';
}
foreach (['user', 'pass', 'path', 'query', 'fragment'] as $part) {
if (!isset($parts[$part])) {
continue;
}
if (str_contains($parts[$part], '%')) {
// https://tools.ietf.org/html/rfc3986#section-2.3
$parts[$part] = preg_replace_callback('/%(?:2[DE]|3[0-9]|[46][1-9A-F]|5F|[57][0-9A]|7E)++/i', function ($m) { return rawurldecode($m[0]); }, $parts[$part]);
}
// https://tools.ietf.org/html/rfc3986#section-3.3
$parts[$part] = preg_replace_callback("#[^-A-Za-z0-9._~!$&/'()[\]*+,;=:@{}%]++#", function ($m) { return rawurlencode($m[0]); }, $parts[$part]);
}
return [
'scheme' => $scheme,
'authority' => null !== $host ? '//'.(isset($parts['user']) ? $parts['user'].(isset($parts['pass']) ? ':'.$parts['pass'] : '').'@' : '').$host : null,
'path' => isset($parts['path'][0]) ? $parts['path'] : null,
'query' => isset($parts['query']) ? '?'.$parts['query'] : null,
'fragment' => isset($parts['fragment']) ? '#'.$parts['fragment'] : null,
];
}
/**
* Removes dot-segments from a path.
*
* @see https://tools.ietf.org/html/rfc3986#section-5.2.4
*/
private static function removeDotSegments(string $path)
{
$result = '';
while (!\in_array($path, ['', '.', '..'], true)) {
if ('.' === $path[0] && (str_starts_with($path, $p = '../') || str_starts_with($path, $p = './'))) {
$path = substr($path, \strlen($p));
} elseif ('/.' === $path || str_starts_with($path, '/./')) {
$path = substr_replace($path, '/', 0, 3);
} elseif ('/..' === $path || str_starts_with($path, '/../')) {
$i = strrpos($result, '/');
$result = $i ? substr($result, 0, $i) : '';
$path = substr_replace($path, '/', 0, 4);
} else {
$i = strpos($path, '/', 1) ?: \strlen($path);
$result .= substr($path, 0, $i);
$path = substr($path, $i);
}
}
return $result;
}
/**
* Merges and encodes a query array with a query string.
*
* @throws InvalidArgumentException When an invalid query-string value is passed
*/
private static function mergeQueryString(?string $queryString, array $queryArray, bool $replace): ?string
{
if (!$queryArray) {
return $queryString;
}
$query = [];
if (null !== $queryString) {
foreach (explode('&', $queryString) as $v) {
if ('' !== $v) {
$k = urldecode(explode('=', $v, 2)[0]);
$query[$k] = (isset($query[$k]) ? $query[$k].'&' : '').$v;
}
}
}
if ($replace) {
foreach ($queryArray as $k => $v) {
if (null === $v) {
unset($query[$k]);
}
}
}
$queryString = http_build_query($queryArray, '', '&', \PHP_QUERY_RFC3986);
$queryArray = [];
if ($queryString) {
if (str_contains($queryString, '%')) {
// https://tools.ietf.org/html/rfc3986#section-2.3 + some chars not encoded by browsers
$queryString = strtr($queryString, [
'%21' => '!',
'%24' => '$',
'%28' => '(',
'%29' => ')',
'%2A' => '*',
'%2F' => '/',
'%3A' => ':',
'%3B' => ';',
'%40' => '@',
'%5B' => '[',
'%5D' => ']',
]);
}
foreach (explode('&', $queryString) as $v) {
$queryArray[rawurldecode(explode('=', $v, 2)[0])] = $v;
}
}
return implode('&', $replace ? array_replace($query, $queryArray) : ($query + $queryArray));
}
/**
* Loads proxy configuration from the same environment variables as curl when no proxy is explicitly set.
*/
private static function getProxy(?string $proxy, array $url, ?string $noProxy): ?array
{
if (null === $proxy = self::getProxyUrl($proxy, $url)) {
return null;
}
$proxy = (parse_url($proxy) ?: []) + ['scheme' => 'http'];
if (!isset($proxy['host'])) {
throw new TransportException('Invalid HTTP proxy: host is missing.');
}
if ('http' === $proxy['scheme']) {
$proxyUrl = 'tcp://'.$proxy['host'].':'.($proxy['port'] ?? '80');
} elseif ('https' === $proxy['scheme']) {
$proxyUrl = 'ssl://'.$proxy['host'].':'.($proxy['port'] ?? '443');
} else {
throw new TransportException(sprintf('Unsupported proxy scheme "%s": "http" or "https" expected.', $proxy['scheme']));
}
$noProxy ??= $_SERVER['no_proxy'] ?? $_SERVER['NO_PROXY'] ?? '';
$noProxy = $noProxy ? preg_split('/[\s,]+/', $noProxy) : [];
return [
'url' => $proxyUrl,
'auth' => isset($proxy['user']) ? 'Basic '.base64_encode(rawurldecode($proxy['user']).':'.rawurldecode($proxy['pass'] ?? '')) : null,
'no_proxy' => $noProxy,
];
}
private static function getProxyUrl(?string $proxy, array $url): ?string
{
if (null !== $proxy) {
return $proxy;
}
// Ignore HTTP_PROXY except on the CLI to work around httpoxy set of vulnerabilities
$proxy = $_SERVER['http_proxy'] ?? (\in_array(\PHP_SAPI, ['cli', 'phpdbg'], true) ? $_SERVER['HTTP_PROXY'] ?? null : null) ?? $_SERVER['all_proxy'] ?? $_SERVER['ALL_PROXY'] ?? null;
if ('https:' === $url['scheme']) {
$proxy = $_SERVER['https_proxy'] ?? $_SERVER['HTTPS_PROXY'] ?? $proxy;
}
return $proxy;
}
private static function shouldBuffer(array $headers): bool
{
if (null === $contentType = $headers['content-type'][0] ?? null) {
return false;
}
if (false !== $i = strpos($contentType, ';')) {
$contentType = substr($contentType, 0, $i);
}
return $contentType && preg_match('#^(?:text/|application/(?:.+\+)?(?:json|xml)$)#i', $contentType);
}
}

View File

@@ -0,0 +1,327 @@
<?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;
use Symfony\Contracts\HttpClient\HttpClientInterface;
/**
* A helper providing autocompletion for available options.
*
* @see HttpClientInterface for a description of each options.
*
* @author Nicolas Grekas <p@tchwork.com>
*/
class HttpOptions
{
private array $options = [];
public function toArray(): array
{
return $this->options;
}
/**
* @return $this
*/
public function setAuthBasic(string $user, #[\SensitiveParameter] string $password = ''): static
{
$this->options['auth_basic'] = $user;
if ('' !== $password) {
$this->options['auth_basic'] .= ':'.$password;
}
return $this;
}
/**
* @return $this
*/
public function setAuthBearer(#[\SensitiveParameter] string $token): static
{
$this->options['auth_bearer'] = $token;
return $this;
}
/**
* @return $this
*/
public function setQuery(array $query): static
{
$this->options['query'] = $query;
return $this;
}
/**
* @return $this
*/
public function setHeaders(iterable $headers): static
{
$this->options['headers'] = $headers;
return $this;
}
/**
* @param array|string|resource|\Traversable|\Closure $body
*
* @return $this
*/
public function setBody(mixed $body): static
{
$this->options['body'] = $body;
return $this;
}
/**
* @return $this
*/
public function setJson(mixed $json): static
{
$this->options['json'] = $json;
return $this;
}
/**
* @return $this
*/
public function setUserData(mixed $data): static
{
$this->options['user_data'] = $data;
return $this;
}
/**
* @return $this
*/
public function setMaxRedirects(int $max): static
{
$this->options['max_redirects'] = $max;
return $this;
}
/**
* @return $this
*/
public function setHttpVersion(string $version): static
{
$this->options['http_version'] = $version;
return $this;
}
/**
* @return $this
*/
public function setBaseUri(string $uri): static
{
$this->options['base_uri'] = $uri;
return $this;
}
/**
* @return $this
*/
public function buffer(bool $buffer): static
{
$this->options['buffer'] = $buffer;
return $this;
}
/**
* @return $this
*/
public function setOnProgress(callable $callback): static
{
$this->options['on_progress'] = $callback;
return $this;
}
/**
* @return $this
*/
public function resolve(array $hostIps): static
{
$this->options['resolve'] = $hostIps;
return $this;
}
/**
* @return $this
*/
public function setProxy(string $proxy): static
{
$this->options['proxy'] = $proxy;
return $this;
}
/**
* @return $this
*/
public function setNoProxy(string $noProxy): static
{
$this->options['no_proxy'] = $noProxy;
return $this;
}
/**
* @return $this
*/
public function setTimeout(float $timeout): static
{
$this->options['timeout'] = $timeout;
return $this;
}
/**
* @return $this
*/
public function setMaxDuration(float $maxDuration): static
{
$this->options['max_duration'] = $maxDuration;
return $this;
}
/**
* @return $this
*/
public function bindTo(string $bindto): static
{
$this->options['bindto'] = $bindto;
return $this;
}
/**
* @return $this
*/
public function verifyPeer(bool $verify): static
{
$this->options['verify_peer'] = $verify;
return $this;
}
/**
* @return $this
*/
public function verifyHost(bool $verify): static
{
$this->options['verify_host'] = $verify;
return $this;
}
/**
* @return $this
*/
public function setCaFile(string $cafile): static
{
$this->options['cafile'] = $cafile;
return $this;
}
/**
* @return $this
*/
public function setCaPath(string $capath): static
{
$this->options['capath'] = $capath;
return $this;
}
/**
* @return $this
*/
public function setLocalCert(string $cert): static
{
$this->options['local_cert'] = $cert;
return $this;
}
/**
* @return $this
*/
public function setLocalPk(string $pk): static
{
$this->options['local_pk'] = $pk;
return $this;
}
/**
* @return $this
*/
public function setPassphrase(string $passphrase): static
{
$this->options['passphrase'] = $passphrase;
return $this;
}
/**
* @return $this
*/
public function setCiphers(string $ciphers): static
{
$this->options['ciphers'] = $ciphers;
return $this;
}
/**
* @return $this
*/
public function setPeerFingerprint(string|array $fingerprint): static
{
$this->options['peer_fingerprint'] = $fingerprint;
return $this;
}
/**
* @return $this
*/
public function capturePeerCertChain(bool $capture): static
{
$this->options['capture_peer_cert_chain'] = $capture;
return $this;
}
/**
* @return $this
*/
public function setExtra(string $name, mixed $value): static
{
$this->options['extra'][$name] = $value;
return $this;
}
}

View File

@@ -0,0 +1,302 @@
<?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;
use GuzzleHttp\Promise\Promise as GuzzlePromise;
use GuzzleHttp\Promise\RejectedPromise;
use GuzzleHttp\Promise\Utils;
use Http\Client\Exception\NetworkException;
use Http\Client\Exception\RequestException;
use Http\Client\HttpAsyncClient;
use Http\Client\HttpClient as HttplugInterface;
use Http\Discovery\Exception\NotFoundException;
use Http\Discovery\Psr17FactoryDiscovery;
use Http\Message\RequestFactory;
use Http\Message\StreamFactory;
use Http\Message\UriFactory;
use Nyholm\Psr7\Factory\Psr17Factory;
use Nyholm\Psr7\Request;
use Nyholm\Psr7\Uri;
use Psr\Http\Message\RequestFactoryInterface;
use Psr\Http\Message\RequestInterface;
use Psr\Http\Message\ResponseFactoryInterface;
use Psr\Http\Message\ResponseInterface as Psr7ResponseInterface;
use Psr\Http\Message\StreamFactoryInterface;
use Psr\Http\Message\StreamInterface;
use Psr\Http\Message\UriFactoryInterface;
use Psr\Http\Message\UriInterface;
use Symfony\Component\HttpClient\Internal\HttplugWaitLoop;
use Symfony\Component\HttpClient\Response\HttplugPromise;
use Symfony\Contracts\HttpClient\Exception\TransportExceptionInterface;
use Symfony\Contracts\HttpClient\HttpClientInterface;
use Symfony\Contracts\HttpClient\ResponseInterface;
use Symfony\Contracts\Service\ResetInterface;
if (!interface_exists(HttplugInterface::class)) {
throw new \LogicException('You cannot use "Symfony\Component\HttpClient\HttplugClient" as the "php-http/httplug" package is not installed. Try running "composer require php-http/httplug".');
}
if (!interface_exists(RequestFactory::class)) {
throw new \LogicException('You cannot use "Symfony\Component\HttpClient\HttplugClient" as the "php-http/message-factory" package is not installed. Try running "composer require php-http/message-factory".');
}
if (!interface_exists(RequestFactoryInterface::class)) {
throw new \LogicException('You cannot use the "Symfony\Component\HttpClient\HttplugClient" as the "psr/http-factory" package is not installed. Try running "composer require nyholm/psr7".');
}
/**
* An adapter to turn a Symfony HttpClientInterface into an Httplug client.
*
* Run "composer require nyholm/psr7" to install an efficient implementation of response
* and stream factories with flex-provided autowiring aliases.
*
* @author Nicolas Grekas <p@tchwork.com>
*/
final class HttplugClient implements HttplugInterface, HttpAsyncClient, RequestFactoryInterface, StreamFactoryInterface, UriFactoryInterface, RequestFactory, StreamFactory, UriFactory, ResetInterface
{
private HttpClientInterface $client;
private ResponseFactoryInterface $responseFactory;
private StreamFactoryInterface $streamFactory;
/**
* @var \SplObjectStorage<ResponseInterface, array{RequestInterface, Promise}>|null
*/
private ?\SplObjectStorage $promisePool;
private HttplugWaitLoop $waitLoop;
public function __construct(HttpClientInterface $client = null, ResponseFactoryInterface $responseFactory = null, StreamFactoryInterface $streamFactory = null)
{
$this->client = $client ?? HttpClient::create();
$streamFactory ??= $responseFactory instanceof StreamFactoryInterface ? $responseFactory : null;
$this->promisePool = class_exists(Utils::class) ? new \SplObjectStorage() : null;
if (null === $responseFactory || null === $streamFactory) {
if (!class_exists(Psr17Factory::class) && !class_exists(Psr17FactoryDiscovery::class)) {
throw new \LogicException('You cannot use the "Symfony\Component\HttpClient\HttplugClient" as no PSR-17 factories have been provided. Try running "composer require nyholm/psr7".');
}
try {
$psr17Factory = class_exists(Psr17Factory::class, false) ? new Psr17Factory() : null;
$responseFactory ??= $psr17Factory ?? Psr17FactoryDiscovery::findResponseFactory();
$streamFactory ??= $psr17Factory ?? Psr17FactoryDiscovery::findStreamFactory();
} catch (NotFoundException $e) {
throw new \LogicException('You cannot use the "Symfony\Component\HttpClient\HttplugClient" as no PSR-17 factories have been found. Try running "composer require nyholm/psr7".', 0, $e);
}
}
$this->responseFactory = $responseFactory;
$this->streamFactory = $streamFactory;
$this->waitLoop = new HttplugWaitLoop($this->client, $this->promisePool, $this->responseFactory, $this->streamFactory);
}
public function withOptions(array $options): static
{
$clone = clone $this;
$clone->client = $clone->client->withOptions($options);
return $clone;
}
public function sendRequest(RequestInterface $request): Psr7ResponseInterface
{
try {
return $this->waitLoop->createPsr7Response($this->sendPsr7Request($request));
} catch (TransportExceptionInterface $e) {
throw new NetworkException($e->getMessage(), $request, $e);
}
}
public function sendAsyncRequest(RequestInterface $request): HttplugPromise
{
if (!$promisePool = $this->promisePool) {
throw new \LogicException(sprintf('You cannot use "%s()" as the "guzzlehttp/promises" package is not installed. Try running "composer require guzzlehttp/promises".', __METHOD__));
}
try {
$response = $this->sendPsr7Request($request, true);
} catch (NetworkException $e) {
return new HttplugPromise(new RejectedPromise($e));
}
$waitLoop = $this->waitLoop;
$promise = new GuzzlePromise(static function () use ($response, $waitLoop) {
$waitLoop->wait($response);
}, static function () use ($response, $promisePool) {
$response->cancel();
unset($promisePool[$response]);
});
$promisePool[$response] = [$request, $promise];
return new HttplugPromise($promise);
}
/**
* Resolves pending promises that complete before the timeouts are reached.
*
* When $maxDuration is null and $idleTimeout is reached, promises are rejected.
*
* @return int The number of remaining pending promises
*/
public function wait(float $maxDuration = null, float $idleTimeout = null): int
{
return $this->waitLoop->wait(null, $maxDuration, $idleTimeout);
}
/**
* @param string $method
* @param UriInterface|string $uri
*/
public function createRequest($method, $uri, array $headers = [], $body = null, $protocolVersion = '1.1'): RequestInterface
{
if (2 < \func_num_args()) {
trigger_deprecation('symfony/http-client', '6.2', 'Passing more than 2 arguments to "%s()" is deprecated.', __METHOD__);
}
if ($this->responseFactory instanceof RequestFactoryInterface) {
$request = $this->responseFactory->createRequest($method, $uri);
} elseif (class_exists(Request::class)) {
$request = new Request($method, $uri);
} elseif (class_exists(Psr17FactoryDiscovery::class)) {
$request = Psr17FactoryDiscovery::findRequestFactory()->createRequest($method, $uri);
} else {
throw new \LogicException(sprintf('You cannot use "%s()" as the "nyholm/psr7" package is not installed. Try running "composer require nyholm/psr7".', __METHOD__));
}
$request = $request
->withProtocolVersion($protocolVersion)
->withBody($this->createStream($body ?? ''))
;
foreach ($headers as $name => $value) {
$request = $request->withAddedHeader($name, $value);
}
return $request;
}
/**
* @param string $content
*/
public function createStream($content = ''): StreamInterface
{
if (!\is_string($content)) {
trigger_deprecation('symfony/http-client', '6.2', 'Passing a "%s" to "%s()" is deprecated, use "createStreamFrom*()" instead.', get_debug_type($content), __METHOD__);
}
if ($content instanceof StreamInterface) {
return $content;
}
if (\is_string($content ?? '')) {
$stream = $this->streamFactory->createStream($content ?? '');
} elseif (\is_resource($content)) {
$stream = $this->streamFactory->createStreamFromResource($content);
} else {
throw new \InvalidArgumentException(sprintf('"%s()" expects string, resource or StreamInterface, "%s" given.', __METHOD__, get_debug_type($content)));
}
if ($stream->isSeekable()) {
$stream->seek(0);
}
return $stream;
}
public function createStreamFromFile(string $filename, string $mode = 'r'): StreamInterface
{
return $this->streamFactory->createStreamFromFile($filename, $mode);
}
public function createStreamFromResource($resource): StreamInterface
{
return $this->streamFactory->createStreamFromResource($resource);
}
/**
* @param string $uri
*/
public function createUri($uri = ''): UriInterface
{
if (!\is_string($uri)) {
trigger_deprecation('symfony/http-client', '6.2', 'Passing a "%s" to "%s()" is deprecated, pass a string instead.', get_debug_type($uri), __METHOD__);
}
if ($uri instanceof UriInterface) {
return $uri;
}
if ($this->responseFactory instanceof UriFactoryInterface) {
return $this->responseFactory->createUri($uri);
}
if (class_exists(Uri::class)) {
return new Uri($uri);
}
if (class_exists(Psr17FactoryDiscovery::class)) {
return Psr17FactoryDiscovery::findUrlFactory()->createUri($uri);
}
throw new \LogicException(sprintf('You cannot use "%s()" as the "nyholm/psr7" package is not installed. Try running "composer require nyholm/psr7".', __METHOD__));
}
public function __sleep(): array
{
throw new \BadMethodCallException('Cannot serialize '.__CLASS__);
}
public function __wakeup()
{
throw new \BadMethodCallException('Cannot unserialize '.__CLASS__);
}
public function __destruct()
{
$this->wait();
}
public function reset()
{
if ($this->client instanceof ResetInterface) {
$this->client->reset();
}
}
private function sendPsr7Request(RequestInterface $request, bool $buffer = null): ResponseInterface
{
try {
$body = $request->getBody();
if ($body->isSeekable()) {
$body->seek(0);
}
$options = [
'headers' => $request->getHeaders(),
'body' => $body->getContents(),
'buffer' => $buffer,
];
if ('1.0' === $request->getProtocolVersion()) {
$options['http_version'] = '1.0';
}
return $this->client->request($request->getMethod(), (string) $request->getUri(), $options);
} catch (\InvalidArgumentException $e) {
throw new RequestException($e->getMessage(), $request, $e);
} catch (TransportExceptionInterface $e) {
throw new NetworkException($e->getMessage(), $request, $e);
}
}
}

View File

@@ -0,0 +1,147 @@
<?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\Internal;
use Amp\ByteStream\InputStream;
use Amp\ByteStream\ResourceInputStream;
use Amp\Http\Client\RequestBody;
use Amp\Promise;
use Amp\Success;
use Symfony\Component\HttpClient\Exception\TransportException;
/**
* @author Nicolas Grekas <p@tchwork.com>
*
* @internal
*/
class AmpBody implements RequestBody, InputStream
{
private ResourceInputStream|\Closure|string $body;
private array $info;
private \Closure $onProgress;
private ?int $offset = 0;
private int $length = -1;
private ?int $uploaded = null;
/**
* @param \Closure|resource|string $body
*/
public function __construct($body, &$info, \Closure $onProgress)
{
$this->info = &$info;
$this->onProgress = $onProgress;
if (\is_resource($body)) {
$this->offset = ftell($body);
$this->length = fstat($body)['size'];
$this->body = new ResourceInputStream($body);
} elseif (\is_string($body)) {
$this->length = \strlen($body);
$this->body = $body;
} else {
$this->body = $body;
}
}
public function createBodyStream(): InputStream
{
if (null !== $this->uploaded) {
$this->uploaded = null;
if (\is_string($this->body)) {
$this->offset = 0;
} elseif ($this->body instanceof ResourceInputStream) {
fseek($this->body->getResource(), $this->offset);
}
}
return $this;
}
public function getHeaders(): Promise
{
return new Success([]);
}
public function getBodyLength(): Promise
{
return new Success($this->length - $this->offset);
}
public function read(): Promise
{
$this->info['size_upload'] += $this->uploaded;
$this->uploaded = 0;
($this->onProgress)();
$chunk = $this->doRead();
$chunk->onResolve(function ($e, $data) {
if (null !== $data) {
$this->uploaded = \strlen($data);
} else {
$this->info['upload_content_length'] = $this->info['size_upload'];
}
});
return $chunk;
}
public static function rewind(RequestBody $body): RequestBody
{
if (!$body instanceof self) {
return $body;
}
$body->uploaded = null;
if ($body->body instanceof ResourceInputStream) {
fseek($body->body->getResource(), $body->offset);
return new $body($body->body, $body->info, $body->onProgress);
}
if (\is_string($body->body)) {
$body->offset = 0;
}
return $body;
}
private function doRead(): Promise
{
if ($this->body instanceof ResourceInputStream) {
return $this->body->read();
}
if (null === $this->offset || !$this->length) {
return new Success();
}
if (\is_string($this->body)) {
$this->offset = null;
return new Success($this->body);
}
if ('' === $data = ($this->body)(16372)) {
$this->offset = null;
return new Success();
}
if (!\is_string($data)) {
throw new TransportException(sprintf('Return value of the "body" option callback must be string, "%s" returned.', get_debug_type($data)));
}
return new Success($data);
}
}

View File

@@ -0,0 +1,217 @@
<?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\Internal;
use Amp\CancellationToken;
use Amp\Deferred;
use Amp\Http\Client\Connection\ConnectionLimitingPool;
use Amp\Http\Client\Connection\DefaultConnectionFactory;
use Amp\Http\Client\InterceptedHttpClient;
use Amp\Http\Client\Interceptor\RetryRequests;
use Amp\Http\Client\PooledHttpClient;
use Amp\Http\Client\Request;
use Amp\Http\Client\Response;
use Amp\Http\Tunnel\Http1TunnelConnector;
use Amp\Http\Tunnel\Https1TunnelConnector;
use Amp\Promise;
use Amp\Socket\Certificate;
use Amp\Socket\ClientTlsContext;
use Amp\Socket\ConnectContext;
use Amp\Socket\Connector;
use Amp\Socket\DnsConnector;
use Amp\Socket\SocketAddress;
use Amp\Success;
use Psr\Log\LoggerInterface;
/**
* Internal representation of the Amp client's state.
*
* @author Nicolas Grekas <p@tchwork.com>
*
* @internal
*/
final class AmpClientState extends ClientState
{
public array $dnsCache = [];
public int $responseCount = 0;
public array $pushedResponses = [];
private array $clients = [];
private \Closure $clientConfigurator;
private int $maxHostConnections;
private int $maxPendingPushes;
private ?LoggerInterface $logger;
public function __construct(?callable $clientConfigurator, int $maxHostConnections, int $maxPendingPushes, ?LoggerInterface &$logger)
{
$clientConfigurator ??= static fn (PooledHttpClient $client) => new InterceptedHttpClient($client, new RetryRequests(2));
$this->clientConfigurator = $clientConfigurator(...);
$this->maxHostConnections = $maxHostConnections;
$this->maxPendingPushes = $maxPendingPushes;
$this->logger = &$logger;
}
/**
* @return Promise<Response>
*/
public function request(array $options, Request $request, CancellationToken $cancellation, array &$info, \Closure $onProgress, &$handle): Promise
{
if ($options['proxy']) {
if ($request->hasHeader('proxy-authorization')) {
$options['proxy']['auth'] = $request->getHeader('proxy-authorization');
}
// Matching "no_proxy" should follow the behavior of curl
$host = $request->getUri()->getHost();
foreach ($options['proxy']['no_proxy'] as $rule) {
$dotRule = '.'.ltrim($rule, '.');
if ('*' === $rule || $host === $rule || str_ends_with($host, $dotRule)) {
$options['proxy'] = null;
break;
}
}
}
$request = clone $request;
if ($request->hasHeader('proxy-authorization')) {
$request->removeHeader('proxy-authorization');
}
if ($options['capture_peer_cert_chain']) {
$info['peer_certificate_chain'] = [];
}
$request->addEventListener(new AmpListener($info, $options['peer_fingerprint']['pin-sha256'] ?? [], $onProgress, $handle));
$request->setPushHandler(function ($request, $response) use ($options): Promise {
return $this->handlePush($request, $response, $options);
});
($request->hasHeader('content-length') ? new Success((int) $request->getHeader('content-length')) : $request->getBody()->getBodyLength())
->onResolve(static function ($e, $bodySize) use (&$info) {
if (null !== $bodySize && 0 <= $bodySize) {
$info['upload_content_length'] = ((1 + $info['upload_content_length']) ?? 1) - 1 + $bodySize;
}
});
[$client, $connector] = $this->getClient($options);
$response = $client->request($request, $cancellation);
$response->onResolve(static function ($e) use ($connector, &$handle) {
if (null === $e) {
$handle = $connector->handle;
}
});
return $response;
}
private function getClient(array $options): array
{
$options = [
'bindto' => $options['bindto'] ?: '0',
'verify_peer' => $options['verify_peer'],
'capath' => $options['capath'],
'cafile' => $options['cafile'],
'local_cert' => $options['local_cert'],
'local_pk' => $options['local_pk'],
'ciphers' => $options['ciphers'],
'capture_peer_cert_chain' => $options['capture_peer_cert_chain'] || $options['peer_fingerprint'],
'proxy' => $options['proxy'],
];
$key = md5(serialize($options));
if (isset($this->clients[$key])) {
return $this->clients[$key];
}
$context = new ClientTlsContext('');
$options['verify_peer'] || $context = $context->withoutPeerVerification();
$options['cafile'] && $context = $context->withCaFile($options['cafile']);
$options['capath'] && $context = $context->withCaPath($options['capath']);
$options['local_cert'] && $context = $context->withCertificate(new Certificate($options['local_cert'], $options['local_pk']));
$options['ciphers'] && $context = $context->withCiphers($options['ciphers']);
$options['capture_peer_cert_chain'] && $context = $context->withPeerCapturing();
$connector = $handleConnector = new class() implements Connector {
public $connector;
public $uri;
public $handle;
public function connect(string $uri, ConnectContext $context = null, CancellationToken $token = null): Promise
{
$result = $this->connector->connect($this->uri ?? $uri, $context, $token);
$result->onResolve(function ($e, $socket) {
$this->handle = null !== $socket ? $socket->getResource() : false;
});
return $result;
}
};
$connector->connector = new DnsConnector(new AmpResolver($this->dnsCache));
$context = (new ConnectContext())
->withTcpNoDelay()
->withTlsContext($context);
if ($options['bindto']) {
if (file_exists($options['bindto'])) {
$connector->uri = 'unix://'.$options['bindto'];
} else {
$context = $context->withBindTo($options['bindto']);
}
}
if ($options['proxy']) {
$proxyUrl = parse_url($options['proxy']['url']);
$proxySocket = new SocketAddress($proxyUrl['host'], $proxyUrl['port']);
$proxyHeaders = $options['proxy']['auth'] ? ['Proxy-Authorization' => $options['proxy']['auth']] : [];
if ('ssl' === $proxyUrl['scheme']) {
$connector = new Https1TunnelConnector($proxySocket, $context->getTlsContext(), $proxyHeaders, $connector);
} else {
$connector = new Http1TunnelConnector($proxySocket, $proxyHeaders, $connector);
}
}
$maxHostConnections = 0 < $this->maxHostConnections ? $this->maxHostConnections : \PHP_INT_MAX;
$pool = new DefaultConnectionFactory($connector, $context);
$pool = ConnectionLimitingPool::byAuthority($maxHostConnections, $pool);
return $this->clients[$key] = [($this->clientConfigurator)(new PooledHttpClient($pool)), $handleConnector];
}
private function handlePush(Request $request, Promise $response, array $options): Promise
{
$deferred = new Deferred();
$authority = $request->getUri()->getAuthority();
if ($this->maxPendingPushes <= \count($this->pushedResponses[$authority] ?? [])) {
$fifoUrl = key($this->pushedResponses[$authority]);
unset($this->pushedResponses[$authority][$fifoUrl]);
$this->logger?->debug(sprintf('Evicting oldest pushed response: "%s"', $fifoUrl));
}
$url = (string) $request->getUri();
$this->logger?->debug(sprintf('Queueing pushed response: "%s"', $url));
$this->pushedResponses[$authority][] = [$url, $deferred, $request, $response, [
'proxy' => $options['proxy'],
'bindto' => $options['bindto'],
'local_cert' => $options['local_cert'],
'local_pk' => $options['local_pk'],
]];
return $deferred->promise();
}
}

View File

@@ -0,0 +1,183 @@
<?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\Internal;
use Amp\Http\Client\Connection\Stream;
use Amp\Http\Client\EventListener;
use Amp\Http\Client\Request;
use Amp\Promise;
use Amp\Success;
use Symfony\Component\HttpClient\Exception\TransportException;
/**
* @author Nicolas Grekas <p@tchwork.com>
*
* @internal
*/
class AmpListener implements EventListener
{
private array $info;
private array $pinSha256;
private \Closure $onProgress;
private $handle;
public function __construct(array &$info, array $pinSha256, \Closure $onProgress, &$handle)
{
$info += [
'connect_time' => 0.0,
'pretransfer_time' => 0.0,
'starttransfer_time' => 0.0,
'total_time' => 0.0,
'namelookup_time' => 0.0,
'primary_ip' => '',
'primary_port' => 0,
];
$this->info = &$info;
$this->pinSha256 = $pinSha256;
$this->onProgress = $onProgress;
$this->handle = &$handle;
}
public function startRequest(Request $request): Promise
{
$this->info['start_time'] ??= microtime(true);
($this->onProgress)();
return new Success();
}
public function startDnsResolution(Request $request): Promise
{
($this->onProgress)();
return new Success();
}
public function startConnectionCreation(Request $request): Promise
{
($this->onProgress)();
return new Success();
}
public function startTlsNegotiation(Request $request): Promise
{
($this->onProgress)();
return new Success();
}
public function startSendingRequest(Request $request, Stream $stream): Promise
{
$host = $stream->getRemoteAddress()->getHost();
if (str_contains($host, ':')) {
$host = '['.$host.']';
}
$this->info['primary_ip'] = $host;
$this->info['primary_port'] = $stream->getRemoteAddress()->getPort();
$this->info['pretransfer_time'] = microtime(true) - $this->info['start_time'];
$this->info['debug'] .= sprintf("* Connected to %s (%s) port %d\n", $request->getUri()->getHost(), $host, $this->info['primary_port']);
if ((isset($this->info['peer_certificate_chain']) || $this->pinSha256) && null !== $tlsInfo = $stream->getTlsInfo()) {
foreach ($tlsInfo->getPeerCertificates() as $cert) {
$this->info['peer_certificate_chain'][] = openssl_x509_read($cert->toPem());
}
if ($this->pinSha256) {
$pin = openssl_pkey_get_public($this->info['peer_certificate_chain'][0]);
$pin = openssl_pkey_get_details($pin)['key'];
$pin = \array_slice(explode("\n", $pin), 1, -2);
$pin = base64_decode(implode('', $pin));
$pin = base64_encode(hash('sha256', $pin, true));
if (!\in_array($pin, $this->pinSha256, true)) {
throw new TransportException(sprintf('SSL public key does not match pinned public key for "%s".', $this->info['url']));
}
}
}
($this->onProgress)();
$uri = $request->getUri();
$requestUri = $uri->getPath() ?: '/';
if ('' !== $query = $uri->getQuery()) {
$requestUri .= '?'.$query;
}
if ('CONNECT' === $method = $request->getMethod()) {
$requestUri = $uri->getHost().': '.($uri->getPort() ?? ('https' === $uri->getScheme() ? 443 : 80));
}
$this->info['debug'] .= sprintf("> %s %s HTTP/%s \r\n", $method, $requestUri, $request->getProtocolVersions()[0]);
foreach ($request->getRawHeaders() as [$name, $value]) {
$this->info['debug'] .= $name.': '.$value."\r\n";
}
$this->info['debug'] .= "\r\n";
return new Success();
}
public function completeSendingRequest(Request $request, Stream $stream): Promise
{
($this->onProgress)();
return new Success();
}
public function startReceivingResponse(Request $request, Stream $stream): Promise
{
$this->info['starttransfer_time'] = microtime(true) - $this->info['start_time'];
($this->onProgress)();
return new Success();
}
public function completeReceivingResponse(Request $request, Stream $stream): Promise
{
$this->handle = null;
($this->onProgress)();
return new Success();
}
public function completeDnsResolution(Request $request): Promise
{
$this->info['namelookup_time'] = microtime(true) - $this->info['start_time'];
($this->onProgress)();
return new Success();
}
public function completeConnectionCreation(Request $request): Promise
{
$this->info['connect_time'] = microtime(true) - $this->info['start_time'];
($this->onProgress)();
return new Success();
}
public function completeTlsNegotiation(Request $request): Promise
{
($this->onProgress)();
return new Success();
}
public function abort(Request $request, \Throwable $cause): Promise
{
return new Success();
}
}

View File

@@ -0,0 +1,52 @@
<?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\Internal;
use Amp\Dns;
use Amp\Dns\Record;
use Amp\Promise;
use Amp\Success;
/**
* Handles local overrides for the DNS resolver.
*
* @author Nicolas Grekas <p@tchwork.com>
*
* @internal
*/
class AmpResolver implements Dns\Resolver
{
private array $dnsMap;
public function __construct(array &$dnsMap)
{
$this->dnsMap = &$dnsMap;
}
public function resolve(string $name, int $typeRestriction = null): Promise
{
if (!isset($this->dnsMap[$name]) || !\in_array($typeRestriction, [Record::A, null], true)) {
return Dns\resolver()->resolve($name, $typeRestriction);
}
return new Success([new Record($this->dnsMap[$name], Record::A, null)]);
}
public function query(string $name, int $type): Promise
{
if (!isset($this->dnsMap[$name]) || Record::A !== $type) {
return Dns\resolver()->query($name, $type);
}
return new Success([new Record($this->dnsMap[$name], Record::A, null)]);
}
}

View File

@@ -0,0 +1,41 @@
<?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\Internal;
/**
* @author Nicolas Grekas <p@tchwork.com>
*
* @internal
*/
final class Canary
{
private \Closure $canceller;
public function __construct(\Closure $canceller)
{
$this->canceller = $canceller;
}
public function cancel()
{
if (isset($this->canceller)) {
$canceller = $this->canceller;
unset($this->canceller);
$canceller();
}
}
public function __destruct()
{
$this->cancel();
}
}

View File

@@ -0,0 +1,26 @@
<?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\Internal;
/**
* Internal representation of the client state.
*
* @author Alexander M. Turek <me@derrabus.de>
*
* @internal
*/
class ClientState
{
public array $handlesActivity = [];
public array $openHandles = [];
public ?float $lastTimeout = null;
}

View File

@@ -0,0 +1,146 @@
<?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\Internal;
use Psr\Log\LoggerInterface;
use Symfony\Component\HttpClient\Response\CurlResponse;
/**
* Internal representation of the cURL client's state.
*
* @author Alexander M. Turek <me@derrabus.de>
*
* @internal
*/
final class CurlClientState extends ClientState
{
public ?\CurlMultiHandle $handle;
public ?\CurlShareHandle $share;
public bool $performing = false;
/** @var PushedResponse[] */
public array $pushedResponses = [];
public DnsCache $dnsCache;
/** @var float[] */
public array $pauseExpiries = [];
public int $execCounter = \PHP_INT_MIN;
public ?LoggerInterface $logger = null;
public static array $curlVersion;
public function __construct(int $maxHostConnections, int $maxPendingPushes)
{
self::$curlVersion ??= curl_version();
$this->handle = curl_multi_init();
$this->dnsCache = new DnsCache();
$this->reset();
// Don't enable HTTP/1.1 pipelining: it forces responses to be sent in order
if (\defined('CURLPIPE_MULTIPLEX')) {
curl_multi_setopt($this->handle, \CURLMOPT_PIPELINING, \CURLPIPE_MULTIPLEX);
}
if (\defined('CURLMOPT_MAX_HOST_CONNECTIONS')) {
$maxHostConnections = curl_multi_setopt($this->handle, \CURLMOPT_MAX_HOST_CONNECTIONS, 0 < $maxHostConnections ? $maxHostConnections : \PHP_INT_MAX) ? 0 : $maxHostConnections;
}
if (\defined('CURLMOPT_MAXCONNECTS') && 0 < $maxHostConnections) {
curl_multi_setopt($this->handle, \CURLMOPT_MAXCONNECTS, $maxHostConnections);
}
// Skip configuring HTTP/2 push when it's unsupported or buggy, see https://bugs.php.net/77535
if (0 >= $maxPendingPushes) {
return;
}
// HTTP/2 push crashes before curl 7.61
if (!\defined('CURLMOPT_PUSHFUNCTION') || 0x073D00 > self::$curlVersion['version_number'] || !(\CURL_VERSION_HTTP2 & self::$curlVersion['features'])) {
return;
}
// Clone to prevent a circular reference
$multi = clone $this;
$multi->handle = null;
$multi->share = null;
$multi->pushedResponses = &$this->pushedResponses;
$multi->logger = &$this->logger;
$multi->handlesActivity = &$this->handlesActivity;
$multi->openHandles = &$this->openHandles;
curl_multi_setopt($this->handle, \CURLMOPT_PUSHFUNCTION, static function ($parent, $pushed, array $requestHeaders) use ($multi, $maxPendingPushes) {
return $multi->handlePush($parent, $pushed, $requestHeaders, $maxPendingPushes);
});
}
public function reset()
{
foreach ($this->pushedResponses as $url => $response) {
$this->logger?->debug(sprintf('Unused pushed response: "%s"', $url));
curl_multi_remove_handle($this->handle, $response->handle);
curl_close($response->handle);
}
$this->pushedResponses = [];
$this->dnsCache->evictions = $this->dnsCache->evictions ?: $this->dnsCache->removals;
$this->dnsCache->removals = $this->dnsCache->hostnames = [];
$this->share = curl_share_init();
curl_share_setopt($this->share, \CURLSHOPT_SHARE, \CURL_LOCK_DATA_DNS);
curl_share_setopt($this->share, \CURLSHOPT_SHARE, \CURL_LOCK_DATA_SSL_SESSION);
if (\defined('CURL_LOCK_DATA_CONNECT') && \PHP_VERSION_ID >= 80000) {
curl_share_setopt($this->share, \CURLSHOPT_SHARE, \CURL_LOCK_DATA_CONNECT);
}
}
private function handlePush($parent, $pushed, array $requestHeaders, int $maxPendingPushes): int
{
$headers = [];
$origin = curl_getinfo($parent, \CURLINFO_EFFECTIVE_URL);
foreach ($requestHeaders as $h) {
if (false !== $i = strpos($h, ':', 1)) {
$headers[substr($h, 0, $i)][] = substr($h, 1 + $i);
}
}
if (!isset($headers[':method']) || !isset($headers[':scheme']) || !isset($headers[':authority']) || !isset($headers[':path'])) {
$this->logger?->debug(sprintf('Rejecting pushed response from "%s": pushed headers are invalid', $origin));
return \CURL_PUSH_DENY;
}
$url = $headers[':scheme'][0].'://'.$headers[':authority'][0];
// curl before 7.65 doesn't validate the pushed ":authority" header,
// but this is a MUST in the HTTP/2 RFC; let's restrict pushes to the original host,
// ignoring domains mentioned as alt-name in the certificate for now (same as curl).
if (!str_starts_with($origin, $url.'/')) {
$this->logger?->debug(sprintf('Rejecting pushed response from "%s": server is not authoritative for "%s"', $origin, $url));
return \CURL_PUSH_DENY;
}
if ($maxPendingPushes <= \count($this->pushedResponses)) {
$fifoUrl = key($this->pushedResponses);
unset($this->pushedResponses[$fifoUrl]);
$this->logger?->debug(sprintf('Evicting oldest pushed response: "%s"', $fifoUrl));
}
$url .= $headers[':path'][0];
$this->logger?->debug(sprintf('Queueing pushed response: "%s"', $url));
$this->pushedResponses[$url] = new PushedResponse(new CurlResponse($this, $pushed), $headers, $this->openHandles[(int) $parent][1] ?? [], $pushed);
return \CURL_PUSH_OK;
}
}

View File

@@ -0,0 +1,39 @@
<?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\Internal;
/**
* Cache for resolved DNS queries.
*
* @author Alexander M. Turek <me@derrabus.de>
*
* @internal
*/
final class DnsCache
{
/**
* Resolved hostnames (hostname => IP address).
*
* @var string[]
*/
public array $hostnames = [];
/**
* @var string[]
*/
public array $removals = [];
/**
* @var string[]
*/
public array $evictions = [];
}

View File

@@ -0,0 +1,145 @@
<?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\Internal;
use Http\Client\Exception\NetworkException;
use Http\Promise\Promise;
use Psr\Http\Message\RequestInterface as Psr7RequestInterface;
use Psr\Http\Message\ResponseFactoryInterface;
use Psr\Http\Message\ResponseInterface as Psr7ResponseInterface;
use Psr\Http\Message\StreamFactoryInterface;
use Symfony\Component\HttpClient\Response\StreamableInterface;
use Symfony\Component\HttpClient\Response\StreamWrapper;
use Symfony\Contracts\HttpClient\Exception\TransportExceptionInterface;
use Symfony\Contracts\HttpClient\HttpClientInterface;
use Symfony\Contracts\HttpClient\ResponseInterface;
/**
* @author Nicolas Grekas <p@tchwork.com>
*
* @internal
*/
final class HttplugWaitLoop
{
private HttpClientInterface $client;
private ?\SplObjectStorage $promisePool;
private ResponseFactoryInterface $responseFactory;
private StreamFactoryInterface $streamFactory;
/**
* @param \SplObjectStorage<ResponseInterface, array{Psr7RequestInterface, Promise}>|null $promisePool
*/
public function __construct(HttpClientInterface $client, ?\SplObjectStorage $promisePool, ResponseFactoryInterface $responseFactory, StreamFactoryInterface $streamFactory)
{
$this->client = $client;
$this->promisePool = $promisePool;
$this->responseFactory = $responseFactory;
$this->streamFactory = $streamFactory;
}
public function wait(?ResponseInterface $pendingResponse, float $maxDuration = null, float $idleTimeout = null): int
{
if (!$this->promisePool) {
return 0;
}
$guzzleQueue = \GuzzleHttp\Promise\Utils::queue();
if (0.0 === $remainingDuration = $maxDuration) {
$idleTimeout = 0.0;
} elseif (null !== $maxDuration) {
$startTime = microtime(true);
$idleTimeout = max(0.0, min($maxDuration / 5, $idleTimeout ?? $maxDuration));
}
do {
foreach ($this->client->stream($this->promisePool, $idleTimeout) as $response => $chunk) {
try {
if (null !== $maxDuration && $chunk->isTimeout()) {
goto check_duration;
}
if ($chunk->isFirst()) {
// Deactivate throwing on 3/4/5xx
$response->getStatusCode();
}
if (!$chunk->isLast()) {
goto check_duration;
}
if ([, $promise] = $this->promisePool[$response] ?? null) {
unset($this->promisePool[$response]);
$promise->resolve($this->createPsr7Response($response, true));
}
} catch (\Exception $e) {
if ([$request, $promise] = $this->promisePool[$response] ?? null) {
unset($this->promisePool[$response]);
if ($e instanceof TransportExceptionInterface) {
$e = new NetworkException($e->getMessage(), $request, $e);
}
$promise->reject($e);
}
}
$guzzleQueue->run();
if ($pendingResponse === $response) {
return $this->promisePool->count();
}
check_duration:
if (null !== $maxDuration && $idleTimeout && $idleTimeout > $remainingDuration = max(0.0, $maxDuration - microtime(true) + $startTime)) {
$idleTimeout = $remainingDuration / 5;
break;
}
}
if (!$count = $this->promisePool->count()) {
return 0;
}
} while (null === $maxDuration || 0 < $remainingDuration);
return $count;
}
public function createPsr7Response(ResponseInterface $response, bool $buffer = false): Psr7ResponseInterface
{
$psrResponse = $this->responseFactory->createResponse($response->getStatusCode());
foreach ($response->getHeaders(false) as $name => $values) {
foreach ($values as $value) {
try {
$psrResponse = $psrResponse->withAddedHeader($name, $value);
} catch (\InvalidArgumentException $e) {
// ignore invalid header
}
}
}
if ($response instanceof StreamableInterface) {
$body = $this->streamFactory->createStreamFromResource($response->toStream(false));
} elseif (!$buffer) {
$body = $this->streamFactory->createStreamFromResource(StreamWrapper::createResource($response, $this->client));
} else {
$body = $this->streamFactory->createStream($response->getContent(false));
}
if ($body->isSeekable()) {
$body->seek(0);
}
return $psrResponse->withBody($body);
}
}

View File

@@ -0,0 +1,43 @@
<?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\Internal;
/**
* Internal representation of the native client's state.
*
* @author Alexander M. Turek <me@derrabus.de>
*
* @internal
*/
final class NativeClientState extends ClientState
{
public int $id;
public int $maxHostConnections = \PHP_INT_MAX;
public int $responseCount = 0;
/** @var string[] */
public array $dnsCache = [];
public bool $sleep = false;
/** @var int[] */
public array $hosts = [];
public function __construct()
{
$this->id = random_int(\PHP_INT_MIN, \PHP_INT_MAX);
}
public function reset()
{
$this->responseCount = 0;
$this->dnsCache = [];
$this->hosts = [];
}
}

View File

@@ -0,0 +1,41 @@
<?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\Internal;
use Symfony\Component\HttpClient\Response\CurlResponse;
/**
* A pushed response with its request headers.
*
* @author Alexander M. Turek <me@derrabus.de>
*
* @internal
*/
final class PushedResponse
{
public CurlResponse $response;
/** @var string[] */
public array $requestHeaders;
public array $parentOptions = [];
public $handle;
public function __construct(CurlResponse $response, array $requestHeaders, array $parentOptions, $handle)
{
$this->response = $response;
$this->requestHeaders = $requestHeaders;
$this->parentOptions = $parentOptions;
$this->handle = $handle;
}
}

View File

@@ -0,0 +1,19 @@
Copyright (c) 2018-present Fabien Potencier
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is furnished
to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
THE SOFTWARE.

View File

@@ -0,0 +1,113 @@
<?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;
use Symfony\Component\HttpClient\Exception\TransportException;
use Symfony\Component\HttpClient\Response\MockResponse;
use Symfony\Component\HttpClient\Response\ResponseStream;
use Symfony\Contracts\HttpClient\HttpClientInterface;
use Symfony\Contracts\HttpClient\ResponseInterface;
use Symfony\Contracts\HttpClient\ResponseStreamInterface;
use Symfony\Contracts\Service\ResetInterface;
/**
* A test-friendly HttpClient that doesn't make actual HTTP requests.
*
* @author Nicolas Grekas <p@tchwork.com>
*/
class MockHttpClient implements HttpClientInterface, ResetInterface
{
use HttpClientTrait;
private ResponseInterface|\Closure|iterable|null $responseFactory;
private int $requestsCount = 0;
private array $defaultOptions = [];
/**
* @param callable|callable[]|ResponseInterface|ResponseInterface[]|iterable|null $responseFactory
*/
public function __construct(callable|iterable|ResponseInterface $responseFactory = null, ?string $baseUri = 'https://example.com')
{
$this->setResponseFactory($responseFactory);
$this->defaultOptions['base_uri'] = $baseUri;
}
/**
* @param callable|callable[]|ResponseInterface|ResponseInterface[]|iterable|null $responseFactory
*/
public function setResponseFactory($responseFactory): void
{
if ($responseFactory instanceof ResponseInterface) {
$responseFactory = [$responseFactory];
}
if (!$responseFactory instanceof \Iterator && null !== $responseFactory && !\is_callable($responseFactory)) {
$responseFactory = (static function () use ($responseFactory) {
yield from $responseFactory;
})();
}
$this->responseFactory = !\is_callable($responseFactory) ? $responseFactory : $responseFactory(...);
}
public function request(string $method, string $url, array $options = []): ResponseInterface
{
[$url, $options] = $this->prepareRequest($method, $url, $options, $this->defaultOptions, true);
$url = implode('', $url);
if (null === $this->responseFactory) {
$response = new MockResponse();
} elseif (\is_callable($this->responseFactory)) {
$response = ($this->responseFactory)($method, $url, $options);
} elseif (!$this->responseFactory->valid()) {
throw new TransportException('The response factory iterator passed to MockHttpClient is empty.');
} else {
$responseFactory = $this->responseFactory->current();
$response = \is_callable($responseFactory) ? $responseFactory($method, $url, $options) : $responseFactory;
$this->responseFactory->next();
}
++$this->requestsCount;
if (!$response instanceof ResponseInterface) {
throw new TransportException(sprintf('The response factory passed to MockHttpClient must return/yield an instance of ResponseInterface, "%s" given.', get_debug_type($response)));
}
return MockResponse::fromRequest($method, $url, $options, $response);
}
public function stream(ResponseInterface|iterable $responses, float $timeout = null): ResponseStreamInterface
{
if ($responses instanceof ResponseInterface) {
$responses = [$responses];
}
return new ResponseStream(MockResponse::stream($responses, $timeout));
}
public function getRequestsCount(): int
{
return $this->requestsCount;
}
public function withOptions(array $options): static
{
$clone = clone $this;
$clone->defaultOptions = self::mergeDefaultOptions($options, $this->defaultOptions, true);
return $clone;
}
public function reset()
{
$this->requestsCount = 0;
}
}

View File

@@ -0,0 +1,455 @@
<?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;
use Psr\Log\LoggerAwareInterface;
use Psr\Log\LoggerAwareTrait;
use Symfony\Component\HttpClient\Exception\InvalidArgumentException;
use Symfony\Component\HttpClient\Exception\TransportException;
use Symfony\Component\HttpClient\Internal\NativeClientState;
use Symfony\Component\HttpClient\Response\NativeResponse;
use Symfony\Component\HttpClient\Response\ResponseStream;
use Symfony\Contracts\HttpClient\HttpClientInterface;
use Symfony\Contracts\HttpClient\ResponseInterface;
use Symfony\Contracts\HttpClient\ResponseStreamInterface;
use Symfony\Contracts\Service\ResetInterface;
/**
* A portable implementation of the HttpClientInterface contracts based on PHP stream wrappers.
*
* PHP stream wrappers are able to fetch response bodies concurrently,
* but each request is opened synchronously.
*
* @author Nicolas Grekas <p@tchwork.com>
*/
final class NativeHttpClient implements HttpClientInterface, LoggerAwareInterface, ResetInterface
{
use HttpClientTrait;
use LoggerAwareTrait;
private array $defaultOptions = self::OPTIONS_DEFAULTS;
private static array $emptyDefaults = self::OPTIONS_DEFAULTS;
private NativeClientState $multi;
/**
* @param array $defaultOptions Default request's options
* @param int $maxHostConnections The maximum number of connections to open
*
* @see HttpClientInterface::OPTIONS_DEFAULTS for available options
*/
public function __construct(array $defaultOptions = [], int $maxHostConnections = 6)
{
$this->defaultOptions['buffer'] ??= self::shouldBuffer(...);
if ($defaultOptions) {
[, $this->defaultOptions] = self::prepareRequest(null, null, $defaultOptions, $this->defaultOptions);
}
$this->multi = new NativeClientState();
$this->multi->maxHostConnections = 0 < $maxHostConnections ? $maxHostConnections : \PHP_INT_MAX;
}
/**
* @see HttpClientInterface::OPTIONS_DEFAULTS for available options
*/
public function request(string $method, string $url, array $options = []): ResponseInterface
{
[$url, $options] = self::prepareRequest($method, $url, $options, $this->defaultOptions);
if ($options['bindto']) {
if (file_exists($options['bindto'])) {
throw new TransportException(__CLASS__.' cannot bind to local Unix sockets, use e.g. CurlHttpClient instead.');
}
if (str_starts_with($options['bindto'], 'if!')) {
throw new TransportException(__CLASS__.' cannot bind to network interfaces, use e.g. CurlHttpClient instead.');
}
if (str_starts_with($options['bindto'], 'host!')) {
$options['bindto'] = substr($options['bindto'], 5);
}
}
$hasContentLength = isset($options['normalized_headers']['content-length']);
$hasBody = '' !== $options['body'] || 'POST' === $method || $hasContentLength;
$options['body'] = self::getBodyAsString($options['body']);
if ('chunked' === substr($options['normalized_headers']['transfer-encoding'][0] ?? '', \strlen('Transfer-Encoding: '))) {
unset($options['normalized_headers']['transfer-encoding']);
$options['headers'] = array_merge(...array_values($options['normalized_headers']));
$options['body'] = self::dechunk($options['body']);
}
if ('' === $options['body'] && $hasBody && !$hasContentLength) {
$options['headers'][] = 'Content-Length: 0';
}
if ($hasBody && !isset($options['normalized_headers']['content-type'])) {
$options['headers'][] = 'Content-Type: application/x-www-form-urlencoded';
}
if (\extension_loaded('zlib') && !isset($options['normalized_headers']['accept-encoding'])) {
// gzip is the most widely available algo, no need to deal with deflate
$options['headers'][] = 'Accept-Encoding: gzip';
}
if ($options['peer_fingerprint']) {
if (isset($options['peer_fingerprint']['pin-sha256']) && 1 === \count($options['peer_fingerprint'])) {
throw new TransportException(__CLASS__.' cannot verify "pin-sha256" fingerprints, please provide a "sha256" one.');
}
unset($options['peer_fingerprint']['pin-sha256']);
}
$info = [
'response_headers' => [],
'url' => $url,
'error' => null,
'canceled' => false,
'http_method' => $method,
'http_code' => 0,
'redirect_count' => 0,
'start_time' => 0.0,
'connect_time' => 0.0,
'redirect_time' => 0.0,
'pretransfer_time' => 0.0,
'starttransfer_time' => 0.0,
'total_time' => 0.0,
'namelookup_time' => 0.0,
'size_upload' => 0,
'size_download' => 0,
'size_body' => \strlen($options['body']),
'primary_ip' => '',
'primary_port' => 'http:' === $url['scheme'] ? 80 : 443,
'debug' => \extension_loaded('curl') ? '' : "* Enable the curl extension for better performance\n",
];
if ($onProgress = $options['on_progress']) {
// Memoize the last progress to ease calling the callback periodically when no network transfer happens
$lastProgress = [0, 0];
$maxDuration = 0 < $options['max_duration'] ? $options['max_duration'] : \INF;
$onProgress = static function (...$progress) use ($onProgress, &$lastProgress, &$info, $maxDuration) {
if ($info['total_time'] >= $maxDuration) {
throw new TransportException(sprintf('Max duration was reached for "%s".', implode('', $info['url'])));
}
$progressInfo = $info;
$progressInfo['url'] = implode('', $info['url']);
unset($progressInfo['size_body']);
if ($progress && -1 === $progress[0]) {
// Response completed
$lastProgress[0] = max($lastProgress);
} else {
$lastProgress = $progress ?: $lastProgress;
}
$onProgress($lastProgress[0], $lastProgress[1], $progressInfo);
};
} elseif (0 < $options['max_duration']) {
$maxDuration = $options['max_duration'];
$onProgress = static function () use (&$info, $maxDuration): void {
if ($info['total_time'] >= $maxDuration) {
throw new TransportException(sprintf('Max duration was reached for "%s".', implode('', $info['url'])));
}
};
}
// Always register a notification callback to compute live stats about the response
$notification = static function (int $code, int $severity, ?string $msg, int $msgCode, int $dlNow, int $dlSize) use ($onProgress, &$info) {
$info['total_time'] = microtime(true) - $info['start_time'];
if (\STREAM_NOTIFY_PROGRESS === $code) {
$info['starttransfer_time'] = $info['starttransfer_time'] ?: $info['total_time'];
$info['size_upload'] += $dlNow ? 0 : $info['size_body'];
$info['size_download'] = $dlNow;
} elseif (\STREAM_NOTIFY_CONNECT === $code) {
$info['connect_time'] = $info['total_time'];
$info['debug'] .= $info['request_header'];
unset($info['request_header']);
} else {
return;
}
if ($onProgress) {
$onProgress($dlNow, $dlSize);
}
};
if ($options['resolve']) {
$this->multi->dnsCache = $options['resolve'] + $this->multi->dnsCache;
}
$this->logger?->info(sprintf('Request: "%s %s"', $method, implode('', $url)));
if (!isset($options['normalized_headers']['user-agent'])) {
$options['headers'][] = 'User-Agent: Symfony HttpClient/Native';
}
if (0 < $options['max_duration']) {
$options['timeout'] = min($options['max_duration'], $options['timeout']);
}
$context = [
'http' => [
'protocol_version' => min($options['http_version'] ?: '1.1', '1.1'),
'method' => $method,
'content' => $options['body'],
'ignore_errors' => true,
'curl_verify_ssl_peer' => $options['verify_peer'],
'curl_verify_ssl_host' => $options['verify_host'],
'auto_decode' => false, // Disable dechunk filter, it's incompatible with stream_select()
'timeout' => $options['timeout'],
'follow_location' => false, // We follow redirects ourselves - the native logic is too limited
],
'ssl' => array_filter([
'verify_peer' => $options['verify_peer'],
'verify_peer_name' => $options['verify_host'],
'cafile' => $options['cafile'],
'capath' => $options['capath'],
'local_cert' => $options['local_cert'],
'local_pk' => $options['local_pk'],
'passphrase' => $options['passphrase'],
'ciphers' => $options['ciphers'],
'peer_fingerprint' => $options['peer_fingerprint'],
'capture_peer_cert_chain' => $options['capture_peer_cert_chain'],
'allow_self_signed' => (bool) $options['peer_fingerprint'],
'SNI_enabled' => true,
'disable_compression' => true,
], static function ($v) { return null !== $v; }),
'socket' => [
'bindto' => $options['bindto'],
'tcp_nodelay' => true,
],
];
$context = stream_context_create($context, ['notification' => $notification]);
$resolver = static function ($multi) use ($context, $options, $url, &$info, $onProgress) {
[$host, $port] = self::parseHostPort($url, $info);
if (!isset($options['normalized_headers']['host'])) {
$options['headers'][] = 'Host: '.$host.$port;
}
$proxy = self::getProxy($options['proxy'], $url, $options['no_proxy']);
if (!self::configureHeadersAndProxy($context, $host, $options['headers'], $proxy, 'https:' === $url['scheme'])) {
$ip = self::dnsResolve($host, $multi, $info, $onProgress);
$url['authority'] = substr_replace($url['authority'], $ip, -\strlen($host) - \strlen($port), \strlen($host));
}
return [self::createRedirectResolver($options, $host, $port, $proxy, $info, $onProgress), implode('', $url)];
};
return new NativeResponse($this->multi, $context, implode('', $url), $options, $info, $resolver, $onProgress, $this->logger);
}
public function stream(ResponseInterface|iterable $responses, float $timeout = null): ResponseStreamInterface
{
if ($responses instanceof NativeResponse) {
$responses = [$responses];
}
return new ResponseStream(NativeResponse::stream($responses, $timeout));
}
public function reset()
{
$this->multi->reset();
}
private static function getBodyAsString($body): string
{
if (\is_resource($body)) {
return stream_get_contents($body);
}
if (!$body instanceof \Closure) {
return $body;
}
$result = '';
while ('' !== $data = $body(self::$CHUNK_SIZE)) {
if (!\is_string($data)) {
throw new TransportException(sprintf('Return value of the "body" option callback must be string, "%s" returned.', get_debug_type($data)));
}
$result .= $data;
}
return $result;
}
/**
* Extracts the host and the port from the URL.
*/
private static function parseHostPort(array $url, array &$info): array
{
if ($port = parse_url($url['authority'], \PHP_URL_PORT) ?: '') {
$info['primary_port'] = $port;
$port = ':'.$port;
} else {
$info['primary_port'] = 'http:' === $url['scheme'] ? 80 : 443;
}
return [parse_url($url['authority'], \PHP_URL_HOST), $port];
}
/**
* Resolves the IP of the host using the local DNS cache if possible.
*/
private static function dnsResolve(string $host, NativeClientState $multi, array &$info, ?\Closure $onProgress): string
{
if (null === $ip = $multi->dnsCache[$host] ?? null) {
$info['debug'] .= "* Hostname was NOT found in DNS cache\n";
$now = microtime(true);
if (!$ip = gethostbynamel($host)) {
throw new TransportException(sprintf('Could not resolve host "%s".', $host));
}
$info['namelookup_time'] = microtime(true) - ($info['start_time'] ?: $now);
$multi->dnsCache[$host] = $ip = $ip[0];
$info['debug'] .= "* Added {$host}:0:{$ip} to DNS cache\n";
} else {
$info['debug'] .= "* Hostname was found in DNS cache\n";
}
$info['primary_ip'] = $ip;
if ($onProgress) {
// Notify DNS resolution
$onProgress();
}
return $ip;
}
/**
* Handles redirects - the native logic is too buggy to be used.
*/
private static function createRedirectResolver(array $options, string $host, string $port, ?array $proxy, array &$info, ?\Closure $onProgress): \Closure
{
$redirectHeaders = [];
if (0 < $maxRedirects = $options['max_redirects']) {
$redirectHeaders = ['host' => $host, 'port' => $port];
$redirectHeaders['with_auth'] = $redirectHeaders['no_auth'] = array_filter($options['headers'], static function ($h) {
return 0 !== stripos($h, 'Host:');
});
if (isset($options['normalized_headers']['authorization']) || isset($options['normalized_headers']['cookie'])) {
$redirectHeaders['no_auth'] = array_filter($redirectHeaders['no_auth'], static function ($h) {
return 0 !== stripos($h, 'Authorization:') && 0 !== stripos($h, 'Cookie:');
});
}
}
return static function (NativeClientState $multi, ?string $location, $context) use (&$redirectHeaders, $proxy, &$info, $maxRedirects, $onProgress): ?string {
if (null === $location || $info['http_code'] < 300 || 400 <= $info['http_code']) {
$info['redirect_url'] = null;
return null;
}
try {
$url = self::parseUrl($location);
} catch (InvalidArgumentException) {
$info['redirect_url'] = null;
return null;
}
$url = self::resolveUrl($url, $info['url']);
$info['redirect_url'] = implode('', $url);
if ($info['redirect_count'] >= $maxRedirects) {
return null;
}
$info['url'] = $url;
++$info['redirect_count'];
$info['redirect_time'] = microtime(true) - $info['start_time'];
// Do like curl and browsers: turn POST to GET on 301, 302 and 303
if (\in_array($info['http_code'], [301, 302, 303], true)) {
$options = stream_context_get_options($context)['http'];
if ('POST' === $options['method'] || 303 === $info['http_code']) {
$info['http_method'] = $options['method'] = 'HEAD' === $options['method'] ? 'HEAD' : 'GET';
$options['content'] = '';
$filterContentHeaders = static function ($h) {
return 0 !== stripos($h, 'Content-Length:') && 0 !== stripos($h, 'Content-Type:') && 0 !== stripos($h, 'Transfer-Encoding:');
};
$options['header'] = array_filter($options['header'], $filterContentHeaders);
$redirectHeaders['no_auth'] = array_filter($redirectHeaders['no_auth'], $filterContentHeaders);
$redirectHeaders['with_auth'] = array_filter($redirectHeaders['with_auth'], $filterContentHeaders);
stream_context_set_option($context, ['http' => $options]);
}
}
[$host, $port] = self::parseHostPort($url, $info);
if (false !== (parse_url($location, \PHP_URL_HOST) ?? false)) {
// Authorization and Cookie headers MUST NOT follow except for the initial host name
$requestHeaders = $redirectHeaders['host'] === $host && $redirectHeaders['port'] === $port ? $redirectHeaders['with_auth'] : $redirectHeaders['no_auth'];
$requestHeaders[] = 'Host: '.$host.$port;
$dnsResolve = !self::configureHeadersAndProxy($context, $host, $requestHeaders, $proxy, 'https:' === $url['scheme']);
} else {
$dnsResolve = isset(stream_context_get_options($context)['ssl']['peer_name']);
}
if ($dnsResolve) {
$ip = self::dnsResolve($host, $multi, $info, $onProgress);
$url['authority'] = substr_replace($url['authority'], $ip, -\strlen($host) - \strlen($port), \strlen($host));
}
return implode('', $url);
};
}
private static function configureHeadersAndProxy($context, string $host, array $requestHeaders, ?array $proxy, bool $isSsl): bool
{
if (null === $proxy) {
stream_context_set_option($context, 'http', 'header', $requestHeaders);
stream_context_set_option($context, 'ssl', 'peer_name', $host);
return false;
}
// Matching "no_proxy" should follow the behavior of curl
foreach ($proxy['no_proxy'] as $rule) {
$dotRule = '.'.ltrim($rule, '.');
if ('*' === $rule || $host === $rule || str_ends_with($host, $dotRule)) {
stream_context_set_option($context, 'http', 'proxy', null);
stream_context_set_option($context, 'http', 'request_fulluri', false);
stream_context_set_option($context, 'http', 'header', $requestHeaders);
stream_context_set_option($context, 'ssl', 'peer_name', $host);
return false;
}
}
if (null !== $proxy['auth']) {
$requestHeaders[] = 'Proxy-Authorization: '.$proxy['auth'];
}
stream_context_set_option($context, 'http', 'proxy', $proxy['url']);
stream_context_set_option($context, 'http', 'request_fulluri', !$isSsl);
stream_context_set_option($context, 'http', 'header', $requestHeaders);
stream_context_set_option($context, 'ssl', 'peer_name', null);
return true;
}
}

View File

@@ -0,0 +1,116 @@
<?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;
use Psr\Log\LoggerAwareInterface;
use Psr\Log\LoggerInterface;
use Symfony\Component\HttpClient\Exception\InvalidArgumentException;
use Symfony\Component\HttpClient\Exception\TransportException;
use Symfony\Component\HttpFoundation\IpUtils;
use Symfony\Contracts\HttpClient\HttpClientInterface;
use Symfony\Contracts\HttpClient\ResponseInterface;
use Symfony\Contracts\HttpClient\ResponseStreamInterface;
use Symfony\Contracts\Service\ResetInterface;
/**
* Decorator that blocks requests to private networks by default.
*
* @author Hallison Boaventura <hallisonboaventura@gmail.com>
*/
final class NoPrivateNetworkHttpClient implements HttpClientInterface, LoggerAwareInterface, ResetInterface
{
use HttpClientTrait;
private const PRIVATE_SUBNETS = [
'127.0.0.0/8',
'10.0.0.0/8',
'192.168.0.0/16',
'172.16.0.0/12',
'169.254.0.0/16',
'0.0.0.0/8',
'240.0.0.0/4',
'::1/128',
'fc00::/7',
'fe80::/10',
'::ffff:0:0/96',
'::/128',
];
private HttpClientInterface $client;
private string|array|null $subnets;
/**
* @param string|array|null $subnets String or array of subnets using CIDR notation that will be used by IpUtils.
* If null is passed, the standard private subnets will be used.
*/
public function __construct(HttpClientInterface $client, string|array $subnets = null)
{
if (!class_exists(IpUtils::class)) {
throw new \LogicException(sprintf('You cannot use "%s" if the HttpFoundation component is not installed. Try running "composer require symfony/http-foundation".', __CLASS__));
}
$this->client = $client;
$this->subnets = $subnets;
}
public function request(string $method, string $url, array $options = []): ResponseInterface
{
$onProgress = $options['on_progress'] ?? null;
if (null !== $onProgress && !\is_callable($onProgress)) {
throw new InvalidArgumentException(sprintf('Option "on_progress" must be callable, "%s" given.', get_debug_type($onProgress)));
}
$subnets = $this->subnets;
$lastPrimaryIp = '';
$options['on_progress'] = function (int $dlNow, int $dlSize, array $info) use ($onProgress, $subnets, &$lastPrimaryIp): void {
if ($info['primary_ip'] !== $lastPrimaryIp) {
if ($info['primary_ip'] && IpUtils::checkIp($info['primary_ip'], $subnets ?? self::PRIVATE_SUBNETS)) {
throw new TransportException(sprintf('IP "%s" is blocked for "%s".', $info['primary_ip'], $info['url']));
}
$lastPrimaryIp = $info['primary_ip'];
}
null !== $onProgress && $onProgress($dlNow, $dlSize, $info);
};
return $this->client->request($method, $url, $options);
}
public function stream(ResponseInterface|iterable $responses, float $timeout = null): ResponseStreamInterface
{
return $this->client->stream($responses, $timeout);
}
public function setLogger(LoggerInterface $logger): void
{
if ($this->client instanceof LoggerAwareInterface) {
$this->client->setLogger($logger);
}
}
public function withOptions(array $options): static
{
$clone = clone $this;
$clone->client = $this->client->withOptions($options);
return $clone;
}
public function reset()
{
if ($this->client instanceof ResetInterface) {
$this->client->reset();
}
}
}

View File

@@ -0,0 +1,238 @@
<?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;
use Http\Discovery\Exception\NotFoundException;
use Http\Discovery\Psr17FactoryDiscovery;
use Nyholm\Psr7\Factory\Psr17Factory;
use Nyholm\Psr7\Request;
use Nyholm\Psr7\Uri;
use Psr\Http\Client\ClientInterface;
use Psr\Http\Client\NetworkExceptionInterface;
use Psr\Http\Client\RequestExceptionInterface;
use Psr\Http\Message\RequestFactoryInterface;
use Psr\Http\Message\RequestInterface;
use Psr\Http\Message\ResponseFactoryInterface;
use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\StreamFactoryInterface;
use Psr\Http\Message\StreamInterface;
use Psr\Http\Message\UriFactoryInterface;
use Psr\Http\Message\UriInterface;
use Symfony\Component\HttpClient\Response\StreamableInterface;
use Symfony\Component\HttpClient\Response\StreamWrapper;
use Symfony\Contracts\HttpClient\Exception\TransportExceptionInterface;
use Symfony\Contracts\HttpClient\HttpClientInterface;
use Symfony\Contracts\Service\ResetInterface;
if (!interface_exists(RequestFactoryInterface::class)) {
throw new \LogicException('You cannot use the "Symfony\Component\HttpClient\Psr18Client" as the "psr/http-factory" package is not installed. Try running "composer require nyholm/psr7".');
}
if (!interface_exists(ClientInterface::class)) {
throw new \LogicException('You cannot use the "Symfony\Component\HttpClient\Psr18Client" as the "psr/http-client" package is not installed. Try running "composer require psr/http-client".');
}
/**
* An adapter to turn a Symfony HttpClientInterface into a PSR-18 ClientInterface.
*
* Run "composer require psr/http-client" to install the base ClientInterface. Run
* "composer require nyholm/psr7" to install an efficient implementation of response
* and stream factories with flex-provided autowiring aliases.
*
* @author Nicolas Grekas <p@tchwork.com>
*/
final class Psr18Client implements ClientInterface, RequestFactoryInterface, StreamFactoryInterface, UriFactoryInterface, ResetInterface
{
private HttpClientInterface $client;
private ResponseFactoryInterface $responseFactory;
private StreamFactoryInterface $streamFactory;
public function __construct(HttpClientInterface $client = null, ResponseFactoryInterface $responseFactory = null, StreamFactoryInterface $streamFactory = null)
{
$this->client = $client ?? HttpClient::create();
$streamFactory ??= $responseFactory instanceof StreamFactoryInterface ? $responseFactory : null;
if (null === $responseFactory || null === $streamFactory) {
if (!class_exists(Psr17Factory::class) && !class_exists(Psr17FactoryDiscovery::class)) {
throw new \LogicException('You cannot use the "Symfony\Component\HttpClient\Psr18Client" as no PSR-17 factories have been provided. Try running "composer require nyholm/psr7".');
}
try {
$psr17Factory = class_exists(Psr17Factory::class, false) ? new Psr17Factory() : null;
$responseFactory ??= $psr17Factory ?? Psr17FactoryDiscovery::findResponseFactory();
$streamFactory ??= $psr17Factory ?? Psr17FactoryDiscovery::findStreamFactory();
} catch (NotFoundException $e) {
throw new \LogicException('You cannot use the "Symfony\Component\HttpClient\HttplugClient" as no PSR-17 factories have been found. Try running "composer require nyholm/psr7".', 0, $e);
}
}
$this->responseFactory = $responseFactory;
$this->streamFactory = $streamFactory;
}
public function withOptions(array $options): static
{
$clone = clone $this;
$clone->client = $clone->client->withOptions($options);
return $clone;
}
public function sendRequest(RequestInterface $request): ResponseInterface
{
try {
$body = $request->getBody();
if ($body->isSeekable()) {
$body->seek(0);
}
$options = [
'headers' => $request->getHeaders(),
'body' => $body->getContents(),
];
if ('1.0' === $request->getProtocolVersion()) {
$options['http_version'] = '1.0';
}
$response = $this->client->request($request->getMethod(), (string) $request->getUri(), $options);
$psrResponse = $this->responseFactory->createResponse($response->getStatusCode());
foreach ($response->getHeaders(false) as $name => $values) {
foreach ($values as $value) {
try {
$psrResponse = $psrResponse->withAddedHeader($name, $value);
} catch (\InvalidArgumentException $e) {
// ignore invalid header
}
}
}
$body = $response instanceof StreamableInterface ? $response->toStream(false) : StreamWrapper::createResource($response, $this->client);
$body = $this->streamFactory->createStreamFromResource($body);
if ($body->isSeekable()) {
$body->seek(0);
}
return $psrResponse->withBody($body);
} catch (TransportExceptionInterface $e) {
if ($e instanceof \InvalidArgumentException) {
throw new Psr18RequestException($e, $request);
}
throw new Psr18NetworkException($e, $request);
}
}
public function createRequest(string $method, $uri): RequestInterface
{
if ($this->responseFactory instanceof RequestFactoryInterface) {
return $this->responseFactory->createRequest($method, $uri);
}
if (class_exists(Request::class)) {
return new Request($method, $uri);
}
if (class_exists(Psr17FactoryDiscovery::class)) {
return Psr17FactoryDiscovery::findRequestFactory()->createRequest($method, $uri);
}
throw new \LogicException(sprintf('You cannot use "%s()" as the "nyholm/psr7" package is not installed. Try running "composer require nyholm/psr7".', __METHOD__));
}
public function createStream(string $content = ''): StreamInterface
{
$stream = $this->streamFactory->createStream($content);
if ($stream->isSeekable()) {
$stream->seek(0);
}
return $stream;
}
public function createStreamFromFile(string $filename, string $mode = 'r'): StreamInterface
{
return $this->streamFactory->createStreamFromFile($filename, $mode);
}
public function createStreamFromResource($resource): StreamInterface
{
return $this->streamFactory->createStreamFromResource($resource);
}
public function createUri(string $uri = ''): UriInterface
{
if ($this->responseFactory instanceof UriFactoryInterface) {
return $this->responseFactory->createUri($uri);
}
if (class_exists(Uri::class)) {
return new Uri($uri);
}
if (class_exists(Psr17FactoryDiscovery::class)) {
return Psr17FactoryDiscovery::findUrlFactory()->createUri($uri);
}
throw new \LogicException(sprintf('You cannot use "%s()" as the "nyholm/psr7" package is not installed. Try running "composer require nyholm/psr7".', __METHOD__));
}
public function reset()
{
if ($this->client instanceof ResetInterface) {
$this->client->reset();
}
}
}
/**
* @internal
*/
class Psr18NetworkException extends \RuntimeException implements NetworkExceptionInterface
{
private RequestInterface $request;
public function __construct(TransportExceptionInterface $e, RequestInterface $request)
{
parent::__construct($e->getMessage(), 0, $e);
$this->request = $request;
}
public function getRequest(): RequestInterface
{
return $this->request;
}
}
/**
* @internal
*/
class Psr18RequestException extends \InvalidArgumentException implements RequestExceptionInterface
{
private RequestInterface $request;
public function __construct(TransportExceptionInterface $e, RequestInterface $request)
{
parent::__construct($e->getMessage(), 0, $e);
$this->request = $request;
}
public function getRequest(): RequestInterface
{
return $this->request;
}
}

View File

@@ -0,0 +1,451 @@
<?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\Response;
use Amp\ByteStream\StreamException;
use Amp\CancellationTokenSource;
use Amp\Coroutine;
use Amp\Deferred;
use Amp\Http\Client\HttpException;
use Amp\Http\Client\Request;
use Amp\Http\Client\Response;
use Amp\Loop;
use Amp\Promise;
use Amp\Success;
use Psr\Log\LoggerInterface;
use Symfony\Component\HttpClient\Chunk\FirstChunk;
use Symfony\Component\HttpClient\Chunk\InformationalChunk;
use Symfony\Component\HttpClient\Exception\InvalidArgumentException;
use Symfony\Component\HttpClient\Exception\TransportException;
use Symfony\Component\HttpClient\HttpClientTrait;
use Symfony\Component\HttpClient\Internal\AmpBody;
use Symfony\Component\HttpClient\Internal\AmpClientState;
use Symfony\Component\HttpClient\Internal\Canary;
use Symfony\Component\HttpClient\Internal\ClientState;
use Symfony\Contracts\HttpClient\ResponseInterface;
/**
* @author Nicolas Grekas <p@tchwork.com>
*
* @internal
*/
final class AmpResponse implements ResponseInterface, StreamableInterface
{
use CommonResponseTrait;
use TransportResponseTrait;
private static string $nextId = 'a';
private AmpClientState $multi;
private ?array $options;
private \Closure $onProgress;
private static ?string $delay = null;
/**
* @internal
*/
public function __construct(AmpClientState $multi, Request $request, array $options, ?LoggerInterface $logger)
{
$this->multi = $multi;
$this->options = &$options;
$this->logger = $logger;
$this->timeout = $options['timeout'];
$this->shouldBuffer = $options['buffer'];
if ($this->inflate = \extension_loaded('zlib') && !$request->hasHeader('accept-encoding')) {
$request->setHeader('Accept-Encoding', 'gzip');
}
$this->initializer = static function (self $response) {
return null !== $response->options;
};
$info = &$this->info;
$headers = &$this->headers;
$canceller = new CancellationTokenSource();
$handle = &$this->handle;
$info['url'] = (string) $request->getUri();
$info['http_method'] = $request->getMethod();
$info['start_time'] = null;
$info['redirect_url'] = null;
$info['original_url'] = $info['url'];
$info['redirect_time'] = 0.0;
$info['redirect_count'] = 0;
$info['size_upload'] = 0.0;
$info['size_download'] = 0.0;
$info['upload_content_length'] = -1.0;
$info['download_content_length'] = -1.0;
$info['user_data'] = $options['user_data'];
$info['max_duration'] = $options['max_duration'];
$info['debug'] = '';
$onProgress = $options['on_progress'] ?? static function () {};
$onProgress = $this->onProgress = static function () use (&$info, $onProgress) {
$info['total_time'] = microtime(true) - $info['start_time'];
$onProgress((int) $info['size_download'], ((int) (1 + $info['download_content_length']) ?: 1) - 1, (array) $info);
};
$pauseDeferred = new Deferred();
$pause = new Success();
$throttleWatcher = null;
$this->id = $id = self::$nextId++;
Loop::defer(static function () use ($request, $multi, &$id, &$info, &$headers, $canceller, &$options, $onProgress, &$handle, $logger, &$pause) {
return new Coroutine(self::generateResponse($request, $multi, $id, $info, $headers, $canceller, $options, $onProgress, $handle, $logger, $pause));
});
$info['pause_handler'] = static function (float $duration) use (&$throttleWatcher, &$pauseDeferred, &$pause) {
if (null !== $throttleWatcher) {
Loop::cancel($throttleWatcher);
}
$pause = $pauseDeferred->promise();
if ($duration <= 0) {
$deferred = $pauseDeferred;
$pauseDeferred = new Deferred();
$deferred->resolve();
} else {
$throttleWatcher = Loop::delay(ceil(1000 * $duration), static function () use (&$pauseDeferred) {
$deferred = $pauseDeferred;
$pauseDeferred = new Deferred();
$deferred->resolve();
});
}
};
$multi->lastTimeout = null;
$multi->openHandles[$id] = $id;
++$multi->responseCount;
$this->canary = new Canary(static function () use ($canceller, $multi, $id) {
$canceller->cancel();
unset($multi->openHandles[$id], $multi->handlesActivity[$id]);
});
}
public function getInfo(string $type = null): mixed
{
return null !== $type ? $this->info[$type] ?? null : $this->info;
}
public function __sleep(): array
{
throw new \BadMethodCallException('Cannot serialize '.__CLASS__);
}
public function __wakeup()
{
throw new \BadMethodCallException('Cannot unserialize '.__CLASS__);
}
public function __destruct()
{
try {
$this->doDestruct();
} finally {
// Clear the DNS cache when all requests completed
if (0 >= --$this->multi->responseCount) {
$this->multi->responseCount = 0;
$this->multi->dnsCache = [];
}
}
}
private static function schedule(self $response, array &$runningResponses): void
{
if (isset($runningResponses[0])) {
$runningResponses[0][1][$response->id] = $response;
} else {
$runningResponses[0] = [$response->multi, [$response->id => $response]];
}
if (!isset($response->multi->openHandles[$response->id])) {
$response->multi->handlesActivity[$response->id][] = null;
$response->multi->handlesActivity[$response->id][] = null !== $response->info['error'] ? new TransportException($response->info['error']) : null;
}
}
/**
* @param AmpClientState $multi
*/
private static function perform(ClientState $multi, array &$responses = null): void
{
if ($responses) {
foreach ($responses as $response) {
try {
if ($response->info['start_time']) {
$response->info['total_time'] = microtime(true) - $response->info['start_time'];
($response->onProgress)();
}
} catch (\Throwable $e) {
$multi->handlesActivity[$response->id][] = null;
$multi->handlesActivity[$response->id][] = $e;
}
}
}
}
/**
* @param AmpClientState $multi
*/
private static function select(ClientState $multi, float $timeout): int
{
$timeout += microtime(true);
self::$delay = Loop::defer(static function () use ($timeout) {
if (0 < $timeout -= microtime(true)) {
self::$delay = Loop::delay(ceil(1000 * $timeout), Loop::stop(...));
} else {
Loop::stop();
}
});
Loop::run();
return null === self::$delay ? 1 : 0;
}
private static function generateResponse(Request $request, AmpClientState $multi, string $id, array &$info, array &$headers, CancellationTokenSource $canceller, array &$options, \Closure $onProgress, &$handle, ?LoggerInterface $logger, Promise &$pause): \Generator
{
$request->setInformationalResponseHandler(static function (Response $response) use ($multi, $id, &$info, &$headers) {
self::addResponseHeaders($response, $info, $headers);
$multi->handlesActivity[$id][] = new InformationalChunk($response->getStatus(), $response->getHeaders());
self::stopLoop();
});
try {
/* @var Response $response */
if (null === $response = yield from self::getPushedResponse($request, $multi, $info, $headers, $options, $logger)) {
$logger?->info(sprintf('Request: "%s %s"', $info['http_method'], $info['url']));
$response = yield from self::followRedirects($request, $multi, $info, $headers, $canceller, $options, $onProgress, $handle, $logger, $pause);
}
$options = null;
$multi->handlesActivity[$id][] = new FirstChunk();
if ('HEAD' === $response->getRequest()->getMethod() || \in_array($info['http_code'], [204, 304], true)) {
$multi->handlesActivity[$id][] = null;
$multi->handlesActivity[$id][] = null;
self::stopLoop();
return;
}
if ($response->hasHeader('content-length')) {
$info['download_content_length'] = (float) $response->getHeader('content-length');
}
$body = $response->getBody();
while (true) {
self::stopLoop();
yield $pause;
if (null === $data = yield $body->read()) {
break;
}
$info['size_download'] += \strlen($data);
$multi->handlesActivity[$id][] = $data;
}
$multi->handlesActivity[$id][] = null;
$multi->handlesActivity[$id][] = null;
} catch (\Throwable $e) {
$multi->handlesActivity[$id][] = null;
$multi->handlesActivity[$id][] = $e;
} finally {
$info['download_content_length'] = $info['size_download'];
}
self::stopLoop();
}
private static function followRedirects(Request $originRequest, AmpClientState $multi, array &$info, array &$headers, CancellationTokenSource $canceller, array $options, \Closure $onProgress, &$handle, ?LoggerInterface $logger, Promise &$pause): \Generator
{
yield $pause;
$originRequest->setBody(new AmpBody($options['body'], $info, $onProgress));
$response = yield $multi->request($options, $originRequest, $canceller->getToken(), $info, $onProgress, $handle);
$previousUrl = null;
while (true) {
self::addResponseHeaders($response, $info, $headers);
$status = $response->getStatus();
if (!\in_array($status, [301, 302, 303, 307, 308], true) || null === $location = $response->getHeader('location')) {
return $response;
}
$urlResolver = new class() {
use HttpClientTrait {
parseUrl as public;
resolveUrl as public;
}
};
try {
$previousUrl ??= $urlResolver::parseUrl($info['url']);
$location = $urlResolver::parseUrl($location);
$location = $urlResolver::resolveUrl($location, $previousUrl);
$info['redirect_url'] = implode('', $location);
} catch (InvalidArgumentException) {
return $response;
}
if (0 >= $options['max_redirects'] || $info['redirect_count'] >= $options['max_redirects']) {
return $response;
}
$logger?->info(sprintf('Redirecting: "%s %s"', $status, $info['url']));
try {
// Discard body of redirects
while (null !== yield $response->getBody()->read()) {
}
} catch (HttpException|StreamException) {
// Ignore streaming errors on previous responses
}
++$info['redirect_count'];
$info['url'] = $info['redirect_url'];
$info['redirect_url'] = null;
$previousUrl = $location;
$request = new Request($info['url'], $info['http_method']);
$request->setProtocolVersions($originRequest->getProtocolVersions());
$request->setTcpConnectTimeout($originRequest->getTcpConnectTimeout());
$request->setTlsHandshakeTimeout($originRequest->getTlsHandshakeTimeout());
$request->setTransferTimeout($originRequest->getTransferTimeout());
if (\in_array($status, [301, 302, 303], true)) {
$originRequest->removeHeader('transfer-encoding');
$originRequest->removeHeader('content-length');
$originRequest->removeHeader('content-type');
// Do like curl and browsers: turn POST to GET on 301, 302 and 303
if ('POST' === $response->getRequest()->getMethod() || 303 === $status) {
$info['http_method'] = 'HEAD' === $response->getRequest()->getMethod() ? 'HEAD' : 'GET';
$request->setMethod($info['http_method']);
}
} else {
$request->setBody(AmpBody::rewind($response->getRequest()->getBody()));
}
foreach ($originRequest->getRawHeaders() as [$name, $value]) {
$request->addHeader($name, $value);
}
if ($request->getUri()->getAuthority() !== $originRequest->getUri()->getAuthority()) {
$request->removeHeader('authorization');
$request->removeHeader('cookie');
$request->removeHeader('host');
}
yield $pause;
$response = yield $multi->request($options, $request, $canceller->getToken(), $info, $onProgress, $handle);
$info['redirect_time'] = microtime(true) - $info['start_time'];
}
}
private static function addResponseHeaders(Response $response, array &$info, array &$headers): void
{
$info['http_code'] = $response->getStatus();
if ($headers) {
$info['debug'] .= "< \r\n";
$headers = [];
}
$h = sprintf('HTTP/%s %s %s', $response->getProtocolVersion(), $response->getStatus(), $response->getReason());
$info['debug'] .= "< {$h}\r\n";
$info['response_headers'][] = $h;
foreach ($response->getRawHeaders() as [$name, $value]) {
$headers[strtolower($name)][] = $value;
$h = $name.': '.$value;
$info['debug'] .= "< {$h}\r\n";
$info['response_headers'][] = $h;
}
$info['debug'] .= "< \r\n";
}
/**
* Accepts pushed responses only if their headers related to authentication match the request.
*/
private static function getPushedResponse(Request $request, AmpClientState $multi, array &$info, array &$headers, array $options, ?LoggerInterface $logger): \Generator
{
if ('' !== $options['body']) {
return null;
}
$authority = $request->getUri()->getAuthority();
foreach ($multi->pushedResponses[$authority] ?? [] as $i => [$pushedUrl, $pushDeferred, $pushedRequest, $pushedResponse, $parentOptions]) {
if ($info['url'] !== $pushedUrl || $info['http_method'] !== $pushedRequest->getMethod()) {
continue;
}
foreach ($parentOptions as $k => $v) {
if ($options[$k] !== $v) {
continue 2;
}
}
foreach (['authorization', 'cookie', 'range', 'proxy-authorization'] as $k) {
if ($pushedRequest->getHeaderArray($k) !== $request->getHeaderArray($k)) {
continue 2;
}
}
$response = yield $pushedResponse;
foreach ($response->getHeaderArray('vary') as $vary) {
foreach (preg_split('/\s*+,\s*+/', $vary) as $v) {
if ('*' === $v || ($pushedRequest->getHeaderArray($v) !== $request->getHeaderArray($v) && 'accept-encoding' !== strtolower($v))) {
$logger?->debug(sprintf('Skipping pushed response: "%s"', $info['url']));
continue 3;
}
}
}
$pushDeferred->resolve();
$logger?->debug(sprintf('Accepting pushed response: "%s %s"', $info['http_method'], $info['url']));
self::addResponseHeaders($response, $info, $headers);
unset($multi->pushedResponses[$authority][$i]);
if (!$multi->pushedResponses[$authority]) {
unset($multi->pushedResponses[$authority]);
}
return $response;
}
}
private static function stopLoop(): void
{
if (null !== self::$delay) {
Loop::cancel(self::$delay);
self::$delay = null;
}
Loop::defer(Loop::stop(...));
}
}

View File

@@ -0,0 +1,198 @@
<?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\Response;
use Symfony\Component\HttpClient\Chunk\DataChunk;
use Symfony\Component\HttpClient\Chunk\LastChunk;
use Symfony\Component\HttpClient\Exception\TransportException;
use Symfony\Contracts\HttpClient\ChunkInterface;
use Symfony\Contracts\HttpClient\HttpClientInterface;
use Symfony\Contracts\HttpClient\ResponseInterface;
/**
* A DTO to work with AsyncResponse.
*
* @author Nicolas Grekas <p@tchwork.com>
*/
final class AsyncContext
{
private $passthru;
private HttpClientInterface $client;
private ResponseInterface $response;
private array $info = [];
private $content;
private int $offset;
/**
* @param resource|null $content
*/
public function __construct(?callable &$passthru, HttpClientInterface $client, ResponseInterface &$response, array &$info, $content, int $offset)
{
$this->passthru = &$passthru;
$this->client = $client;
$this->response = &$response;
$this->info = &$info;
$this->content = $content;
$this->offset = $offset;
}
/**
* Returns the HTTP status without consuming the response.
*/
public function getStatusCode(): int
{
return $this->response->getInfo('http_code');
}
/**
* Returns the headers without consuming the response.
*/
public function getHeaders(): array
{
$headers = [];
foreach ($this->response->getInfo('response_headers') as $h) {
if (11 <= \strlen($h) && '/' === $h[4] && preg_match('#^HTTP/\d+(?:\.\d+)? ([123456789]\d\d)(?: |$)#', $h, $m)) {
$headers = [];
} elseif (2 === \count($m = explode(':', $h, 2))) {
$headers[strtolower($m[0])][] = ltrim($m[1]);
}
}
return $headers;
}
/**
* @return resource|null The PHP stream resource where the content is buffered, if it is
*/
public function getContent()
{
return $this->content;
}
/**
* Creates a new chunk of content.
*/
public function createChunk(string $data): ChunkInterface
{
return new DataChunk($this->offset, $data);
}
/**
* Pauses the request for the given number of seconds.
*/
public function pause(float $duration): void
{
if (\is_callable($pause = $this->response->getInfo('pause_handler'))) {
$pause($duration);
} elseif (0 < $duration) {
usleep(1E6 * $duration);
}
}
/**
* Cancels the request and returns the last chunk to yield.
*/
public function cancel(): ChunkInterface
{
$this->info['canceled'] = true;
$this->info['error'] = 'Response has been canceled.';
$this->response->cancel();
return new LastChunk();
}
/**
* Returns the current info of the response.
*/
public function getInfo(string $type = null): mixed
{
if (null !== $type) {
return $this->info[$type] ?? $this->response->getInfo($type);
}
return $this->info + $this->response->getInfo();
}
/**
* Attaches an info to the response.
*
* @return $this
*/
public function setInfo(string $type, mixed $value): static
{
if ('canceled' === $type && $value !== $this->info['canceled']) {
throw new \LogicException('You cannot set the "canceled" info directly.');
}
if (null === $value) {
unset($this->info[$type]);
} else {
$this->info[$type] = $value;
}
return $this;
}
/**
* Returns the currently processed response.
*/
public function getResponse(): ResponseInterface
{
return $this->response;
}
/**
* Replaces the currently processed response by doing a new request.
*/
public function replaceRequest(string $method, string $url, array $options = []): ResponseInterface
{
$this->info['previous_info'][] = $info = $this->response->getInfo();
if (null !== $onProgress = $options['on_progress'] ?? null) {
$thisInfo = &$this->info;
$options['on_progress'] = static function (int $dlNow, int $dlSize, array $info) use (&$thisInfo, $onProgress) {
$onProgress($dlNow, $dlSize, $thisInfo + $info);
};
}
if (0 < ($info['max_duration'] ?? 0) && 0 < ($info['total_time'] ?? 0)) {
if (0 >= $options['max_duration'] = $info['max_duration'] - $info['total_time']) {
throw new TransportException(sprintf('Max duration was reached for "%s".', $info['url']));
}
}
return $this->response = $this->client->request($method, $url, ['buffer' => false] + $options);
}
/**
* Replaces the currently processed response by another one.
*/
public function replaceResponse(ResponseInterface $response): ResponseInterface
{
$this->info['previous_info'][] = $this->response->getInfo();
return $this->response = $response;
}
/**
* Replaces or removes the chunk filter iterator.
*
* @param ?callable(ChunkInterface, self): ?\Iterator $passthru
*/
public function passthru(callable $passthru = null): void
{
$this->passthru = $passthru ?? static function ($chunk, $context) {
$context->passthru = null;
yield $chunk;
};
}
}

View File

@@ -0,0 +1,472 @@
<?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\Response;
use Symfony\Component\HttpClient\Chunk\ErrorChunk;
use Symfony\Component\HttpClient\Chunk\FirstChunk;
use Symfony\Component\HttpClient\Chunk\LastChunk;
use Symfony\Component\HttpClient\Exception\TransportException;
use Symfony\Contracts\HttpClient\ChunkInterface;
use Symfony\Contracts\HttpClient\Exception\ExceptionInterface;
use Symfony\Contracts\HttpClient\Exception\HttpExceptionInterface;
use Symfony\Contracts\HttpClient\Exception\TransportExceptionInterface;
use Symfony\Contracts\HttpClient\HttpClientInterface;
use Symfony\Contracts\HttpClient\ResponseInterface;
/**
* Provides a single extension point to process a response's content stream.
*
* @author Nicolas Grekas <p@tchwork.com>
*/
final class AsyncResponse implements ResponseInterface, StreamableInterface
{
use CommonResponseTrait;
private const FIRST_CHUNK_YIELDED = 1;
private const LAST_CHUNK_YIELDED = 2;
private ?HttpClientInterface $client;
private ResponseInterface $response;
private array $info = ['canceled' => false];
private $passthru;
private $stream;
private $yieldedState;
/**
* @param ?callable(ChunkInterface, AsyncContext): ?\Iterator $passthru
*/
public function __construct(HttpClientInterface $client, string $method, string $url, array $options, callable $passthru = null)
{
$this->client = $client;
$this->shouldBuffer = $options['buffer'] ?? true;
if (null !== $onProgress = $options['on_progress'] ?? null) {
$thisInfo = &$this->info;
$options['on_progress'] = static function (int $dlNow, int $dlSize, array $info) use (&$thisInfo, $onProgress) {
$onProgress($dlNow, $dlSize, $thisInfo + $info);
};
}
$this->response = $client->request($method, $url, ['buffer' => false] + $options);
$this->passthru = $passthru;
$this->initializer = static function (self $response, float $timeout = null) {
if (null === $response->shouldBuffer) {
return false;
}
while (true) {
foreach (self::stream([$response], $timeout) as $chunk) {
if ($chunk->isTimeout() && $response->passthru) {
foreach (self::passthru($response->client, $response, new ErrorChunk($response->offset, new TransportException($chunk->getError()))) as $chunk) {
if ($chunk->isFirst()) {
return false;
}
}
continue 2;
}
if ($chunk->isFirst()) {
return false;
}
}
return false;
}
};
if (\array_key_exists('user_data', $options)) {
$this->info['user_data'] = $options['user_data'];
}
if (\array_key_exists('max_duration', $options)) {
$this->info['max_duration'] = $options['max_duration'];
}
}
public function getStatusCode(): int
{
if ($this->initializer) {
self::initialize($this);
}
return $this->response->getStatusCode();
}
public function getHeaders(bool $throw = true): array
{
if ($this->initializer) {
self::initialize($this);
}
$headers = $this->response->getHeaders(false);
if ($throw) {
$this->checkStatusCode();
}
return $headers;
}
public function getInfo(string $type = null): mixed
{
if (null !== $type) {
return $this->info[$type] ?? $this->response->getInfo($type);
}
return $this->info + $this->response->getInfo();
}
public function toStream(bool $throw = true)
{
if ($throw) {
// Ensure headers arrived
$this->getHeaders(true);
}
$handle = function () {
$stream = $this->response instanceof StreamableInterface ? $this->response->toStream(false) : StreamWrapper::createResource($this->response);
return stream_get_meta_data($stream)['wrapper_data']->stream_cast(\STREAM_CAST_FOR_SELECT);
};
$stream = StreamWrapper::createResource($this);
stream_get_meta_data($stream)['wrapper_data']
->bindHandles($handle, $this->content);
return $stream;
}
public function cancel(): void
{
if ($this->info['canceled']) {
return;
}
$this->info['canceled'] = true;
$this->info['error'] = 'Response has been canceled.';
$this->close();
$client = $this->client;
$this->client = null;
if (!$this->passthru) {
return;
}
try {
foreach (self::passthru($client, $this, new LastChunk()) as $chunk) {
// no-op
}
$this->passthru = null;
} catch (ExceptionInterface) {
// ignore any errors when canceling
}
}
public function __destruct()
{
$httpException = null;
if ($this->initializer && null === $this->getInfo('error')) {
try {
self::initialize($this, -0.0);
$this->getHeaders(true);
} catch (HttpExceptionInterface $httpException) {
// no-op
}
}
if ($this->passthru && null === $this->getInfo('error')) {
$this->info['canceled'] = true;
try {
foreach (self::passthru($this->client, $this, new LastChunk()) as $chunk) {
// no-op
}
} catch (ExceptionInterface) {
// ignore any errors when destructing
}
}
if (null !== $httpException) {
throw $httpException;
}
}
/**
* @internal
*/
public static function stream(iterable $responses, float $timeout = null, string $class = null): \Generator
{
while ($responses) {
$wrappedResponses = [];
$asyncMap = new \SplObjectStorage();
$client = null;
foreach ($responses as $r) {
if (!$r instanceof self) {
throw new \TypeError(sprintf('"%s::stream()" expects parameter 1 to be an iterable of AsyncResponse objects, "%s" given.', $class ?? static::class, get_debug_type($r)));
}
if (null !== $e = $r->info['error'] ?? null) {
yield $r => $chunk = new ErrorChunk($r->offset, new TransportException($e));
$chunk->didThrow() ?: $chunk->getContent();
continue;
}
if (null === $client) {
$client = $r->client;
} elseif ($r->client !== $client) {
throw new TransportException('Cannot stream AsyncResponse objects with many clients.');
}
$asyncMap[$r->response] = $r;
$wrappedResponses[] = $r->response;
if ($r->stream) {
yield from self::passthruStream($response = $r->response, $r, new FirstChunk(), $asyncMap);
if (!isset($asyncMap[$response])) {
array_pop($wrappedResponses);
}
if ($r->response !== $response && !isset($asyncMap[$r->response])) {
$asyncMap[$r->response] = $r;
$wrappedResponses[] = $r->response;
}
}
}
if (!$client || !$wrappedResponses) {
return;
}
foreach ($client->stream($wrappedResponses, $timeout) as $response => $chunk) {
$r = $asyncMap[$response];
if (null === $chunk->getError()) {
if ($chunk->isFirst()) {
// Ensure no exception is thrown on destruct for the wrapped response
$r->response->getStatusCode();
} elseif (0 === $r->offset && null === $r->content && $chunk->isLast()) {
$r->content = fopen('php://memory', 'w+');
}
}
if (!$r->passthru) {
if (null !== $chunk->getError() || $chunk->isLast()) {
unset($asyncMap[$response]);
} elseif (null !== $r->content && '' !== ($content = $chunk->getContent()) && \strlen($content) !== fwrite($r->content, $content)) {
$chunk = new ErrorChunk($r->offset, new TransportException(sprintf('Failed writing %d bytes to the response buffer.', \strlen($content))));
$r->info['error'] = $chunk->getError();
$r->response->cancel();
}
yield $r => $chunk;
continue;
}
if (null !== $chunk->getError()) {
// no-op
} elseif ($chunk->isFirst()) {
$r->yieldedState = self::FIRST_CHUNK_YIELDED;
} elseif (self::FIRST_CHUNK_YIELDED !== $r->yieldedState && null === $chunk->getInformationalStatus()) {
throw new \LogicException(sprintf('Instance of "%s" is already consumed and cannot be managed by "%s". A decorated client should not call any of the response\'s methods in its "request()" method.', get_debug_type($response), $class ?? static::class));
}
foreach (self::passthru($r->client, $r, $chunk, $asyncMap) as $chunk) {
yield $r => $chunk;
}
if ($r->response !== $response && isset($asyncMap[$response])) {
break;
}
}
if (null === $chunk->getError() && $chunk->isLast()) {
$r->yieldedState = self::LAST_CHUNK_YIELDED;
}
if (null === $chunk->getError() && self::LAST_CHUNK_YIELDED !== $r->yieldedState && $r->response === $response && null !== $r->client) {
throw new \LogicException('A chunk passthru must yield an "isLast()" chunk before ending a stream.');
}
$responses = [];
foreach ($asyncMap as $response) {
$r = $asyncMap[$response];
if (null !== $r->client) {
$responses[] = $asyncMap[$response];
}
}
}
}
/**
* @param \SplObjectStorage<ResponseInterface, AsyncResponse>|null $asyncMap
*/
private static function passthru(HttpClientInterface $client, self $r, ChunkInterface $chunk, \SplObjectStorage $asyncMap = null): \Generator
{
$r->stream = null;
$response = $r->response;
$context = new AsyncContext($r->passthru, $client, $r->response, $r->info, $r->content, $r->offset);
if (null === $stream = ($r->passthru)($chunk, $context)) {
if ($r->response === $response && (null !== $chunk->getError() || $chunk->isLast())) {
throw new \LogicException('A chunk passthru cannot swallow the last chunk.');
}
return;
}
if (!$stream instanceof \Iterator) {
throw new \LogicException(sprintf('A chunk passthru must return an "Iterator", "%s" returned.', get_debug_type($stream)));
}
$r->stream = $stream;
yield from self::passthruStream($response, $r, null, $asyncMap);
}
/**
* @param \SplObjectStorage<ResponseInterface, AsyncResponse>|null $asyncMap
*/
private static function passthruStream(ResponseInterface $response, self $r, ?ChunkInterface $chunk, ?\SplObjectStorage $asyncMap): \Generator
{
while (true) {
try {
if (null !== $chunk && $r->stream) {
$r->stream->next();
}
if (!$r->stream || !$r->stream->valid() || !$r->stream) {
$r->stream = null;
break;
}
} catch (\Throwable $e) {
unset($asyncMap[$response]);
$r->stream = null;
$r->info['error'] = $e->getMessage();
$r->response->cancel();
yield $r => $chunk = new ErrorChunk($r->offset, $e);
$chunk->didThrow() ?: $chunk->getContent();
break;
}
$chunk = $r->stream->current();
if (!$chunk instanceof ChunkInterface) {
throw new \LogicException(sprintf('A chunk passthru must yield instances of "%s", "%s" yielded.', ChunkInterface::class, get_debug_type($chunk)));
}
if (null !== $chunk->getError()) {
// no-op
} elseif ($chunk->isFirst()) {
$e = $r->openBuffer();
yield $r => $chunk;
if ($r->initializer && null === $r->getInfo('error')) {
// Ensure the HTTP status code is always checked
$r->getHeaders(true);
}
if (null === $e) {
continue;
}
$r->response->cancel();
$chunk = new ErrorChunk($r->offset, $e);
} elseif ('' !== $content = $chunk->getContent()) {
if (null !== $r->shouldBuffer) {
throw new \LogicException('A chunk passthru must yield an "isFirst()" chunk before any content chunk.');
}
if (null !== $r->content && \strlen($content) !== fwrite($r->content, $content)) {
$chunk = new ErrorChunk($r->offset, new TransportException(sprintf('Failed writing %d bytes to the response buffer.', \strlen($content))));
$r->info['error'] = $chunk->getError();
$r->response->cancel();
}
}
if (null !== $chunk->getError() || $chunk->isLast()) {
$stream = $r->stream;
$r->stream = null;
unset($asyncMap[$response]);
}
if (null === $chunk->getError()) {
$r->offset += \strlen($content);
yield $r => $chunk;
if (!$chunk->isLast()) {
continue;
}
$stream->next();
if ($stream->valid()) {
throw new \LogicException('A chunk passthru cannot yield after an "isLast()" chunk.');
}
$r->passthru = null;
} else {
if ($chunk instanceof ErrorChunk) {
$chunk->didThrow(false);
} else {
try {
$chunk = new ErrorChunk($chunk->getOffset(), !$chunk->isTimeout() ?: $chunk->getError());
} catch (TransportExceptionInterface $e) {
$chunk = new ErrorChunk($chunk->getOffset(), $e);
}
}
yield $r => $chunk;
$chunk->didThrow() ?: $chunk->getContent();
}
break;
}
}
private function openBuffer(): ?\Throwable
{
if (null === $shouldBuffer = $this->shouldBuffer) {
throw new \LogicException('A chunk passthru cannot yield more than one "isFirst()" chunk.');
}
$e = $this->shouldBuffer = null;
if ($shouldBuffer instanceof \Closure) {
try {
$shouldBuffer = $shouldBuffer($this->getHeaders(false));
if (null !== $e = $this->response->getInfo('error')) {
throw new TransportException($e);
}
} catch (\Throwable $e) {
$this->info['error'] = $e->getMessage();
$this->response->cancel();
}
}
if (true === $shouldBuffer) {
$this->content = fopen('php://temp', 'w+');
} elseif (\is_resource($shouldBuffer)) {
$this->content = $shouldBuffer;
}
return $e;
}
private function close(): void
{
$this->response->cancel();
}
}

View File

@@ -0,0 +1,172 @@
<?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\Response;
use Symfony\Component\HttpClient\Exception\ClientException;
use Symfony\Component\HttpClient\Exception\JsonException;
use Symfony\Component\HttpClient\Exception\RedirectionException;
use Symfony\Component\HttpClient\Exception\ServerException;
use Symfony\Component\HttpClient\Exception\TransportException;
/**
* Implements common logic for response classes.
*
* @author Nicolas Grekas <p@tchwork.com>
*
* @internal
*/
trait CommonResponseTrait
{
/**
* @var callable|null A callback that tells whether we're waiting for response headers
*/
private $initializer;
private $shouldBuffer;
private $content;
private int $offset = 0;
private ?array $jsonData = null;
public function getContent(bool $throw = true): string
{
if ($this->initializer) {
self::initialize($this);
}
if ($throw) {
$this->checkStatusCode();
}
if (null === $this->content) {
$content = null;
foreach (self::stream([$this]) as $chunk) {
if (!$chunk->isLast()) {
$content .= $chunk->getContent();
}
}
if (null !== $content) {
return $content;
}
if (null === $this->content) {
throw new TransportException('Cannot get the content of the response twice: buffering is disabled.');
}
} else {
foreach (self::stream([$this]) as $chunk) {
// Chunks are buffered in $this->content already
}
}
rewind($this->content);
return stream_get_contents($this->content);
}
public function toArray(bool $throw = true): array
{
if ('' === $content = $this->getContent($throw)) {
throw new JsonException('Response body is empty.');
}
if (null !== $this->jsonData) {
return $this->jsonData;
}
try {
$content = json_decode($content, true, 512, \JSON_BIGINT_AS_STRING | \JSON_THROW_ON_ERROR);
} catch (\JsonException $e) {
throw new JsonException($e->getMessage().sprintf(' for "%s".', $this->getInfo('url')), $e->getCode());
}
if (!\is_array($content)) {
throw new JsonException(sprintf('JSON content was expected to decode to an array, "%s" returned for "%s".', get_debug_type($content), $this->getInfo('url')));
}
if (null !== $this->content) {
// Option "buffer" is true
return $this->jsonData = $content;
}
return $content;
}
public function toStream(bool $throw = true)
{
if ($throw) {
// Ensure headers arrived
$this->getHeaders($throw);
}
$stream = StreamWrapper::createResource($this);
stream_get_meta_data($stream)['wrapper_data']
->bindHandles($this->handle, $this->content);
return $stream;
}
public function __sleep(): array
{
throw new \BadMethodCallException('Cannot serialize '.__CLASS__);
}
public function __wakeup()
{
throw new \BadMethodCallException('Cannot unserialize '.__CLASS__);
}
/**
* Closes the response and all its network handles.
*/
abstract protected function close(): void;
private static function initialize(self $response): void
{
if (null !== $response->getInfo('error')) {
throw new TransportException($response->getInfo('error'));
}
try {
if (($response->initializer)($response, -0.0)) {
foreach (self::stream([$response], -0.0) as $chunk) {
if ($chunk->isFirst()) {
break;
}
}
}
} catch (\Throwable $e) {
// Persist timeouts thrown during initialization
$response->info['error'] = $e->getMessage();
$response->close();
throw $e;
}
$response->initializer = null;
}
private function checkStatusCode()
{
$code = $this->getInfo('http_code');
if (500 <= $code) {
throw new ServerException($this);
}
if (400 <= $code) {
throw new ClientException($this);
}
if (300 <= $code) {
throw new RedirectionException($this);
}
}
}

View File

@@ -0,0 +1,458 @@
<?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\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\Canary;
use Symfony\Component\HttpClient\Internal\ClientState;
use Symfony\Component\HttpClient\Internal\CurlClientState;
use Symfony\Contracts\HttpClient\ResponseInterface;
/**
* @author Nicolas Grekas <p@tchwork.com>
*
* @internal
*/
final class CurlResponse implements ResponseInterface, StreamableInterface
{
use CommonResponseTrait {
getContent as private doGetContent;
}
use TransportResponseTrait;
private CurlClientState $multi;
/**
* @var resource
*/
private $debugBuffer;
/**
* @internal
*/
public function __construct(CurlClientState $multi, \CurlHandle|string $ch, array $options = null, LoggerInterface $logger = null, string $method = 'GET', callable $resolveRedirect = null, int $curlVersion = null, string $originalUrl = null)
{
$this->multi = $multi;
if ($ch instanceof \CurlHandle) {
$this->handle = $ch;
$this->debugBuffer = fopen('php://temp', 'w+');
if (0x074000 === $curlVersion) {
fwrite($this->debugBuffer, 'Due to a bug in curl 7.64.0, the debug log is disabled; use another version to work around the issue.');
} else {
curl_setopt($ch, \CURLOPT_VERBOSE, true);
curl_setopt($ch, \CURLOPT_STDERR, $this->debugBuffer);
}
} else {
$this->info['url'] = $ch;
$ch = $this->handle;
}
$this->id = $id = (int) $ch;
$this->logger = $logger;
$this->shouldBuffer = $options['buffer'] ?? true;
$this->timeout = $options['timeout'] ?? null;
$this->info['http_method'] = $method;
$this->info['user_data'] = $options['user_data'] ?? null;
$this->info['max_duration'] = $options['max_duration'] ?? null;
$this->info['start_time'] ??= microtime(true);
$this->info['original_url'] = $originalUrl ?? $this->info['url'] ?? curl_getinfo($ch, \CURLINFO_EFFECTIVE_URL);
$info = &$this->info;
$headers = &$this->headers;
$debugBuffer = $this->debugBuffer;
if (!$info['response_headers']) {
// Used to keep track of what we're waiting for
curl_setopt($ch, \CURLOPT_PRIVATE, \in_array($method, ['GET', 'HEAD', 'OPTIONS', 'TRACE'], true) && 1.0 < (float) ($options['http_version'] ?? 1.1) ? 'H2' : 'H0'); // H = headers + retry counter
}
curl_setopt($ch, \CURLOPT_HEADERFUNCTION, static function ($ch, string $data) use (&$info, &$headers, $options, $multi, $id, &$location, $resolveRedirect, $logger): int {
return self::parseHeaderLine($ch, $data, $info, $headers, $options, $multi, $id, $location, $resolveRedirect, $logger);
});
if (null === $options) {
// Pushed response: buffer until requested
curl_setopt($ch, \CURLOPT_WRITEFUNCTION, static function ($ch, string $data) use ($multi, $id): int {
$multi->handlesActivity[$id][] = $data;
curl_pause($ch, \CURLPAUSE_RECV);
return \strlen($data);
});
return;
}
$execCounter = $multi->execCounter;
$this->info['pause_handler'] = static function (float $duration) use ($ch, $multi, $execCounter) {
if (0 < $duration) {
if ($execCounter === $multi->execCounter) {
$multi->execCounter = !\is_float($execCounter) ? 1 + $execCounter : \PHP_INT_MIN;
curl_multi_remove_handle($multi->handle, $ch);
}
$lastExpiry = end($multi->pauseExpiries);
$multi->pauseExpiries[(int) $ch] = $duration += microtime(true);
if (false !== $lastExpiry && $lastExpiry > $duration) {
asort($multi->pauseExpiries);
}
curl_pause($ch, \CURLPAUSE_ALL);
} else {
unset($multi->pauseExpiries[(int) $ch]);
curl_pause($ch, \CURLPAUSE_CONT);
curl_multi_add_handle($multi->handle, $ch);
}
};
$this->inflate = !isset($options['normalized_headers']['accept-encoding']);
curl_pause($ch, \CURLPAUSE_CONT);
if ($onProgress = $options['on_progress']) {
$url = isset($info['url']) ? ['url' => $info['url']] : [];
curl_setopt($ch, \CURLOPT_NOPROGRESS, false);
curl_setopt($ch, \CURLOPT_PROGRESSFUNCTION, static function ($ch, $dlSize, $dlNow) use ($onProgress, &$info, $url, $multi, $debugBuffer) {
try {
rewind($debugBuffer);
$debug = ['debug' => stream_get_contents($debugBuffer)];
$onProgress($dlNow, $dlSize, $url + curl_getinfo($ch) + $info + $debug);
} catch (\Throwable $e) {
$multi->handlesActivity[(int) $ch][] = null;
$multi->handlesActivity[(int) $ch][] = $e;
return 1; // Abort the request
}
return null;
});
}
curl_setopt($ch, \CURLOPT_WRITEFUNCTION, static function ($ch, string $data) use ($multi, $id): int {
if ('H' === (curl_getinfo($ch, \CURLINFO_PRIVATE)[0] ?? null)) {
$multi->handlesActivity[$id][] = null;
$multi->handlesActivity[$id][] = new TransportException(sprintf('Unsupported protocol for "%s"', curl_getinfo($ch, \CURLINFO_EFFECTIVE_URL)));
return 0;
}
curl_setopt($ch, \CURLOPT_WRITEFUNCTION, static function ($ch, string $data) use ($multi, $id): int {
$multi->handlesActivity[$id][] = $data;
return \strlen($data);
});
$multi->handlesActivity[$id][] = $data;
return \strlen($data);
});
$this->initializer = static function (self $response) {
$waitFor = curl_getinfo($ch = $response->handle, \CURLINFO_PRIVATE);
return 'H' === $waitFor[0];
};
// Schedule the request in a non-blocking way
$multi->lastTimeout = null;
$multi->openHandles[$id] = [$ch, $options];
curl_multi_add_handle($multi->handle, $ch);
$this->canary = new Canary(static function () use ($ch, $multi, $id) {
unset($multi->pauseExpiries[$id], $multi->openHandles[$id], $multi->handlesActivity[$id]);
curl_setopt($ch, \CURLOPT_PRIVATE, '_0');
if ($multi->performing) {
return;
}
curl_multi_remove_handle($multi->handle, $ch);
curl_setopt_array($ch, [
\CURLOPT_NOPROGRESS => true,
\CURLOPT_PROGRESSFUNCTION => null,
\CURLOPT_HEADERFUNCTION => null,
\CURLOPT_WRITEFUNCTION => null,
\CURLOPT_READFUNCTION => null,
\CURLOPT_INFILE => null,
]);
if (!$multi->openHandles) {
// Schedule DNS cache eviction for the next request
$multi->dnsCache->evictions = $multi->dnsCache->evictions ?: $multi->dnsCache->removals;
$multi->dnsCache->removals = $multi->dnsCache->hostnames = [];
}
});
}
public function getInfo(string $type = null): mixed
{
if (!$info = $this->finalInfo) {
$info = array_merge($this->info, curl_getinfo($this->handle));
$info['url'] = $this->info['url'] ?? $info['url'];
$info['redirect_url'] = $this->info['redirect_url'] ?? null;
// workaround curl not subtracting the time offset for pushed responses
if (isset($this->info['url']) && $info['start_time'] / 1000 < $info['total_time']) {
$info['total_time'] -= $info['starttransfer_time'] ?: $info['total_time'];
$info['starttransfer_time'] = 0.0;
}
rewind($this->debugBuffer);
$info['debug'] = stream_get_contents($this->debugBuffer);
$waitFor = curl_getinfo($this->handle, \CURLINFO_PRIVATE);
if ('H' !== $waitFor[0] && 'C' !== $waitFor[0]) {
curl_setopt($this->handle, \CURLOPT_VERBOSE, false);
rewind($this->debugBuffer);
ftruncate($this->debugBuffer, 0);
$this->finalInfo = $info;
}
}
return null !== $type ? $info[$type] ?? null : $info;
}
public function getContent(bool $throw = true): string
{
$performing = $this->multi->performing;
$this->multi->performing = $performing || '_0' === curl_getinfo($this->handle, \CURLINFO_PRIVATE);
try {
return $this->doGetContent($throw);
} finally {
$this->multi->performing = $performing;
}
}
public function __destruct()
{
try {
if (null === $this->timeout) {
return; // Unused pushed response
}
$this->doDestruct();
} finally {
if (\is_resource($this->handle) || $this->handle instanceof \CurlHandle) {
curl_setopt($this->handle, \CURLOPT_VERBOSE, false);
}
}
}
private static function schedule(self $response, array &$runningResponses): void
{
if (isset($runningResponses[$i = (int) $response->multi->handle])) {
$runningResponses[$i][1][$response->id] = $response;
} else {
$runningResponses[$i] = [$response->multi, [$response->id => $response]];
}
if ('_0' === curl_getinfo($ch = $response->handle, \CURLINFO_PRIVATE)) {
// Response already completed
$response->multi->handlesActivity[$response->id][] = null;
$response->multi->handlesActivity[$response->id][] = null !== $response->info['error'] ? new TransportException($response->info['error']) : null;
}
}
/**
* @param CurlClientState $multi
*/
private static function perform(ClientState $multi, array &$responses = null): void
{
if ($multi->performing) {
if ($responses) {
$response = current($responses);
$multi->handlesActivity[(int) $response->handle][] = null;
$multi->handlesActivity[(int) $response->handle][] = new TransportException(sprintf('Userland callback cannot use the client nor the response while processing "%s".', curl_getinfo($response->handle, \CURLINFO_EFFECTIVE_URL)));
}
return;
}
try {
$multi->performing = true;
++$multi->execCounter;
$active = 0;
while (\CURLM_CALL_MULTI_PERFORM === ($err = curl_multi_exec($multi->handle, $active))) {
}
if (\CURLM_OK !== $err) {
throw new TransportException(curl_multi_strerror($err));
}
while ($info = curl_multi_info_read($multi->handle)) {
if (\CURLMSG_DONE !== $info['msg']) {
continue;
}
$result = $info['result'];
$id = (int) $ch = $info['handle'];
$waitFor = @curl_getinfo($ch, \CURLINFO_PRIVATE) ?: '_0';
if (\in_array($result, [\CURLE_SEND_ERROR, \CURLE_RECV_ERROR, /* CURLE_HTTP2 */ 16, /* CURLE_HTTP2_STREAM */ 92], true) && $waitFor[1] && 'C' !== $waitFor[0]) {
curl_multi_remove_handle($multi->handle, $ch);
$waitFor[1] = (string) ((int) $waitFor[1] - 1); // decrement the retry counter
curl_setopt($ch, \CURLOPT_PRIVATE, $waitFor);
curl_setopt($ch, \CURLOPT_FORBID_REUSE, true);
if (0 === curl_multi_add_handle($multi->handle, $ch)) {
continue;
}
}
if (\CURLE_RECV_ERROR === $result && 'H' === $waitFor[0] && 400 <= ($responses[(int) $ch]->info['http_code'] ?? 0)) {
$multi->handlesActivity[$id][] = new FirstChunk();
}
$multi->handlesActivity[$id][] = null;
$multi->handlesActivity[$id][] = \in_array($result, [\CURLE_OK, \CURLE_TOO_MANY_REDIRECTS], true) || '_0' === $waitFor || curl_getinfo($ch, \CURLINFO_SIZE_DOWNLOAD) === curl_getinfo($ch, \CURLINFO_CONTENT_LENGTH_DOWNLOAD) ? null : new TransportException(ucfirst(curl_error($ch) ?: curl_strerror($result)).sprintf(' for "%s".', curl_getinfo($ch, \CURLINFO_EFFECTIVE_URL)));
}
} finally {
$multi->performing = false;
}
}
/**
* @param CurlClientState $multi
*/
private static function select(ClientState $multi, float $timeout): int
{
if ($multi->pauseExpiries) {
$now = microtime(true);
foreach ($multi->pauseExpiries as $id => $pauseExpiry) {
if ($now < $pauseExpiry) {
$timeout = min($timeout, $pauseExpiry - $now);
break;
}
unset($multi->pauseExpiries[$id]);
curl_pause($multi->openHandles[$id][0], \CURLPAUSE_CONT);
curl_multi_add_handle($multi->handle, $multi->openHandles[$id][0]);
}
}
if (0 !== $selected = curl_multi_select($multi->handle, $timeout)) {
return $selected;
}
if ($multi->pauseExpiries && 0 < $timeout -= microtime(true) - $now) {
usleep((int) (1E6 * $timeout));
}
return 0;
}
/**
* Parses header lines as curl yields them to us.
*/
private static function parseHeaderLine($ch, string $data, array &$info, array &$headers, ?array $options, CurlClientState $multi, int $id, ?string &$location, ?callable $resolveRedirect, ?LoggerInterface $logger): int
{
if (!str_ends_with($data, "\r\n")) {
return 0;
}
$waitFor = @curl_getinfo($ch, \CURLINFO_PRIVATE) ?: '_0';
if ('H' !== $waitFor[0]) {
return \strlen($data); // Ignore HTTP trailers
}
$statusCode = curl_getinfo($ch, \CURLINFO_RESPONSE_CODE);
if ($statusCode !== $info['http_code'] && !preg_match("#^HTTP/\d+(?:\.\d+)? {$statusCode}(?: |\r\n$)#", $data)) {
return \strlen($data); // Ignore headers from responses to CONNECT requests
}
if ("\r\n" !== $data) {
// Regular header line: add it to the list
self::addResponseHeaders([substr($data, 0, -2)], $info, $headers);
if (!str_starts_with($data, 'HTTP/')) {
if (0 === stripos($data, 'Location:')) {
$location = trim(substr($data, 9, -2));
}
return \strlen($data);
}
if (\function_exists('openssl_x509_read') && $certinfo = curl_getinfo($ch, \CURLINFO_CERTINFO)) {
$info['peer_certificate_chain'] = array_map('openssl_x509_read', array_column($certinfo, 'Cert'));
}
if (300 <= $info['http_code'] && $info['http_code'] < 400) {
if (curl_getinfo($ch, \CURLINFO_REDIRECT_COUNT) === $options['max_redirects']) {
curl_setopt($ch, \CURLOPT_FOLLOWLOCATION, false);
} elseif (303 === $info['http_code'] || ('POST' === $info['http_method'] && \in_array($info['http_code'], [301, 302], true))) {
curl_setopt($ch, \CURLOPT_POSTFIELDS, '');
}
}
return \strlen($data);
}
// End of headers: handle informational responses, redirects, etc.
if (200 > $statusCode) {
$multi->handlesActivity[$id][] = new InformationalChunk($statusCode, $headers);
$location = null;
return \strlen($data);
}
$info['redirect_url'] = null;
if (300 <= $statusCode && $statusCode < 400 && null !== $location) {
if ($noContent = 303 === $statusCode || ('POST' === $info['http_method'] && \in_array($statusCode, [301, 302], true))) {
$info['http_method'] = 'HEAD' === $info['http_method'] ? 'HEAD' : 'GET';
curl_setopt($ch, \CURLOPT_CUSTOMREQUEST, $info['http_method']);
}
if (null === $info['redirect_url'] = $resolveRedirect($ch, $location, $noContent)) {
$options['max_redirects'] = curl_getinfo($ch, \CURLINFO_REDIRECT_COUNT);
curl_setopt($ch, \CURLOPT_FOLLOWLOCATION, false);
curl_setopt($ch, \CURLOPT_MAXREDIRS, $options['max_redirects']);
} else {
$url = parse_url($location ?? ':');
if (isset($url['host']) && null !== $ip = $multi->dnsCache->hostnames[$url['host'] = strtolower($url['host'])] ?? null) {
// Populate DNS cache for redirects if needed
$port = $url['port'] ?? ('http' === ($url['scheme'] ?? parse_url(curl_getinfo($ch, \CURLINFO_EFFECTIVE_URL), \PHP_URL_SCHEME)) ? 80 : 443);
curl_setopt($ch, \CURLOPT_RESOLVE, ["{$url['host']}:$port:$ip"]);
$multi->dnsCache->removals["-{$url['host']}:$port"] = "-{$url['host']}:$port";
}
}
}
if (401 === $statusCode && isset($options['auth_ntlm']) && 0 === strncasecmp($headers['www-authenticate'][0] ?? '', 'NTLM ', 5)) {
// Continue with NTLM auth
} elseif ($statusCode < 300 || 400 <= $statusCode || null === $location || curl_getinfo($ch, \CURLINFO_REDIRECT_COUNT) === $options['max_redirects']) {
// Headers and redirects completed, time to get the response's content
$multi->handlesActivity[$id][] = new FirstChunk();
if ('HEAD' === $info['http_method'] || \in_array($statusCode, [204, 304], true)) {
$waitFor = '_0'; // no content expected
$multi->handlesActivity[$id][] = null;
$multi->handlesActivity[$id][] = null;
} else {
$waitFor[0] = 'C'; // C = content
}
curl_setopt($ch, \CURLOPT_PRIVATE, $waitFor);
} elseif (null !== $info['redirect_url'] && $logger) {
$logger->info(sprintf('Redirecting: "%s %s"', $info['http_code'], $info['redirect_url']));
}
$location = null;
return \strlen($data);
}
}

View File

@@ -0,0 +1,75 @@
<?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\Response;
use GuzzleHttp\Promise\Create;
use GuzzleHttp\Promise\PromiseInterface as GuzzlePromiseInterface;
use Http\Promise\Promise as HttplugPromiseInterface;
use Psr\Http\Message\ResponseInterface as Psr7ResponseInterface;
/**
* @author Tobias Nyholm <tobias.nyholm@gmail.com>
*
* @internal
*/
final class HttplugPromise implements HttplugPromiseInterface
{
private GuzzlePromiseInterface $promise;
public function __construct(GuzzlePromiseInterface $promise)
{
$this->promise = $promise;
}
public function then(callable $onFulfilled = null, callable $onRejected = null): self
{
return new self($this->promise->then(
$this->wrapThenCallback($onFulfilled),
$this->wrapThenCallback($onRejected)
));
}
public function cancel(): void
{
$this->promise->cancel();
}
public function getState(): string
{
return $this->promise->getState();
}
/**
* @return Psr7ResponseInterface|mixed
*/
public function wait($unwrap = true): mixed
{
$result = $this->promise->wait($unwrap);
while ($result instanceof HttplugPromiseInterface || $result instanceof GuzzlePromiseInterface) {
$result = $result->wait($unwrap);
}
return $result;
}
private function wrapThenCallback(?callable $callback): ?callable
{
if (null === $callback) {
return null;
}
return static function ($value) use ($callback) {
return Create::promiseFor($callback($value));
};
}
}

View File

@@ -0,0 +1,330 @@
<?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\Response;
use Symfony\Component\HttpClient\Chunk\ErrorChunk;
use Symfony\Component\HttpClient\Chunk\FirstChunk;
use Symfony\Component\HttpClient\Exception\InvalidArgumentException;
use Symfony\Component\HttpClient\Exception\TransportException;
use Symfony\Component\HttpClient\Internal\ClientState;
use Symfony\Contracts\HttpClient\ResponseInterface;
/**
* A test-friendly response.
*
* @author Nicolas Grekas <p@tchwork.com>
*/
class MockResponse implements ResponseInterface, StreamableInterface
{
use CommonResponseTrait;
use TransportResponseTrait {
doDestruct as public __destruct;
}
private string|iterable $body;
private array $requestOptions = [];
private string $requestUrl;
private string $requestMethod;
private static ClientState $mainMulti;
private static int $idSequence = 0;
/**
* @param string|iterable<string|\Throwable> $body The response body as a string or an iterable of strings,
* yielding an empty string simulates an idle timeout,
* throwing or yielding an exception yields an ErrorChunk
*
* @see ResponseInterface::getInfo() for possible info, e.g. "response_headers"
*/
public function __construct(string|iterable $body = '', array $info = [])
{
$this->body = $body;
$this->info = $info + ['http_code' => 200] + $this->info;
if (!isset($info['response_headers'])) {
return;
}
$responseHeaders = [];
foreach ($info['response_headers'] as $k => $v) {
foreach ((array) $v as $v) {
$responseHeaders[] = (\is_string($k) ? $k.': ' : '').$v;
}
}
$this->info['response_headers'] = [];
self::addResponseHeaders($responseHeaders, $this->info, $this->headers);
}
/**
* Returns the options used when doing the request.
*/
public function getRequestOptions(): array
{
return $this->requestOptions;
}
/**
* Returns the URL used when doing the request.
*/
public function getRequestUrl(): string
{
return $this->requestUrl;
}
/**
* Returns the method used when doing the request.
*/
public function getRequestMethod(): string
{
return $this->requestMethod;
}
public function getInfo(string $type = null): mixed
{
return null !== $type ? $this->info[$type] ?? null : $this->info;
}
public function cancel(): void
{
$this->info['canceled'] = true;
$this->info['error'] = 'Response has been canceled.';
try {
unset($this->body);
} catch (TransportException $e) {
// ignore errors when canceling
}
$onProgress = $this->requestOptions['on_progress'] ?? static function () {};
$dlSize = isset($this->headers['content-encoding']) || 'HEAD' === ($this->info['http_method'] ?? null) || \in_array($this->info['http_code'], [204, 304], true) ? 0 : (int) ($this->headers['content-length'][0] ?? 0);
$onProgress($this->offset, $dlSize, $this->info);
}
protected function close(): void
{
$this->inflate = null;
$this->body = [];
}
/**
* @internal
*/
public static function fromRequest(string $method, string $url, array $options, ResponseInterface $mock): self
{
$response = new self([]);
$response->requestOptions = $options;
$response->id = ++self::$idSequence;
$response->shouldBuffer = $options['buffer'] ?? true;
$response->initializer = static function (self $response) {
return \is_array($response->body[0] ?? null);
};
$response->info['redirect_count'] = 0;
$response->info['redirect_url'] = null;
$response->info['start_time'] = microtime(true);
$response->info['http_method'] = $method;
$response->info['http_code'] = 0;
$response->info['user_data'] = $options['user_data'] ?? null;
$response->info['max_duration'] = $options['max_duration'] ?? null;
$response->info['url'] = $url;
$response->info['original_url'] = $url;
if ($mock instanceof self) {
$mock->requestOptions = $response->requestOptions;
$mock->requestMethod = $method;
$mock->requestUrl = $url;
}
self::writeRequest($response, $options, $mock);
$response->body[] = [$options, $mock];
return $response;
}
protected static function schedule(self $response, array &$runningResponses): void
{
if (!isset($response->id)) {
throw new InvalidArgumentException('MockResponse instances must be issued by MockHttpClient before processing.');
}
$multi = self::$mainMulti ??= new ClientState();
if (!isset($runningResponses[0])) {
$runningResponses[0] = [$multi, []];
}
$runningResponses[0][1][$response->id] = $response;
}
protected static function perform(ClientState $multi, array &$responses): void
{
foreach ($responses as $response) {
$id = $response->id;
if (!isset($response->body)) {
// Canceled response
$response->body = [];
} elseif ([] === $response->body) {
// Error chunk
$multi->handlesActivity[$id][] = null;
$multi->handlesActivity[$id][] = null !== $response->info['error'] ? new TransportException($response->info['error']) : null;
} elseif (null === $chunk = array_shift($response->body)) {
// Last chunk
$multi->handlesActivity[$id][] = null;
$multi->handlesActivity[$id][] = array_shift($response->body);
} elseif (\is_array($chunk)) {
// First chunk
try {
$offset = 0;
$chunk[1]->getStatusCode();
$chunk[1]->getHeaders(false);
self::readResponse($response, $chunk[0], $chunk[1], $offset);
$multi->handlesActivity[$id][] = new FirstChunk();
$buffer = $response->requestOptions['buffer'] ?? null;
if ($buffer instanceof \Closure && $response->content = $buffer($response->headers) ?: null) {
$response->content = \is_resource($response->content) ? $response->content : fopen('php://temp', 'w+');
}
} catch (\Throwable $e) {
$multi->handlesActivity[$id][] = null;
$multi->handlesActivity[$id][] = $e;
}
} elseif ($chunk instanceof \Throwable) {
$multi->handlesActivity[$id][] = null;
$multi->handlesActivity[$id][] = $chunk;
} else {
// Data or timeout chunk
$multi->handlesActivity[$id][] = $chunk;
}
}
}
protected static function select(ClientState $multi, float $timeout): int
{
return 42;
}
/**
* Simulates sending the request.
*/
private static function writeRequest(self $response, array $options, ResponseInterface $mock): void
{
$onProgress = $options['on_progress'] ?? static function () {};
$response->info += $mock->getInfo() ?: [];
// simulate "size_upload" if it is set
if (isset($response->info['size_upload'])) {
$response->info['size_upload'] = 0.0;
}
// simulate "total_time" if it is not set
if (!isset($response->info['total_time'])) {
$response->info['total_time'] = microtime(true) - $response->info['start_time'];
}
// "notify" DNS resolution
$onProgress(0, 0, $response->info);
// consume the request body
if (\is_resource($body = $options['body'] ?? '')) {
$data = stream_get_contents($body);
if (isset($response->info['size_upload'])) {
$response->info['size_upload'] += \strlen($data);
}
} elseif ($body instanceof \Closure) {
while ('' !== $data = $body(16372)) {
if (!\is_string($data)) {
throw new TransportException(sprintf('Return value of the "body" option callback must be string, "%s" returned.', get_debug_type($data)));
}
// "notify" upload progress
if (isset($response->info['size_upload'])) {
$response->info['size_upload'] += \strlen($data);
}
$onProgress(0, 0, $response->info);
}
}
}
/**
* Simulates reading the response.
*/
private static function readResponse(self $response, array $options, ResponseInterface $mock, int &$offset): void
{
$onProgress = $options['on_progress'] ?? static function () {};
// populate info related to headers
$info = $mock->getInfo() ?: [];
$response->info['http_code'] = ($info['http_code'] ?? 0) ?: $mock->getStatusCode() ?: 200;
$response->addResponseHeaders($info['response_headers'] ?? [], $response->info, $response->headers);
$dlSize = isset($response->headers['content-encoding']) || 'HEAD' === $response->info['http_method'] || \in_array($response->info['http_code'], [204, 304], true) ? 0 : (int) ($response->headers['content-length'][0] ?? 0);
$response->info = [
'start_time' => $response->info['start_time'],
'user_data' => $response->info['user_data'],
'max_duration' => $response->info['max_duration'],
'http_code' => $response->info['http_code'],
] + $info + $response->info;
if (null !== $response->info['error']) {
throw new TransportException($response->info['error']);
}
if (!isset($response->info['total_time'])) {
$response->info['total_time'] = microtime(true) - $response->info['start_time'];
}
// "notify" headers arrival
$onProgress(0, $dlSize, $response->info);
// cast response body to activity list
$body = $mock instanceof self ? $mock->body : $mock->getContent(false);
if (!\is_string($body)) {
try {
foreach ($body as $chunk) {
if ($chunk instanceof \Throwable) {
throw $chunk;
}
if ('' === $chunk = (string) $chunk) {
// simulate an idle timeout
$response->body[] = new ErrorChunk($offset, sprintf('Idle timeout reached for "%s".', $response->info['url']));
} else {
$response->body[] = $chunk;
$offset += \strlen($chunk);
// "notify" download progress
$onProgress($offset, $dlSize, $response->info);
}
}
} catch (\Throwable $e) {
$response->body[] = $e;
}
} elseif ('' !== $body) {
$response->body[] = $body;
$offset = \strlen($body);
}
if (!isset($response->info['total_time'])) {
$response->info['total_time'] = microtime(true) - $response->info['start_time'];
}
// "notify" completion
$onProgress($offset, $dlSize, $response->info);
if ($dlSize && $offset !== $dlSize) {
throw new TransportException(sprintf('Transfer closed with %d bytes remaining to read.', $dlSize - $offset));
}
}
}

View File

@@ -0,0 +1,372 @@
<?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\Response;
use Psr\Log\LoggerInterface;
use Symfony\Component\HttpClient\Chunk\FirstChunk;
use Symfony\Component\HttpClient\Exception\TransportException;
use Symfony\Component\HttpClient\Internal\Canary;
use Symfony\Component\HttpClient\Internal\ClientState;
use Symfony\Component\HttpClient\Internal\NativeClientState;
use Symfony\Contracts\HttpClient\ResponseInterface;
/**
* @author Nicolas Grekas <p@tchwork.com>
*
* @internal
*/
final class NativeResponse implements ResponseInterface, StreamableInterface
{
use CommonResponseTrait;
use TransportResponseTrait;
/**
* @var resource
*/
private $context;
private string $url;
private $resolver;
private $onProgress;
private ?int $remaining = null;
/**
* @var resource|null
*/
private $buffer;
private NativeClientState $multi;
private float $pauseExpiry = 0.0;
/**
* @internal
*/
public function __construct(NativeClientState $multi, $context, string $url, array $options, array &$info, callable $resolver, ?callable $onProgress, ?LoggerInterface $logger)
{
$this->multi = $multi;
$this->id = $id = (int) $context;
$this->context = $context;
$this->url = $url;
$this->logger = $logger;
$this->timeout = $options['timeout'];
$this->info = &$info;
$this->resolver = $resolver;
$this->onProgress = $onProgress;
$this->inflate = !isset($options['normalized_headers']['accept-encoding']);
$this->shouldBuffer = $options['buffer'] ?? true;
// Temporary resource to dechunk the response stream
$this->buffer = fopen('php://temp', 'w+');
$info['original_url'] = implode('', $info['url']);
$info['user_data'] = $options['user_data'];
$info['max_duration'] = $options['max_duration'];
++$multi->responseCount;
$this->initializer = static function (self $response) {
return null === $response->remaining;
};
$pauseExpiry = &$this->pauseExpiry;
$info['pause_handler'] = static function (float $duration) use (&$pauseExpiry) {
$pauseExpiry = 0 < $duration ? microtime(true) + $duration : 0;
};
$this->canary = new Canary(static function () use ($multi, $id) {
if (null !== ($host = $multi->openHandles[$id][6] ?? null) && 0 >= --$multi->hosts[$host]) {
unset($multi->hosts[$host]);
}
unset($multi->openHandles[$id], $multi->handlesActivity[$id]);
});
}
public function getInfo(string $type = null): mixed
{
if (!$info = $this->finalInfo) {
$info = $this->info;
$info['url'] = implode('', $info['url']);
unset($info['size_body'], $info['request_header']);
if (null === $this->buffer) {
$this->finalInfo = $info;
}
}
return null !== $type ? $info[$type] ?? null : $info;
}
public function __destruct()
{
try {
$this->doDestruct();
} finally {
// Clear the DNS cache when all requests completed
if (0 >= --$this->multi->responseCount) {
$this->multi->responseCount = 0;
$this->multi->dnsCache = [];
}
}
}
private function open(): void
{
$url = $this->url;
set_error_handler(function ($type, $msg) use (&$url) {
if (\E_NOTICE !== $type || 'fopen(): Content-type not specified assuming application/x-www-form-urlencoded' !== $msg) {
throw new TransportException($msg);
}
$this->logger?->info(sprintf('%s for "%s".', $msg, $url ?? $this->url));
});
try {
$this->info['start_time'] = microtime(true);
[$resolver, $url] = ($this->resolver)($this->multi);
while (true) {
$context = stream_context_get_options($this->context);
if ($proxy = $context['http']['proxy'] ?? null) {
$this->info['debug'] .= "* Establish HTTP proxy tunnel to {$proxy}\n";
$this->info['request_header'] = $url;
} else {
$this->info['debug'] .= "* Trying {$this->info['primary_ip']}...\n";
$this->info['request_header'] = $this->info['url']['path'].$this->info['url']['query'];
}
$this->info['request_header'] = sprintf("> %s %s HTTP/%s \r\n", $context['http']['method'], $this->info['request_header'], $context['http']['protocol_version']);
$this->info['request_header'] .= implode("\r\n", $context['http']['header'])."\r\n\r\n";
if (\array_key_exists('peer_name', $context['ssl']) && null === $context['ssl']['peer_name']) {
unset($context['ssl']['peer_name']);
$this->context = stream_context_create([], ['options' => $context] + stream_context_get_params($this->context));
}
// Send request and follow redirects when needed
$this->handle = $h = fopen($url, 'r', false, $this->context);
self::addResponseHeaders(stream_get_meta_data($h)['wrapper_data'], $this->info, $this->headers, $this->info['debug']);
$url = $resolver($this->multi, $this->headers['location'][0] ?? null, $this->context);
if (null === $url) {
break;
}
$this->logger?->info(sprintf('Redirecting: "%s %s"', $this->info['http_code'], $url ?? $this->url));
}
} catch (\Throwable $e) {
$this->close();
$this->multi->handlesActivity[$this->id][] = null;
$this->multi->handlesActivity[$this->id][] = $e;
return;
} finally {
$this->info['pretransfer_time'] = $this->info['total_time'] = microtime(true) - $this->info['start_time'];
restore_error_handler();
}
if (isset($context['ssl']['capture_peer_cert_chain']) && isset(($context = stream_context_get_options($this->context))['ssl']['peer_certificate_chain'])) {
$this->info['peer_certificate_chain'] = $context['ssl']['peer_certificate_chain'];
}
stream_set_blocking($h, false);
$this->context = $this->resolver = null;
// Create dechunk buffers
if (isset($this->headers['content-length'])) {
$this->remaining = (int) $this->headers['content-length'][0];
} elseif ('chunked' === ($this->headers['transfer-encoding'][0] ?? null)) {
stream_filter_append($this->buffer, 'dechunk', \STREAM_FILTER_WRITE);
$this->remaining = -1;
} else {
$this->remaining = -2;
}
$this->multi->handlesActivity[$this->id] = [new FirstChunk()];
if ('HEAD' === $context['http']['method'] || \in_array($this->info['http_code'], [204, 304], true)) {
$this->multi->handlesActivity[$this->id][] = null;
$this->multi->handlesActivity[$this->id][] = null;
return;
}
$host = parse_url($this->info['redirect_url'] ?? $this->url, \PHP_URL_HOST);
$this->multi->lastTimeout = null;
$this->multi->openHandles[$this->id] = [&$this->pauseExpiry, $h, $this->buffer, $this->onProgress, &$this->remaining, &$this->info, $host];
$this->multi->hosts[$host] = 1 + ($this->multi->hosts[$host] ?? 0);
}
private function close(): void
{
$this->canary->cancel();
$this->handle = $this->buffer = $this->inflate = $this->onProgress = null;
}
private static function schedule(self $response, array &$runningResponses): void
{
if (!isset($runningResponses[$i = $response->multi->id])) {
$runningResponses[$i] = [$response->multi, []];
}
$runningResponses[$i][1][$response->id] = $response;
if (null === $response->buffer) {
// Response already completed
$response->multi->handlesActivity[$response->id][] = null;
$response->multi->handlesActivity[$response->id][] = null !== $response->info['error'] ? new TransportException($response->info['error']) : null;
}
}
/**
* @param NativeClientState $multi
*/
private static function perform(ClientState $multi, array &$responses = null): void
{
foreach ($multi->openHandles as $i => [$pauseExpiry, $h, $buffer, $onProgress]) {
if ($pauseExpiry) {
if (microtime(true) < $pauseExpiry) {
continue;
}
$multi->openHandles[$i][0] = 0;
}
$hasActivity = false;
$remaining = &$multi->openHandles[$i][4];
$info = &$multi->openHandles[$i][5];
$e = null;
// Read incoming buffer and write it to the dechunk one
try {
if ($remaining && '' !== $data = (string) fread($h, 0 > $remaining ? 16372 : $remaining)) {
fwrite($buffer, $data);
$hasActivity = true;
$multi->sleep = false;
if (-1 !== $remaining) {
$remaining -= \strlen($data);
}
}
} catch (\Throwable $e) {
$hasActivity = $onProgress = false;
}
if (!$hasActivity) {
if ($onProgress) {
try {
// Notify the progress callback so that it can e.g. cancel
// the request if the stream is inactive for too long
$info['total_time'] = microtime(true) - $info['start_time'];
$onProgress();
} catch (\Throwable $e) {
// no-op
}
}
} elseif ('' !== $data = stream_get_contents($buffer, -1, 0)) {
rewind($buffer);
ftruncate($buffer, 0);
if (null === $e) {
$multi->handlesActivity[$i][] = $data;
}
}
if (null !== $e || !$remaining || feof($h)) {
// Stream completed
$info['total_time'] = microtime(true) - $info['start_time'];
$info['starttransfer_time'] = $info['starttransfer_time'] ?: $info['total_time'];
if ($onProgress) {
try {
$onProgress(-1);
} catch (\Throwable $e) {
// no-op
}
}
if (null === $e) {
if (0 < $remaining) {
$e = new TransportException(sprintf('Transfer closed with %s bytes remaining to read.', $remaining));
} elseif (-1 === $remaining && fwrite($buffer, '-') && '' !== stream_get_contents($buffer, -1, 0)) {
$e = new TransportException('Transfer closed with outstanding data remaining from chunked response.');
}
}
$multi->handlesActivity[$i][] = null;
$multi->handlesActivity[$i][] = $e;
if (null !== ($host = $multi->openHandles[$i][6] ?? null) && 0 >= --$multi->hosts[$host]) {
unset($multi->hosts[$host]);
}
unset($multi->openHandles[$i]);
$multi->sleep = false;
}
}
if (null === $responses) {
return;
}
$maxHosts = $multi->maxHostConnections;
foreach ($responses as $i => $response) {
if (null !== $response->remaining || null === $response->buffer) {
continue;
}
if ($response->pauseExpiry && microtime(true) < $response->pauseExpiry) {
// Create empty open handles to tell we still have pending requests
$multi->openHandles[$i] = [\INF, null, null, null];
} elseif ($maxHosts && $maxHosts > ($multi->hosts[parse_url($response->url, \PHP_URL_HOST)] ?? 0)) {
// Open the next pending request - this is a blocking operation so we do only one of them
$response->open();
$multi->sleep = false;
self::perform($multi);
$maxHosts = 0;
}
}
}
/**
* @param NativeClientState $multi
*/
private static function select(ClientState $multi, float $timeout): int
{
if (!$multi->sleep = !$multi->sleep) {
return -1;
}
$_ = $handles = [];
$now = null;
foreach ($multi->openHandles as [$pauseExpiry, $h]) {
if (null === $h) {
continue;
}
if ($pauseExpiry && ($now ??= microtime(true)) < $pauseExpiry) {
$timeout = min($timeout, $pauseExpiry - $now);
continue;
}
$handles[] = $h;
}
if (!$handles) {
usleep((int) (1E6 * $timeout));
return 0;
}
return stream_select($handles, $_, $_, (int) $timeout, (int) (1E6 * ($timeout - (int) $timeout)));
}
}

View File

@@ -0,0 +1,54 @@
<?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\Response;
use Symfony\Contracts\HttpClient\ChunkInterface;
use Symfony\Contracts\HttpClient\ResponseInterface;
use Symfony\Contracts\HttpClient\ResponseStreamInterface;
/**
* @author Nicolas Grekas <p@tchwork.com>
*/
final class ResponseStream implements ResponseStreamInterface
{
private \Generator $generator;
public function __construct(\Generator $generator)
{
$this->generator = $generator;
}
public function key(): ResponseInterface
{
return $this->generator->key();
}
public function current(): ChunkInterface
{
return $this->generator->current();
}
public function next(): void
{
$this->generator->next();
}
public function rewind(): void
{
$this->generator->rewind();
}
public function valid(): bool
{
return $this->generator->valid();
}
}

View File

@@ -0,0 +1,309 @@
<?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\Response;
use Symfony\Contracts\HttpClient\Exception\ExceptionInterface;
use Symfony\Contracts\HttpClient\HttpClientInterface;
use Symfony\Contracts\HttpClient\ResponseInterface;
/**
* Allows turning ResponseInterface instances to PHP streams.
*
* @author Nicolas Grekas <p@tchwork.com>
*/
class StreamWrapper
{
/** @var resource|null */
public $context;
private HttpClientInterface|ResponseInterface $client;
private ResponseInterface $response;
/** @var resource|string|null */
private $content;
/** @var resource|null */
private $handle;
private bool $blocking = true;
private ?float $timeout = null;
private bool $eof = false;
private ?int $offset = 0;
/**
* Creates a PHP stream resource from a ResponseInterface.
*
* @return resource
*/
public static function createResource(ResponseInterface $response, HttpClientInterface $client = null)
{
if ($response instanceof StreamableInterface) {
$stack = debug_backtrace(\DEBUG_BACKTRACE_PROVIDE_OBJECT | \DEBUG_BACKTRACE_IGNORE_ARGS, 2);
if ($response !== ($stack[1]['object'] ?? null)) {
return $response->toStream(false);
}
}
if (null === $client && !method_exists($response, 'stream')) {
throw new \InvalidArgumentException(sprintf('Providing a client to "%s()" is required when the response doesn\'t have any "stream()" method.', __CLASS__));
}
static $registered = false;
if (!$registered = $registered || stream_wrapper_register(strtr(__CLASS__, '\\', '-'), __CLASS__)) {
throw new \RuntimeException(error_get_last()['message'] ?? 'Registering the "symfony" stream wrapper failed.');
}
$context = [
'client' => $client ?? $response,
'response' => $response,
];
return fopen(strtr(__CLASS__, '\\', '-').'://'.$response->getInfo('url'), 'r', false, stream_context_create(['symfony' => $context]));
}
public function getResponse(): ResponseInterface
{
return $this->response;
}
/**
* @param resource|callable|null $handle The resource handle that should be monitored when
* stream_select() is used on the created stream
* @param resource|null $content The seekable resource where the response body is buffered
*/
public function bindHandles(&$handle, &$content): void
{
$this->handle = &$handle;
$this->content = &$content;
$this->offset = null;
}
public function stream_open(string $path, string $mode, int $options): bool
{
if ('r' !== $mode) {
if ($options & \STREAM_REPORT_ERRORS) {
trigger_error(sprintf('Invalid mode "%s": only "r" is supported.', $mode), \E_USER_WARNING);
}
return false;
}
$context = stream_context_get_options($this->context)['symfony'] ?? null;
$this->client = $context['client'] ?? null;
$this->response = $context['response'] ?? null;
$this->context = null;
if (null !== $this->client && null !== $this->response) {
return true;
}
if ($options & \STREAM_REPORT_ERRORS) {
trigger_error('Missing options "client" or "response" in "symfony" stream context.', \E_USER_WARNING);
}
return false;
}
public function stream_read(int $count): string|false
{
if (\is_resource($this->content)) {
// Empty the internal activity list
foreach ($this->client->stream([$this->response], 0) as $chunk) {
try {
if (!$chunk->isTimeout() && $chunk->isFirst()) {
$this->response->getStatusCode(); // ignore 3/4/5xx
}
} catch (ExceptionInterface $e) {
trigger_error($e->getMessage(), \E_USER_WARNING);
return false;
}
}
if (0 !== fseek($this->content, $this->offset ?? 0)) {
return false;
}
if ('' !== $data = fread($this->content, $count)) {
fseek($this->content, 0, \SEEK_END);
$this->offset += \strlen($data);
return $data;
}
}
if (\is_string($this->content)) {
if (\strlen($this->content) <= $count) {
$data = $this->content;
$this->content = null;
} else {
$data = substr($this->content, 0, $count);
$this->content = substr($this->content, $count);
}
$this->offset += \strlen($data);
return $data;
}
foreach ($this->client->stream([$this->response], $this->blocking ? $this->timeout : 0) as $chunk) {
try {
$this->eof = true;
$this->eof = !$chunk->isTimeout();
if (!$this->eof && !$this->blocking) {
return '';
}
$this->eof = $chunk->isLast();
if ($chunk->isFirst()) {
$this->response->getStatusCode(); // ignore 3/4/5xx
}
if ('' !== $data = $chunk->getContent()) {
if (\strlen($data) > $count) {
$this->content ??= substr($data, $count);
$data = substr($data, 0, $count);
}
$this->offset += \strlen($data);
return $data;
}
} catch (ExceptionInterface $e) {
trigger_error($e->getMessage(), \E_USER_WARNING);
return false;
}
}
return '';
}
public function stream_set_option(int $option, int $arg1, ?int $arg2): bool
{
if (\STREAM_OPTION_BLOCKING === $option) {
$this->blocking = (bool) $arg1;
} elseif (\STREAM_OPTION_READ_TIMEOUT === $option) {
$this->timeout = $arg1 + $arg2 / 1e6;
} else {
return false;
}
return true;
}
public function stream_tell(): int
{
return $this->offset ?? 0;
}
public function stream_eof(): bool
{
return $this->eof && !\is_string($this->content);
}
public function stream_seek(int $offset, int $whence = \SEEK_SET): bool
{
if (null === $this->content && null === $this->offset) {
$this->response->getStatusCode();
$this->offset = 0;
}
if (!\is_resource($this->content) || 0 !== fseek($this->content, 0, \SEEK_END)) {
return false;
}
$size = ftell($this->content);
if (\SEEK_CUR === $whence) {
$offset += $this->offset ?? 0;
}
if (\SEEK_END === $whence || $size < $offset) {
foreach ($this->client->stream([$this->response]) as $chunk) {
try {
if ($chunk->isFirst()) {
$this->response->getStatusCode(); // ignore 3/4/5xx
}
// Chunks are buffered in $this->content already
$size += \strlen($chunk->getContent());
if (\SEEK_END !== $whence && $offset <= $size) {
break;
}
} catch (ExceptionInterface $e) {
trigger_error($e->getMessage(), \E_USER_WARNING);
return false;
}
}
if (\SEEK_END === $whence) {
$offset += $size;
}
}
if (0 <= $offset && $offset <= $size) {
$this->eof = false;
$this->offset = $offset;
return true;
}
return false;
}
public function stream_cast(int $castAs)
{
if (\STREAM_CAST_FOR_SELECT === $castAs) {
$this->response->getHeaders(false);
return (\is_callable($this->handle) ? ($this->handle)() : $this->handle) ?? false;
}
return false;
}
public function stream_stat(): array
{
try {
$headers = $this->response->getHeaders(false);
} catch (ExceptionInterface $e) {
trigger_error($e->getMessage(), \E_USER_WARNING);
$headers = [];
}
return [
'dev' => 0,
'ino' => 0,
'mode' => 33060,
'nlink' => 0,
'uid' => 0,
'gid' => 0,
'rdev' => 0,
'size' => (int) ($headers['content-length'][0] ?? -1),
'atime' => 0,
'mtime' => strtotime($headers['last-modified'][0] ?? '') ?: 0,
'ctime' => 0,
'blksize' => 0,
'blocks' => 0,
];
}
private function __construct()
{
}
}

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\Response;
use Symfony\Contracts\HttpClient\Exception\ClientExceptionInterface;
use Symfony\Contracts\HttpClient\Exception\RedirectionExceptionInterface;
use Symfony\Contracts\HttpClient\Exception\ServerExceptionInterface;
use Symfony\Contracts\HttpClient\Exception\TransportExceptionInterface;
/**
* @author Nicolas Grekas <p@tchwork.com>
*/
interface StreamableInterface
{
/**
* Casts the response to a PHP stream resource.
*
* @return resource
*
* @throws TransportExceptionInterface When a network error occurs
* @throws RedirectionExceptionInterface On a 3xx when $throw is true and the "max_redirects" option has been reached
* @throws ClientExceptionInterface On a 4xx when $throw is true
* @throws ServerExceptionInterface On a 5xx when $throw is true
*/
public function toStream(bool $throw = true);
}

View File

@@ -0,0 +1,219 @@
<?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\Response;
use Symfony\Component\HttpClient\Chunk\ErrorChunk;
use Symfony\Component\HttpClient\Exception\ClientException;
use Symfony\Component\HttpClient\Exception\RedirectionException;
use Symfony\Component\HttpClient\Exception\ServerException;
use Symfony\Component\HttpClient\TraceableHttpClient;
use Symfony\Component\Stopwatch\StopwatchEvent;
use Symfony\Contracts\HttpClient\Exception\ClientExceptionInterface;
use Symfony\Contracts\HttpClient\Exception\RedirectionExceptionInterface;
use Symfony\Contracts\HttpClient\Exception\ServerExceptionInterface;
use Symfony\Contracts\HttpClient\Exception\TransportExceptionInterface;
use Symfony\Contracts\HttpClient\HttpClientInterface;
use Symfony\Contracts\HttpClient\ResponseInterface;
/**
* @author Nicolas Grekas <p@tchwork.com>
*
* @internal
*/
class TraceableResponse implements ResponseInterface, StreamableInterface
{
private HttpClientInterface $client;
private ResponseInterface $response;
private mixed $content;
private ?StopwatchEvent $event;
public function __construct(HttpClientInterface $client, ResponseInterface $response, &$content, StopwatchEvent $event = null)
{
$this->client = $client;
$this->response = $response;
$this->content = &$content;
$this->event = $event;
}
public function __sleep(): array
{
throw new \BadMethodCallException('Cannot serialize '.__CLASS__);
}
public function __wakeup()
{
throw new \BadMethodCallException('Cannot unserialize '.__CLASS__);
}
public function __destruct()
{
try {
$this->response->__destruct();
} finally {
if ($this->event?->isStarted()) {
$this->event->stop();
}
}
}
public function getStatusCode(): int
{
try {
return $this->response->getStatusCode();
} finally {
if ($this->event?->isStarted()) {
$this->event->lap();
}
}
}
public function getHeaders(bool $throw = true): array
{
try {
return $this->response->getHeaders($throw);
} finally {
if ($this->event?->isStarted()) {
$this->event->lap();
}
}
}
public function getContent(bool $throw = true): string
{
try {
if (false === $this->content) {
return $this->response->getContent($throw);
}
return $this->content = $this->response->getContent(false);
} finally {
if ($this->event?->isStarted()) {
$this->event->stop();
}
if ($throw) {
$this->checkStatusCode($this->response->getStatusCode());
}
}
}
public function toArray(bool $throw = true): array
{
try {
if (false === $this->content) {
return $this->response->toArray($throw);
}
return $this->content = $this->response->toArray(false);
} finally {
if ($this->event?->isStarted()) {
$this->event->stop();
}
if ($throw) {
$this->checkStatusCode($this->response->getStatusCode());
}
}
}
public function cancel(): void
{
$this->response->cancel();
if ($this->event?->isStarted()) {
$this->event->stop();
}
}
public function getInfo(string $type = null): mixed
{
return $this->response->getInfo($type);
}
/**
* Casts the response to a PHP stream resource.
*
* @return resource
*
* @throws TransportExceptionInterface When a network error occurs
* @throws RedirectionExceptionInterface On a 3xx when $throw is true and the "max_redirects" option has been reached
* @throws ClientExceptionInterface On a 4xx when $throw is true
* @throws ServerExceptionInterface On a 5xx when $throw is true
*/
public function toStream(bool $throw = true)
{
if ($throw) {
// Ensure headers arrived
$this->response->getHeaders(true);
}
if ($this->response instanceof StreamableInterface) {
return $this->response->toStream(false);
}
return StreamWrapper::createResource($this->response, $this->client);
}
/**
* @internal
*/
public static function stream(HttpClientInterface $client, iterable $responses, ?float $timeout): \Generator
{
$wrappedResponses = [];
$traceableMap = new \SplObjectStorage();
foreach ($responses as $r) {
if (!$r instanceof self) {
throw new \TypeError(sprintf('"%s::stream()" expects parameter 1 to be an iterable of TraceableResponse objects, "%s" given.', TraceableHttpClient::class, get_debug_type($r)));
}
$traceableMap[$r->response] = $r;
$wrappedResponses[] = $r->response;
if ($r->event && !$r->event->isStarted()) {
$r->event->start();
}
}
foreach ($client->stream($wrappedResponses, $timeout) as $r => $chunk) {
if ($traceableMap[$r]->event && $traceableMap[$r]->event->isStarted()) {
try {
if ($chunk->isTimeout() || !$chunk->isLast()) {
$traceableMap[$r]->event->lap();
} else {
$traceableMap[$r]->event->stop();
}
} catch (TransportExceptionInterface $e) {
$traceableMap[$r]->event->stop();
if ($chunk instanceof ErrorChunk) {
$chunk->didThrow(false);
} else {
$chunk = new ErrorChunk($chunk->getOffset(), $e);
}
}
}
yield $traceableMap[$r] => $chunk;
}
}
private function checkStatusCode(int $code): void
{
if (500 <= $code) {
throw new ServerException($this);
}
if (400 <= $code) {
throw new ClientException($this);
}
if (300 <= $code) {
throw new RedirectionException($this);
}
}
}

View File

@@ -0,0 +1,305 @@
<?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\Response;
use Psr\Log\LoggerInterface;
use Symfony\Component\HttpClient\Chunk\DataChunk;
use Symfony\Component\HttpClient\Chunk\ErrorChunk;
use Symfony\Component\HttpClient\Chunk\FirstChunk;
use Symfony\Component\HttpClient\Chunk\LastChunk;
use Symfony\Component\HttpClient\Exception\TransportException;
use Symfony\Component\HttpClient\Internal\Canary;
use Symfony\Component\HttpClient\Internal\ClientState;
/**
* Implements common logic for transport-level response classes.
*
* @author Nicolas Grekas <p@tchwork.com>
*
* @internal
*/
trait TransportResponseTrait
{
private Canary $canary;
private array $headers = [];
private array $info = [
'response_headers' => [],
'http_code' => 0,
'error' => null,
'canceled' => false,
];
/** @var object|resource */
private $handle;
private int|string $id;
private ?float $timeout = 0;
private \InflateContext|bool|null $inflate = null;
private ?array $finalInfo = null;
private ?LoggerInterface $logger = null;
public function getStatusCode(): int
{
if ($this->initializer) {
self::initialize($this);
}
return $this->info['http_code'];
}
public function getHeaders(bool $throw = true): array
{
if ($this->initializer) {
self::initialize($this);
}
if ($throw) {
$this->checkStatusCode();
}
return $this->headers;
}
public function cancel(): void
{
$this->info['canceled'] = true;
$this->info['error'] = 'Response has been canceled.';
$this->close();
}
/**
* Closes the response and all its network handles.
*/
protected function close(): void
{
$this->canary->cancel();
$this->inflate = null;
}
/**
* Adds pending responses to the activity list.
*/
abstract protected static function schedule(self $response, array &$runningResponses): void;
/**
* Performs all pending non-blocking operations.
*/
abstract protected static function perform(ClientState $multi, array &$responses): void;
/**
* Waits for network activity.
*/
abstract protected static function select(ClientState $multi, float $timeout): int;
private static function addResponseHeaders(array $responseHeaders, array &$info, array &$headers, string &$debug = ''): void
{
foreach ($responseHeaders as $h) {
if (11 <= \strlen($h) && '/' === $h[4] && preg_match('#^HTTP/\d+(?:\.\d+)? (\d\d\d)(?: |$)#', $h, $m)) {
if ($headers) {
$debug .= "< \r\n";
$headers = [];
}
$info['http_code'] = (int) $m[1];
} elseif (2 === \count($m = explode(':', $h, 2))) {
$headers[strtolower($m[0])][] = ltrim($m[1]);
}
$debug .= "< {$h}\r\n";
$info['response_headers'][] = $h;
}
$debug .= "< \r\n";
}
/**
* Ensures the request is always sent and that the response code was checked.
*/
private function doDestruct(): void
{
$this->shouldBuffer = true;
if ($this->initializer && null === $this->info['error']) {
self::initialize($this);
$this->checkStatusCode();
}
}
/**
* Implements an event loop based on a buffer activity queue.
*
* @param iterable<array-key, self> $responses
*
* @internal
*/
public static function stream(iterable $responses, float $timeout = null): \Generator
{
$runningResponses = [];
foreach ($responses as $response) {
self::schedule($response, $runningResponses);
}
$lastActivity = microtime(true);
$elapsedTimeout = 0;
if ($fromLastTimeout = 0.0 === $timeout && '-0' === (string) $timeout) {
$timeout = null;
} elseif ($fromLastTimeout = 0 > $timeout) {
$timeout = -$timeout;
}
while (true) {
$hasActivity = false;
$timeoutMax = 0;
$timeoutMin = $timeout ?? \INF;
/** @var ClientState $multi */
foreach ($runningResponses as $i => [$multi]) {
$responses = &$runningResponses[$i][1];
self::perform($multi, $responses);
foreach ($responses as $j => $response) {
$timeoutMax = $timeout ?? max($timeoutMax, $response->timeout);
$timeoutMin = min($timeoutMin, $response->timeout, 1);
$chunk = false;
if ($fromLastTimeout && null !== $multi->lastTimeout) {
$elapsedTimeout = microtime(true) - $multi->lastTimeout;
}
if (isset($multi->handlesActivity[$j])) {
$multi->lastTimeout = null;
} elseif (!isset($multi->openHandles[$j])) {
unset($responses[$j]);
continue;
} elseif ($elapsedTimeout >= $timeoutMax) {
$multi->handlesActivity[$j] = [new ErrorChunk($response->offset, sprintf('Idle timeout reached for "%s".', $response->getInfo('url')))];
$multi->lastTimeout ??= $lastActivity;
} else {
continue;
}
while ($multi->handlesActivity[$j] ?? false) {
$hasActivity = true;
$elapsedTimeout = 0;
if (\is_string($chunk = array_shift($multi->handlesActivity[$j]))) {
if (null !== $response->inflate && false === $chunk = @inflate_add($response->inflate, $chunk)) {
$multi->handlesActivity[$j] = [null, new TransportException(sprintf('Error while processing content unencoding for "%s".', $response->getInfo('url')))];
continue;
}
if ('' !== $chunk && null !== $response->content && \strlen($chunk) !== fwrite($response->content, $chunk)) {
$multi->handlesActivity[$j] = [null, new TransportException(sprintf('Failed writing %d bytes to the response buffer.', \strlen($chunk)))];
continue;
}
$chunkLen = \strlen($chunk);
$chunk = new DataChunk($response->offset, $chunk);
$response->offset += $chunkLen;
} elseif (null === $chunk) {
$e = $multi->handlesActivity[$j][0];
unset($responses[$j], $multi->handlesActivity[$j]);
$response->close();
if (null !== $e) {
$response->info['error'] = $e->getMessage();
if ($e instanceof \Error) {
throw $e;
}
$chunk = new ErrorChunk($response->offset, $e);
} else {
if (0 === $response->offset && null === $response->content) {
$response->content = fopen('php://memory', 'w+');
}
$chunk = new LastChunk($response->offset);
}
} elseif ($chunk instanceof ErrorChunk) {
unset($responses[$j]);
$elapsedTimeout = $timeoutMax;
} elseif ($chunk instanceof FirstChunk) {
if ($response->logger) {
$info = $response->getInfo();
$response->logger->info(sprintf('Response: "%s %s"', $info['http_code'], $info['url']));
}
$response->inflate = \extension_loaded('zlib') && $response->inflate && 'gzip' === ($response->headers['content-encoding'][0] ?? null) ? inflate_init(\ZLIB_ENCODING_GZIP) : null;
if ($response->shouldBuffer instanceof \Closure) {
try {
$response->shouldBuffer = ($response->shouldBuffer)($response->headers);
if (null !== $response->info['error']) {
throw new TransportException($response->info['error']);
}
} catch (\Throwable $e) {
$response->close();
$multi->handlesActivity[$j] = [null, $e];
}
}
if (true === $response->shouldBuffer) {
$response->content = fopen('php://temp', 'w+');
} elseif (\is_resource($response->shouldBuffer)) {
$response->content = $response->shouldBuffer;
}
$response->shouldBuffer = null;
yield $response => $chunk;
if ($response->initializer && null === $response->info['error']) {
// Ensure the HTTP status code is always checked
$response->getHeaders(true);
}
continue;
}
yield $response => $chunk;
}
unset($multi->handlesActivity[$j]);
if ($chunk instanceof ErrorChunk && !$chunk->didThrow()) {
// Ensure transport exceptions are always thrown
$chunk->getContent();
}
}
if (!$responses) {
unset($runningResponses[$i]);
}
// Prevent memory leaks
$multi->handlesActivity = $multi->handlesActivity ?: [];
$multi->openHandles = $multi->openHandles ?: [];
}
if (!$runningResponses) {
break;
}
if ($hasActivity) {
$lastActivity = microtime(true);
continue;
}
if (-1 === self::select($multi, min($timeoutMin, $timeoutMax - $elapsedTimeout))) {
usleep(min(500, 1E6 * $timeoutMin));
}
$elapsedTimeout = microtime(true) - $lastActivity;
}
}
}

View File

@@ -0,0 +1,115 @@
<?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\Retry;
use Symfony\Component\HttpClient\Exception\InvalidArgumentException;
use Symfony\Component\HttpClient\Response\AsyncContext;
use Symfony\Contracts\HttpClient\Exception\TransportExceptionInterface;
/**
* Decides to retry the request when HTTP status codes belong to the given list of codes.
*
* @author Jérémy Derussé <jeremy@derusse.com>
*/
class GenericRetryStrategy implements RetryStrategyInterface
{
public const IDEMPOTENT_METHODS = ['GET', 'HEAD', 'PUT', 'DELETE', 'OPTIONS', 'TRACE'];
public const DEFAULT_RETRY_STATUS_CODES = [
0 => self::IDEMPOTENT_METHODS, // for transport exceptions
423,
425,
429,
500 => self::IDEMPOTENT_METHODS,
502,
503,
504 => self::IDEMPOTENT_METHODS,
507 => self::IDEMPOTENT_METHODS,
510 => self::IDEMPOTENT_METHODS,
];
private array $statusCodes;
private int $delayMs;
private float $multiplier;
private int $maxDelayMs;
private float $jitter;
/**
* @param array $statusCodes List of HTTP status codes that trigger a retry
* @param int $delayMs Amount of time to delay (or the initial value when multiplier is used)
* @param float $multiplier Multiplier to apply to the delay each time a retry occurs
* @param int $maxDelayMs Maximum delay to allow (0 means no maximum)
* @param float $jitter Probability of randomness int delay (0 = none, 1 = 100% random)
*/
public function __construct(array $statusCodes = self::DEFAULT_RETRY_STATUS_CODES, int $delayMs = 1000, float $multiplier = 2.0, int $maxDelayMs = 0, float $jitter = 0.1)
{
$this->statusCodes = $statusCodes;
if ($delayMs < 0) {
throw new InvalidArgumentException(sprintf('Delay must be greater than or equal to zero: "%s" given.', $delayMs));
}
$this->delayMs = $delayMs;
if ($multiplier < 1) {
throw new InvalidArgumentException(sprintf('Multiplier must be greater than or equal to one: "%s" given.', $multiplier));
}
$this->multiplier = $multiplier;
if ($maxDelayMs < 0) {
throw new InvalidArgumentException(sprintf('Max delay must be greater than or equal to zero: "%s" given.', $maxDelayMs));
}
$this->maxDelayMs = $maxDelayMs;
if ($jitter < 0 || $jitter > 1) {
throw new InvalidArgumentException(sprintf('Jitter must be between 0 and 1: "%s" given.', $jitter));
}
$this->jitter = $jitter;
}
public function shouldRetry(AsyncContext $context, ?string $responseContent, ?TransportExceptionInterface $exception): ?bool
{
$statusCode = $context->getStatusCode();
if (\in_array($statusCode, $this->statusCodes, true)) {
return true;
}
if (isset($this->statusCodes[$statusCode]) && \is_array($this->statusCodes[$statusCode])) {
return \in_array($context->getInfo('http_method'), $this->statusCodes[$statusCode], true);
}
if (null === $exception) {
return false;
}
if (\in_array(0, $this->statusCodes, true)) {
return true;
}
if (isset($this->statusCodes[0]) && \is_array($this->statusCodes[0])) {
return \in_array($context->getInfo('http_method'), $this->statusCodes[0], true);
}
return false;
}
public function getDelay(AsyncContext $context, ?string $responseContent, ?TransportExceptionInterface $exception): int
{
$delay = $this->delayMs * $this->multiplier ** $context->getInfo('retry_count');
if ($this->jitter > 0) {
$randomness = (int) ($delay * $this->jitter);
$delay = $delay + random_int(-$randomness, +$randomness);
}
if ($delay > $this->maxDelayMs && 0 !== $this->maxDelayMs) {
return $this->maxDelayMs;
}
return (int) $delay;
}
}

View File

@@ -0,0 +1,36 @@
<?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\Retry;
use Symfony\Component\HttpClient\Response\AsyncContext;
use Symfony\Contracts\HttpClient\Exception\TransportExceptionInterface;
/**
* @author Jérémy Derussé <jeremy@derusse.com>
* @author Nicolas Grekas <p@tchwork.com>
*/
interface RetryStrategyInterface
{
/**
* Returns whether the request should be retried.
*
* @param ?string $responseContent Null is passed when the body did not arrive yet
*
* @return bool|null Returns null to signal that the body is required to take a decision
*/
public function shouldRetry(AsyncContext $context, ?string $responseContent, ?TransportExceptionInterface $exception): ?bool;
/**
* Returns the time to wait in milliseconds.
*/
public function getDelay(AsyncContext $context, ?string $responseContent, ?TransportExceptionInterface $exception): int;
}

View File

@@ -0,0 +1,171 @@
<?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;
use Psr\Log\LoggerInterface;
use Psr\Log\NullLogger;
use Symfony\Component\HttpClient\Response\AsyncContext;
use Symfony\Component\HttpClient\Response\AsyncResponse;
use Symfony\Component\HttpClient\Retry\GenericRetryStrategy;
use Symfony\Component\HttpClient\Retry\RetryStrategyInterface;
use Symfony\Contracts\HttpClient\ChunkInterface;
use Symfony\Contracts\HttpClient\Exception\TransportExceptionInterface;
use Symfony\Contracts\HttpClient\HttpClientInterface;
use Symfony\Contracts\HttpClient\ResponseInterface;
use Symfony\Contracts\Service\ResetInterface;
/**
* Automatically retries failing HTTP requests.
*
* @author Jérémy Derussé <jeremy@derusse.com>
*/
class RetryableHttpClient implements HttpClientInterface, ResetInterface
{
use AsyncDecoratorTrait;
private RetryStrategyInterface $strategy;
private int $maxRetries;
private LoggerInterface $logger;
/**
* @param int $maxRetries The maximum number of times to retry
*/
public function __construct(HttpClientInterface $client, RetryStrategyInterface $strategy = null, int $maxRetries = 3, LoggerInterface $logger = null)
{
$this->client = $client;
$this->strategy = $strategy ?? new GenericRetryStrategy();
$this->maxRetries = $maxRetries;
$this->logger = $logger ?? new NullLogger();
}
public function request(string $method, string $url, array $options = []): ResponseInterface
{
if ($this->maxRetries <= 0) {
return new AsyncResponse($this->client, $method, $url, $options);
}
$retryCount = 0;
$content = '';
$firstChunk = null;
return new AsyncResponse($this->client, $method, $url, $options, function (ChunkInterface $chunk, AsyncContext $context) use ($method, $url, $options, &$retryCount, &$content, &$firstChunk) {
$exception = null;
try {
if ($context->getInfo('canceled') || $chunk->isTimeout() || null !== $chunk->getInformationalStatus()) {
yield $chunk;
return;
}
} catch (TransportExceptionInterface $exception) {
// catch TransportExceptionInterface to send it to the strategy
}
if (null !== $exception) {
// always retry request that fail to resolve DNS
if ('' !== $context->getInfo('primary_ip')) {
$shouldRetry = $this->strategy->shouldRetry($context, null, $exception);
if (null === $shouldRetry) {
throw new \LogicException(sprintf('The "%s::shouldRetry()" method must not return null when called with an exception.', \get_class($this->strategy)));
}
if (false === $shouldRetry) {
yield from $this->passthru($context, $firstChunk, $content, $chunk);
return;
}
}
} elseif ($chunk->isFirst()) {
if (false === $shouldRetry = $this->strategy->shouldRetry($context, null, null)) {
yield from $this->passthru($context, $firstChunk, $content, $chunk);
return;
}
// Body is needed to decide
if (null === $shouldRetry) {
$firstChunk = $chunk;
$content = '';
return;
}
} else {
if (!$chunk->isLast()) {
$content .= $chunk->getContent();
return;
}
if (null === $shouldRetry = $this->strategy->shouldRetry($context, $content, null)) {
throw new \LogicException(sprintf('The "%s::shouldRetry()" method must not return null when called with a body.', \get_class($this->strategy)));
}
if (false === $shouldRetry) {
yield from $this->passthru($context, $firstChunk, $content, $chunk);
return;
}
}
$context->getResponse()->cancel();
$delay = $this->getDelayFromHeader($context->getHeaders()) ?? $this->strategy->getDelay($context, !$exception && $chunk->isLast() ? $content : null, $exception);
++$retryCount;
$content = '';
$firstChunk = null;
$this->logger->info('Try #{count} after {delay}ms'.($exception ? ': '.$exception->getMessage() : ', status code: '.$context->getStatusCode()), [
'count' => $retryCount,
'delay' => $delay,
]);
$context->setInfo('retry_count', $retryCount);
$context->replaceRequest($method, $url, $options);
$context->pause($delay / 1000);
if ($retryCount >= $this->maxRetries) {
$context->passthru();
}
});
}
private function getDelayFromHeader(array $headers): ?int
{
if (null !== $after = $headers['retry-after'][0] ?? null) {
if (is_numeric($after)) {
return (int) ($after * 1000);
}
if (false !== $time = strtotime($after)) {
return max(0, $time - time()) * 1000;
}
}
return null;
}
private function passthru(AsyncContext $context, ?ChunkInterface $firstChunk, string &$content, ChunkInterface $lastChunk): \Generator
{
$context->passthru();
if (null !== $firstChunk) {
yield $firstChunk;
}
if ('' !== $content) {
$chunk = $context->createChunk($content);
$content = '';
yield $chunk;
}
yield $lastChunk;
}
}

View File

@@ -0,0 +1,117 @@
<?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;
use Psr\Log\LoggerAwareInterface;
use Psr\Log\LoggerInterface;
use Symfony\Component\HttpClient\Exception\InvalidArgumentException;
use Symfony\Contracts\HttpClient\HttpClientInterface;
use Symfony\Contracts\HttpClient\ResponseInterface;
use Symfony\Contracts\HttpClient\ResponseStreamInterface;
use Symfony\Contracts\Service\ResetInterface;
/**
* Auto-configure the default options based on the requested URL.
*
* @author Anthony Martin <anthony.martin@sensiolabs.com>
*/
class ScopingHttpClient implements HttpClientInterface, ResetInterface, LoggerAwareInterface
{
use HttpClientTrait;
private HttpClientInterface $client;
private array $defaultOptionsByRegexp;
private ?string $defaultRegexp;
public function __construct(HttpClientInterface $client, array $defaultOptionsByRegexp, string $defaultRegexp = null)
{
$this->client = $client;
$this->defaultOptionsByRegexp = $defaultOptionsByRegexp;
$this->defaultRegexp = $defaultRegexp;
if (null !== $defaultRegexp && !isset($defaultOptionsByRegexp[$defaultRegexp])) {
throw new InvalidArgumentException(sprintf('No options are mapped to the provided "%s" default regexp.', $defaultRegexp));
}
}
public static function forBaseUri(HttpClientInterface $client, string $baseUri, array $defaultOptions = [], string $regexp = null): self
{
$regexp ??= preg_quote(implode('', self::resolveUrl(self::parseUrl('.'), self::parseUrl($baseUri))));
$defaultOptions['base_uri'] = $baseUri;
return new self($client, [$regexp => $defaultOptions], $regexp);
}
public function request(string $method, string $url, array $options = []): ResponseInterface
{
$e = null;
$url = self::parseUrl($url, $options['query'] ?? []);
if (\is_string($options['base_uri'] ?? null)) {
$options['base_uri'] = self::parseUrl($options['base_uri']);
}
try {
$url = implode('', self::resolveUrl($url, $options['base_uri'] ?? null));
} catch (InvalidArgumentException $e) {
if (null === $this->defaultRegexp) {
throw $e;
}
$defaultOptions = $this->defaultOptionsByRegexp[$this->defaultRegexp];
$options = self::mergeDefaultOptions($options, $defaultOptions, true);
if (\is_string($options['base_uri'] ?? null)) {
$options['base_uri'] = self::parseUrl($options['base_uri']);
}
$url = implode('', self::resolveUrl($url, $options['base_uri'] ?? null, $defaultOptions['query'] ?? []));
}
foreach ($this->defaultOptionsByRegexp as $regexp => $defaultOptions) {
if (preg_match("{{$regexp}}A", $url)) {
if (null === $e || $regexp !== $this->defaultRegexp) {
$options = self::mergeDefaultOptions($options, $defaultOptions, true);
}
break;
}
}
return $this->client->request($method, $url, $options);
}
public function stream(ResponseInterface|iterable $responses, float $timeout = null): ResponseStreamInterface
{
return $this->client->stream($responses, $timeout);
}
public function reset()
{
if ($this->client instanceof ResetInterface) {
$this->client->reset();
}
}
public function setLogger(LoggerInterface $logger): void
{
if ($this->client instanceof LoggerAwareInterface) {
$this->client->setLogger($logger);
}
}
public function withOptions(array $options): static
{
$clone = clone $this;
$clone->client = $this->client->withOptions($options);
return $clone;
}
}

View File

@@ -0,0 +1,106 @@
<?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;
use Psr\Log\LoggerAwareInterface;
use Psr\Log\LoggerInterface;
use Symfony\Component\HttpClient\Response\ResponseStream;
use Symfony\Component\HttpClient\Response\TraceableResponse;
use Symfony\Component\Stopwatch\Stopwatch;
use Symfony\Contracts\HttpClient\HttpClientInterface;
use Symfony\Contracts\HttpClient\ResponseInterface;
use Symfony\Contracts\HttpClient\ResponseStreamInterface;
use Symfony\Contracts\Service\ResetInterface;
/**
* @author Jérémy Romey <jeremy@free-agent.fr>
*/
final class TraceableHttpClient implements HttpClientInterface, ResetInterface, LoggerAwareInterface
{
private HttpClientInterface $client;
private ?Stopwatch $stopwatch;
private \ArrayObject $tracedRequests;
public function __construct(HttpClientInterface $client, Stopwatch $stopwatch = null)
{
$this->client = $client;
$this->stopwatch = $stopwatch;
$this->tracedRequests = new \ArrayObject();
}
public function request(string $method, string $url, array $options = []): ResponseInterface
{
$content = null;
$traceInfo = [];
$this->tracedRequests[] = [
'method' => $method,
'url' => $url,
'options' => $options,
'info' => &$traceInfo,
'content' => &$content,
];
$onProgress = $options['on_progress'] ?? null;
if (false === ($options['extra']['trace_content'] ?? true)) {
unset($content);
$content = false;
}
$options['on_progress'] = function (int $dlNow, int $dlSize, array $info) use (&$traceInfo, $onProgress) {
$traceInfo = $info;
if (null !== $onProgress) {
$onProgress($dlNow, $dlSize, $info);
}
};
return new TraceableResponse($this->client, $this->client->request($method, $url, $options), $content, $this->stopwatch?->start("$method $url", 'http_client'));
}
public function stream(ResponseInterface|iterable $responses, float $timeout = null): ResponseStreamInterface
{
if ($responses instanceof TraceableResponse) {
$responses = [$responses];
}
return new ResponseStream(TraceableResponse::stream($this->client, $responses, $timeout));
}
public function getTracedRequests(): array
{
return $this->tracedRequests->getArrayCopy();
}
public function reset()
{
if ($this->client instanceof ResetInterface) {
$this->client->reset();
}
$this->tracedRequests->exchangeArray([]);
}
public function setLogger(LoggerInterface $logger): void
{
if ($this->client instanceof LoggerAwareInterface) {
$this->client->setLogger($logger);
}
}
public function withOptions(array $options): static
{
$clone = clone $this;
$clone->client = $this->client->withOptions($options);
return $clone;
}
}

View File

@@ -0,0 +1,104 @@
<?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\OptionsResolver\Debug;
use Symfony\Component\OptionsResolver\Exception\NoConfigurationException;
use Symfony\Component\OptionsResolver\Exception\UndefinedOptionsException;
use Symfony\Component\OptionsResolver\OptionsResolver;
/**
* @author Maxime Steinhausser <maxime.steinhausser@gmail.com>
*
* @final
*/
class OptionsResolverIntrospector
{
private \Closure $get;
public function __construct(OptionsResolver $optionsResolver)
{
$this->get = \Closure::bind(function ($property, $option, $message) {
/** @var OptionsResolver $this */
if (!$this->isDefined($option)) {
throw new UndefinedOptionsException(sprintf('The option "%s" does not exist.', $option));
}
if (!\array_key_exists($option, $this->{$property})) {
throw new NoConfigurationException($message);
}
return $this->{$property}[$option];
}, $optionsResolver, $optionsResolver);
}
/**
* @throws NoConfigurationException on no configured value
*/
public function getDefault(string $option): mixed
{
return ($this->get)('defaults', $option, sprintf('No default value was set for the "%s" option.', $option));
}
/**
* @return \Closure[]
*
* @throws NoConfigurationException on no configured closures
*/
public function getLazyClosures(string $option): array
{
return ($this->get)('lazy', $option, sprintf('No lazy closures were set for the "%s" option.', $option));
}
/**
* @return string[]
*
* @throws NoConfigurationException on no configured types
*/
public function getAllowedTypes(string $option): array
{
return ($this->get)('allowedTypes', $option, sprintf('No allowed types were set for the "%s" option.', $option));
}
/**
* @return mixed[]
*
* @throws NoConfigurationException on no configured values
*/
public function getAllowedValues(string $option): array
{
return ($this->get)('allowedValues', $option, sprintf('No allowed values were set for the "%s" option.', $option));
}
/**
* @throws NoConfigurationException on no configured normalizer
*/
public function getNormalizer(string $option): \Closure
{
return current($this->getNormalizers($option));
}
/**
* @throws NoConfigurationException when no normalizer is configured
*/
public function getNormalizers(string $option): array
{
return ($this->get)('normalizers', $option, sprintf('No normalizer was set for the "%s" option.', $option));
}
/**
* @throws NoConfigurationException on no configured deprecation
*/
public function getDeprecation(string $option): array
{
return ($this->get)('deprecated', $option, sprintf('No deprecation was set for the "%s" option.', $option));
}
}

View File

@@ -0,0 +1,22 @@
<?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\OptionsResolver\Exception;
/**
* Thrown when trying to read an option outside of or write it inside of
* {@link \Symfony\Component\OptionsResolver\Options::resolve()}.
*
* @author Bernhard Schussek <bschussek@gmail.com>
*/
class AccessException extends \LogicException implements ExceptionInterface
{
}

View File

@@ -0,0 +1,21 @@
<?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\OptionsResolver\Exception;
/**
* Marker interface for all exceptions thrown by the OptionsResolver component.
*
* @author Bernhard Schussek <bschussek@gmail.com>
*/
interface ExceptionInterface extends \Throwable
{
}

View File

@@ -0,0 +1,21 @@
<?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\OptionsResolver\Exception;
/**
* Thrown when an argument is invalid.
*
* @author Bernhard Schussek <bschussek@gmail.com>
*/
class InvalidArgumentException extends \InvalidArgumentException implements ExceptionInterface
{
}

View File

@@ -0,0 +1,23 @@
<?php
/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Symfony\Component\OptionsResolver\Exception;
/**
* Thrown when the value of an option does not match its validation rules.
*
* You should make sure a valid value is passed to the option.
*
* @author Bernhard Schussek <bschussek@gmail.com>
*/
class InvalidOptionsException extends InvalidArgumentException
{
}

View File

@@ -0,0 +1,23 @@
<?php
/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Symfony\Component\OptionsResolver\Exception;
/**
* Exception thrown when a required option is missing.
*
* Add the option to the passed options array.
*
* @author Bernhard Schussek <bschussek@gmail.com>
*/
class MissingOptionsException extends InvalidArgumentException
{
}

View File

@@ -0,0 +1,26 @@
<?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\OptionsResolver\Exception;
use Symfony\Component\OptionsResolver\Debug\OptionsResolverIntrospector;
/**
* Thrown when trying to introspect an option definition property
* for which no value was configured inside the OptionsResolver instance.
*
* @see OptionsResolverIntrospector
*
* @author Maxime Steinhausser <maxime.steinhausser@gmail.com>
*/
class NoConfigurationException extends \RuntimeException implements ExceptionInterface
{
}

View File

@@ -0,0 +1,26 @@
<?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\OptionsResolver\Exception;
/**
* Thrown when trying to read an option that has no value set.
*
* When accessing optional options from within a lazy option or normalizer you should first
* check whether the optional option is set. You can do this with `isset($options['optional'])`.
* In contrast to the {@link UndefinedOptionsException}, this is a runtime exception that can
* occur when evaluating lazy options.
*
* @author Tobias Schultze <http://tobion.de>
*/
class NoSuchOptionException extends \OutOfBoundsException implements ExceptionInterface
{
}

View File

@@ -0,0 +1,21 @@
<?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\OptionsResolver\Exception;
/**
* Thrown when two lazy options have a cyclic dependency.
*
* @author Bernhard Schussek <bschussek@gmail.com>
*/
class OptionDefinitionException extends \LogicException implements ExceptionInterface
{
}

View File

@@ -0,0 +1,24 @@
<?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\OptionsResolver\Exception;
/**
* Exception thrown when an undefined option is passed.
*
* You should remove the options in question from your code or define them
* beforehand.
*
* @author Bernhard Schussek <bschussek@gmail.com>
*/
class UndefinedOptionsException extends InvalidArgumentException
{
}

View File

@@ -0,0 +1,19 @@
Copyright (c) 2004-present Fabien Potencier
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is furnished
to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
THE SOFTWARE.

View File

@@ -0,0 +1,149 @@
<?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\OptionsResolver;
use Symfony\Component\OptionsResolver\Exception\AccessException;
final class OptionConfigurator
{
private string $name;
private OptionsResolver $resolver;
public function __construct(string $name, OptionsResolver $resolver)
{
$this->name = $name;
$this->resolver = $resolver;
$this->resolver->setDefined($name);
}
/**
* Adds allowed types for this option.
*
* @return $this
*
* @throws AccessException If called from a lazy option or normalizer
*/
public function allowedTypes(string ...$types): static
{
$this->resolver->setAllowedTypes($this->name, $types);
return $this;
}
/**
* Sets allowed values for this option.
*
* @param mixed ...$values One or more acceptable values/closures
*
* @return $this
*
* @throws AccessException If called from a lazy option or normalizer
*/
public function allowedValues(mixed ...$values): static
{
$this->resolver->setAllowedValues($this->name, $values);
return $this;
}
/**
* Sets the default value for this option.
*
* @return $this
*
* @throws AccessException If called from a lazy option or normalizer
*/
public function default(mixed $value): static
{
$this->resolver->setDefault($this->name, $value);
return $this;
}
/**
* Defines an option configurator with the given name.
*/
public function define(string $option): self
{
return $this->resolver->define($option);
}
/**
* Marks this option as deprecated.
*
* @param string $package The name of the composer package that is triggering the deprecation
* @param string $version The version of the package that introduced the deprecation
* @param string|\Closure $message The deprecation message to use
*
* @return $this
*/
public function deprecated(string $package, string $version, string|\Closure $message = 'The option "%name%" is deprecated.'): static
{
$this->resolver->setDeprecated($this->name, $package, $version, $message);
return $this;
}
/**
* Sets the normalizer for this option.
*
* @return $this
*
* @throws AccessException If called from a lazy option or normalizer
*/
public function normalize(\Closure $normalizer): static
{
$this->resolver->setNormalizer($this->name, $normalizer);
return $this;
}
/**
* Marks this option as required.
*
* @return $this
*
* @throws AccessException If called from a lazy option or normalizer
*/
public function required(): static
{
$this->resolver->setRequired($this->name);
return $this;
}
/**
* Sets an info message for an option.
*
* @return $this
*
* @throws AccessException If called from a lazy option or normalizer
*/
public function info(string $info): static
{
$this->resolver->setInfo($this->name, $info);
return $this;
}
/**
* Sets whether ignore undefined options.
*
* @return $this
*/
public function ignoreUndefined(bool $ignore = true): static
{
$this->resolver->setIgnoreUndefined($ignore);
return $this;
}
}

View File

@@ -0,0 +1,22 @@
<?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\OptionsResolver;
/**
* Contains resolved option values.
*
* @author Bernhard Schussek <bschussek@gmail.com>
* @author Tobias Schultze <http://tobion.de>
*/
interface Options extends \ArrayAccess, \Countable
{
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,232 @@
<?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\Polyfill\Ctype;
/**
* Ctype implementation through regex.
*
* @internal
*
* @author Gert de Pagter <BackEndTea@gmail.com>
*/
final class Ctype
{
/**
* Returns TRUE if every character in text is either a letter or a digit, FALSE otherwise.
*
* @see https://php.net/ctype-alnum
*
* @param mixed $text
*
* @return bool
*/
public static function ctype_alnum($text)
{
$text = self::convert_int_to_char_for_ctype($text, __FUNCTION__);
return \is_string($text) && '' !== $text && !preg_match('/[^A-Za-z0-9]/', $text);
}
/**
* Returns TRUE if every character in text is a letter, FALSE otherwise.
*
* @see https://php.net/ctype-alpha
*
* @param mixed $text
*
* @return bool
*/
public static function ctype_alpha($text)
{
$text = self::convert_int_to_char_for_ctype($text, __FUNCTION__);
return \is_string($text) && '' !== $text && !preg_match('/[^A-Za-z]/', $text);
}
/**
* Returns TRUE if every character in text is a control character from the current locale, FALSE otherwise.
*
* @see https://php.net/ctype-cntrl
*
* @param mixed $text
*
* @return bool
*/
public static function ctype_cntrl($text)
{
$text = self::convert_int_to_char_for_ctype($text, __FUNCTION__);
return \is_string($text) && '' !== $text && !preg_match('/[^\x00-\x1f\x7f]/', $text);
}
/**
* Returns TRUE if every character in the string text is a decimal digit, FALSE otherwise.
*
* @see https://php.net/ctype-digit
*
* @param mixed $text
*
* @return bool
*/
public static function ctype_digit($text)
{
$text = self::convert_int_to_char_for_ctype($text, __FUNCTION__);
return \is_string($text) && '' !== $text && !preg_match('/[^0-9]/', $text);
}
/**
* Returns TRUE if every character in text is printable and actually creates visible output (no white space), FALSE otherwise.
*
* @see https://php.net/ctype-graph
*
* @param mixed $text
*
* @return bool
*/
public static function ctype_graph($text)
{
$text = self::convert_int_to_char_for_ctype($text, __FUNCTION__);
return \is_string($text) && '' !== $text && !preg_match('/[^!-~]/', $text);
}
/**
* Returns TRUE if every character in text is a lowercase letter.
*
* @see https://php.net/ctype-lower
*
* @param mixed $text
*
* @return bool
*/
public static function ctype_lower($text)
{
$text = self::convert_int_to_char_for_ctype($text, __FUNCTION__);
return \is_string($text) && '' !== $text && !preg_match('/[^a-z]/', $text);
}
/**
* Returns TRUE if every character in text will actually create output (including blanks). Returns FALSE if text contains control characters or characters that do not have any output or control function at all.
*
* @see https://php.net/ctype-print
*
* @param mixed $text
*
* @return bool
*/
public static function ctype_print($text)
{
$text = self::convert_int_to_char_for_ctype($text, __FUNCTION__);
return \is_string($text) && '' !== $text && !preg_match('/[^ -~]/', $text);
}
/**
* Returns TRUE if every character in text is printable, but neither letter, digit or blank, FALSE otherwise.
*
* @see https://php.net/ctype-punct
*
* @param mixed $text
*
* @return bool
*/
public static function ctype_punct($text)
{
$text = self::convert_int_to_char_for_ctype($text, __FUNCTION__);
return \is_string($text) && '' !== $text && !preg_match('/[^!-\/\:-@\[-`\{-~]/', $text);
}
/**
* Returns TRUE if every character in text creates some sort of white space, FALSE otherwise. Besides the blank character this also includes tab, vertical tab, line feed, carriage return and form feed characters.
*
* @see https://php.net/ctype-space
*
* @param mixed $text
*
* @return bool
*/
public static function ctype_space($text)
{
$text = self::convert_int_to_char_for_ctype($text, __FUNCTION__);
return \is_string($text) && '' !== $text && !preg_match('/[^\s]/', $text);
}
/**
* Returns TRUE if every character in text is an uppercase letter.
*
* @see https://php.net/ctype-upper
*
* @param mixed $text
*
* @return bool
*/
public static function ctype_upper($text)
{
$text = self::convert_int_to_char_for_ctype($text, __FUNCTION__);
return \is_string($text) && '' !== $text && !preg_match('/[^A-Z]/', $text);
}
/**
* Returns TRUE if every character in text is a hexadecimal 'digit', that is a decimal digit or a character from [A-Fa-f] , FALSE otherwise.
*
* @see https://php.net/ctype-xdigit
*
* @param mixed $text
*
* @return bool
*/
public static function ctype_xdigit($text)
{
$text = self::convert_int_to_char_for_ctype($text, __FUNCTION__);
return \is_string($text) && '' !== $text && !preg_match('/[^A-Fa-f0-9]/', $text);
}
/**
* Converts integers to their char versions according to normal ctype behaviour, if needed.
*
* If an integer between -128 and 255 inclusive is provided,
* it is interpreted as the ASCII value of a single character
* (negative values have 256 added in order to allow characters in the Extended ASCII range).
* Any other integer is interpreted as a string containing the decimal digits of the integer.
*
* @param mixed $int
* @param string $function
*
* @return mixed
*/
private static function convert_int_to_char_for_ctype($int, $function)
{
if (!\is_int($int)) {
return $int;
}
if ($int < -128 || $int > 255) {
return (string) $int;
}
if (\PHP_VERSION_ID >= 80100) {
@trigger_error($function.'(): Argument of type int will be interpreted as string in the future', \E_USER_DEPRECATED);
}
if ($int < 0) {
$int += 256;
}
return \chr($int);
}
}

View File

@@ -0,0 +1,19 @@
Copyright (c) 2018-present Fabien Potencier
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is furnished
to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
THE SOFTWARE.

Some files were not shown because too many files have changed in this diff Show More