Skip to content

Commit

Permalink
Internationalized domain name (IDN) support
Browse files Browse the repository at this point in the history
  • Loading branch information
alexeyshockov committed Apr 14, 2019
1 parent fa74540 commit 76456d6
Show file tree
Hide file tree
Showing 6 changed files with 131 additions and 2 deletions.
4 changes: 3 additions & 1 deletion composer.json
Original file line number Diff line number Diff line change
Expand Up @@ -28,11 +28,13 @@
},
"require-dev": {
"ext-curl": "*",
"ext-intl": "*",
"phpunit/phpunit": "^4.8.35 || ^5.7 || ^6.4 || ^7.0",
"psr/log": "^1.1"
},
"suggest": {
"psr/log": "Required for using the Log middleware"
"psr/log": "Required for using the Log middleware",
"ext-intl": "Required for Internationalized Domain Name (IDN) support"
},
"config": {
"sort-packages": true
Expand Down
25 changes: 25 additions & 0 deletions docs/request-options.rst
Original file line number Diff line number Diff line change
Expand Up @@ -553,6 +553,31 @@ http_errors
default when creating a handler with ``GuzzleHttp\default_handler``.


idn_conversion
---

:Summary: Set to ``false`` to disable Internationalized Domain Name (IDN) to
ASCII conversion.
:Types:
- bool
- int
:Default: ``true`` if ``intl`` extension is available, ``false`` otherwise
:Constant: ``GuzzleHttp\RequestOptions::IDN_CONVERSION``

.. code-block:: php
$client->request('GET', 'https://яндекс.рф');
// яндекс.рф is translated to internally
$res = $client->request('GET', 'https://яндекс.рф', ['idn_conversion' => false]);
// The domain part (яндекс.рф) stays unmodified
Also a combination of IDNA_* constants (except IDNA_ERROR_*) also can be used
for precise control of the IDN support (see ``$options`` parameter in
`idn_to_ascii() <https://www.php.net/manual/en/function.idn-to-ascii.php>`_
documentation for more details).


json
----

Expand Down
36 changes: 36 additions & 0 deletions src/Client.php
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
namespace GuzzleHttp;

use GuzzleHttp\Cookie\CookieJar;
use GuzzleHttp\Exception\InvalidArgumentException;
use GuzzleHttp\Promise;
use GuzzleHttp\Psr7;
use Psr\Http\Message\UriInterface;
Expand Down Expand Up @@ -147,6 +148,38 @@ private function buildUri($uri, array $config)
$uri = Psr7\UriResolver::resolve(Psr7\uri_for($config['base_uri']), $uri);
}

if ($uri->getHost() && isset($config['idn_conversion']) && ($config['idn_conversion'] !== false)) {
$idnOptions = ($config['idn_conversion'] === true) ? IDNA_DEFAULT : $config['idn_conversion'];

$asciiHost = idn_to_ascii($uri->getHost(), $idnOptions, INTL_IDNA_VARIANT_UTS46, $info);
if ($asciiHost === false) {
$errorBitSet = isset($info['errors']) ? $info['errors'] : 0;

$errorConstants = array_filter(array_keys(get_defined_constants()), function ($name) {
return substr($name, 0, 11) === 'IDNA_ERROR_';
});

$errors = [];
foreach ($errorConstants as $errorConstant) {
if ($errorBitSet & constant($errorConstant)) {
$errors[] = $errorConstant;
}
}

$errorMessage = 'IDN conversion failed';
if ($errors) {
$errorMessage .= ' (errors: ' . implode(', ', $errors) . ')';
}

throw new InvalidArgumentException($errorMessage);
} else {
if ($uri->getHost() !== $asciiHost) {
// Replace URI only if the ASCII version is different
$uri = $uri->withHost($asciiHost);
}
}
}

return $uri->getScheme() === '' && $uri->getHost() !== '' ? $uri->withScheme('http') : $uri;
}

Expand All @@ -165,6 +198,9 @@ private function configureDefaults(array $config)
'cookies' => false
];

// idn_to_ascii() is a part of ext-intl and might be not available
$defaults['idn_conversion'] = function_exists('idn_to_ascii');

// Use the standard Linux HTTP_PROXY and HTTPS_PROXY if set.

// We can only trust the HTTP_PROXY environment variable in a CLI
Expand Down
8 changes: 8 additions & 0 deletions src/RequestOptions.php
Original file line number Diff line number Diff line change
Expand Up @@ -132,6 +132,14 @@ final class RequestOptions
*/
const HTTP_ERRORS = 'http_errors';

/**
* idn: (bool|int, default=true) Set to false to disable exceptions
* when a non- successful HTTP response is received. By default,
* exceptions will be thrown for 4xx and 5xx responses. This option only
* works if your handler has the `httpErrors` middleware.
*/
const IDN_CONVERSION = 'idn_conversion';

/**
* json: (mixed) Adds JSON data to a request. The provided value is JSON
* encoded and a Content-Type header of application/json will be added to
Expand Down
55 changes: 54 additions & 1 deletion tests/ClientTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -10,8 +10,8 @@
use GuzzleHttp\Psr7\Request;
use GuzzleHttp\Psr7\Response;
use GuzzleHttp\Psr7\Uri;
use Psr\Http\Message\ResponseInterface;
use PHPUnit\Framework\TestCase;
use Psr\Http\Message\ResponseInterface;

class ClientTest extends TestCase
{
Expand Down Expand Up @@ -674,4 +674,57 @@ public function testHandlerIsCallable()
{
new Client(['handler' => 'not_cllable']);
}

/**
* Unfortunately, it's hard to test the opposite.
*
* @requires extension intl
*/
public function testIdnSupportIsEnabledByDefaultIfIntlExtensionIsAvailable()
{
$mockHandler = new MockHandler([new Response()]);
$client = new Client(['handler' => $mockHandler]);

$config = $client->getConfig();

$this->assertTrue($config['idn_conversion']);
}

public function testIdnIsTranslatedToAsciiWhenSupportIsEnabled()
{
$mockHandler = new MockHandler([new Response()]);
$client = new Client(['handler' => $mockHandler]);

$client->request('GET', 'https://яндекс.рф/images', ['idn_conversion' => true]);

$request = $mockHandler->getLastRequest();

$this->assertSame('https://xn--d1acpjx3f.xn--p1ai/images', (string) $request->getUri());
$this->assertSame('xn--d1acpjx3f.xn--p1ai', (string) $request->getHeaderLine('Host'));
}

public function testIdnStaysTheSameWhenSupportIsDisabled()
{
$mockHandler = new MockHandler([new Response()]);
$client = new Client(['handler' => $mockHandler]);

$client->request('GET', 'https://яндекс.рф/images', ['idn_conversion' => false]);

$request = $mockHandler->getLastRequest();

$this->assertSame('https://яндекс.рф/images', (string) $request->getUri());
$this->assertSame('яндекс.рф', (string) $request->getHeaderLine('Host'));
}

/**
* @expectedException \GuzzleHttp\Exception\InvalidArgumentException
* @expectedExceptionMessage IDN conversion failed (errors: IDNA_ERROR_LEADING_HYPHEN)
*/
public function testExceptionOnInvalidIdn()
{
$mockHandler = new MockHandler([new Response()]);
$client = new Client(['handler' => $mockHandler]);

$client->request('GET', 'https://-яндекс.рф/images', ['idn_conversion' => true]);
}
}
5 changes: 5 additions & 0 deletions tests/bootstrap.php
Original file line number Diff line number Diff line change
@@ -1,4 +1,9 @@
<?php

namespace {
setlocale(LC_ALL, 'C');
}

namespace GuzzleHttp\Test {
require __DIR__ . '/../vendor/autoload.php';
require __DIR__ . '/Server.php';
Expand Down

0 comments on commit 76456d6

Please sign in to comment.