HTTP-клиент

Дата обновления перевода 2023-01-16

HTTP-клиент

Установка

Компонент HttpClient - это низкоуровневый HTTP-клиент с поддержкой как оберток PHP-стримов, так и cURL. Он предоставляет инструменты для потребления API и поддерживает синхронные и асинхронные операции. Вы можете установить его с помощью:

1
$ composer require symfony/http-client

Базовое использование

Используйте класс HttpClient, чтобы делать запросы. В фреймворке Symfony, этот класс доступен, как сервис http_client. Этот сервис будет автоматически смонтирован при вводе подсказки HttpClientInterface:

  • Framework Use
  • Standalone Use
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
use Symfony\Contracts\HttpClient\HttpClientInterface;

class SymfonyDocs
{
    private $client;

    public function __construct(HttpClientInterface $client)
    {
        $this->client = $client;
    }

    public function fetchGitHubInformation(): array
    {
        $response = $this->client->request(
            'GET',
            'https://api.github.com/repos/symfony/symfony-docs'
        );

        $statusCode = $response->getStatusCode();
        // $statusCode = 200
        $contentType = $response->getHeaders()['content-type'][0];
        // $contentType = 'application/json'
        $content = $response->getContent();
        // $content = '{"id":521583, "name":"symfony-docs", ...}'
        $content = $response->toArray();
        // $content = ['id' => 521583, 'name' => 'symfony-docs', ...]

        return $content;
    }
}

Tip

HTTP-клиент взаимодействует со многими распространенными абстракциями HTTP клиентов в PHP. Вы также можете использовать любую из этих абстракций, чтобы извлечь пользу из автомонтирований. См. Взаимосовместимость, чтобы узнать больше.

Конфигурация

HTTP-клиент содержит множество опций, которые могут вам понадобиться для полного контроля над тем, как выполняется запрос, включая предварительное разрешение DNS, параметры SSL, фиксацию публичных ключей и др. Они могут быть определены глобально в конфигурации (чтобы применить ко всем запросам), и к каждому запросу отдельно (что переоепределяет любую глобальную конфигурацию).

Вы можете сконфигурировать глобальные опции используя опцию default_options:

  • YAML
  • XML
  • PHP
  • Standalone Use
1
2
3
4
5
# config/packages/framework.yaml
framework:
    http_client:
        default_options:
            max_redirects: 7

Вы также можете использовать метод withOptions(), чтобы извлечь новый экземпляр клиента с новыми опциями по умолчанию:

1
2
3
4
$this->client = $client->withOptions([
    'base_uri' => 'https://...',
    'headers' => ['header-name' => 'header-value']
]);

Некоторые опции, описанные в этом руководстве:

Посмотрите полный справочник конфигурации http_client , чтобы узнать о всех опциях.

HTTP-клиент также имеет одну опцию конфигурации под названием max_host_connections, эта опция не может быть переопределена запросом:

  • YAML
  • XML
  • PHP
  • Standalone Use
1
2
3
4
5
# config/packages/framework.yaml
framework:
    http_client:
        max_host_connections: 10
        # ...

Определение клиента

Часто бывает так, что некоторые опции HTTP-клиента зависят от URL запроса (наприер, вы должны установить некоторые заголовки при запросе к GitHub API, но не к другим хостам). Если это ваш случай, компонент предоставляет определенных клиентов (используя ScopingHttpClient) для автоконфигурации HTTP-клиента на основе запрошенного URL:

  • YAML
  • XML
  • PHP
  • Standalone Use
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
# config/packages/framework.yaml
framework:
    http_client:
        scoped_clients:
            # только запросы, совпадающие с определением, будут использовать эти опции
            github.client:
                scope: 'https://api\.github\.com'
                headers:
                    Accept: 'application/vnd.github.v3+json'
                    Authorization: 'token %env(GITHUB_API_TOKEN)%'
                # ...

            # использование base_uri, относительных URL (например, request("GET", "/repos/symfony/symfony-docs"))
            # будет по умолчанию в этих опциях
            github.client:
                base_uri: 'https://api.github.com'
                headers:
                    Accept: 'application/vnd.github.v3+json'
                    Authorization: 'token %env(GITHUB_API_TOKEN)%'
                # ...

Вы можете определить несколько определений, чтобы каждый набор опций добавлялся только в случае, если запрошенный URL совпадает с одним из регулярных выражений, установленных опцией scope.

Если вы используете определенных клиентов в фреймворке Symfony, вы должны использовать любые из методов, определенных Symfony, чтобы выбрать конкретных сервис . Каждый клиент имеет уникальный сервис, названный по его конфигурации.

Каждый определенный клиент также определяет соответствующе названный псевдоним автомонтирования. Если вы, к примеру, используете Symfony\Contracts\HttpClient\HttpClientInterface $githubClient в качестве типа и имени аргумента, автомонтирование внедрит сервис github.client в ваши автоматически смонтированные классы.

Note

Прочтите документацию опции base_uri , чтобы узнать правила, применяемые при слиянии относительных URL в базовый URI определенного клиента.

Запросы

HTTP-клиент предоставляет единственный метод request() для выполнения всех видов HTTP-запросов:

1
2
3
4
5
6
7
8
9
10
11
$response = $client->request('GET', 'https://...');
$response = $client->request('POST', 'https://...');
$response = $client->request('PUT', 'https://...');
// ...

// вы можете добавить опции запроса (или переопределить глобальные), используя 3й аргумент
$response = $client->request('GET', 'https://...', [
    'headers' => [
        'Accept' => 'application/json',
    ],
]);

Ответы всегда асинхронны, чтобы вызов метода возвращался немедленно, вместо ожидания получения ответа:

1
2
3
4
5
6
7
8
9
// выполнение кода продолжается немедленно; он не ждет получения ответа
$response = $client->request('GET', 'http://releases.ubuntu.com/18.04.2/ubuntu-18.04.2-desktop-amd64.iso');

// получение заголовков ответа ждет их прибытия
$contentType = $response->getHeaders()['content-type'][0];

// попытка получить содержание ответа заблокирует выполнение до
// момента получения полного содержания ответа
$content = $response->getContent();

Этот компонент также поддерживает потоковую передачу ответов для полностью асинхронных приложений.

Аутентификация

HTTP-клиент поддерживает различные механизмы аутентификации. Они могут быть определены глобально в конфигурации (чтобы применить ко всем запросам) и к каждому запросу отдельно (что переопределяет любую глобальную аутентификацию):

  • YAML
  • XML
  • PHP
  • Standalone Use
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
# config/packages/framework.yaml
framework:
    http_client:
        scoped_clients:
            example_api:
                base_uri: 'https://example.com/'

                # Базовая HTTP аутентификация
                auth_basic: 'the-username:the-password'

                # Аутентификация HTTP Bearer (также называемая аутентификацией токена)
                auth_bearer: the-bearer-token

                # Аутентификация Microsoft NTLM
                auth_ntlm: 'the-username:the-password'
1
2
3
4
5
6
$response = $client->request('GET', 'https://...', [
    // используйте другую базовую HTTP аутентификацию только для этого запроса
    'auth_basic' => ['the-username', 'the-password'],

    // ...
]);

Note

Механизм аутентификации NTLM требует использования транспорта cURL. Используя HttpClient::createForBaseUri(), мы гарантируем, что идентификационные данные авторизации не будут отправлены никаким хостам, кроме https://example.com/.

Параметры строки запроса

Вы можете либо добавить их в начало запрошенного URL вручную, либо определить их в качестве ассоциативного массива через опцию query, которая будет объединена с URL:

1
2
3
4
5
6
7
8
// создает запрос HTTP GET к https://httpbin.org/get?token=...&name=...
$response = $client->request('GET', 'https://httpbin.org/get', [
    // эти значения автоматически шифруются перед добавлением их в URL
    'query' => [
        'token' => '...',
        'name' => '...',
    ],
]);

Заголовки

Используйте опцию headers, чтобы определить заголовки, по умолчанию добавленные ко всем запросам:

  • YAML
  • XML
  • PHP
  • Standalone Use
1
2
3
4
5
6
# config/packages/framework.yaml
framework:
    http_client:
        default_options:
            headers:
                'User-Agent': 'My Fancy App'

Вы также можете установить новые заголовки, или переопределить установленные по умолчанию, для конкретного запроса:

1
2
3
4
5
6
7
// этот заголовок включен только в этот запрос и переопределяет значение
// того же заголовка, если он определен глобально HTTP-клиентом
$response = $client->request('POST', 'https://...', [
    'headers' => [
        'Content-Type' => 'text/plain',
    ],
]);

Загрузка данных

Этот компонент предоставляет несколько методов загрузки данных, используя опцию body. Вы можете использовать обычные строки, замыкания, итерации и источники, и они будут автоматически обработаны при создании запросов:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
$response = $client->request('POST', 'https://...', [
    // определение данных, используя обычную строку
    'body' => 'raw data',

    // определение данных, используя массив параметров
    'body' => ['parameter1' => 'value1', '...'],

    // использование замыкания для генерирования загруженных данных
    'body' => function (int $size): string {
        // ...
    },

    // использование истоничка для получения данных из него
    'body' => fopen('/path/to/file', 'r'),
]);

При загрузке данных с помощью метода POST, если вы не хотите определять HTTP заголовок Content-Type ясно, Symfony предполагает, что вы загружаете данные формы, и добавлят обязательный заголовок 'Content-Type: application/x-www-form-urlencoded' за вас.

Когда опция body установлена, как замыкание, она будет вызвана несколько раз, прежде чем вернет пустую строку, сигнализирующую окончание тела. Каждый раз, замыкание должно вернуть строку, меньшую, чем была запрошена в качестве аргумента.

Генератор, или любое Traversable, также могут быть использованы вместо замыкания.

Tip

При загрузке полезной нагрузки JSON, используйте опцию json вместо body. Заданное содержание будет автоматически JSON-зашифровано, и запрос будет также автоматически добавлять Content-Type: application/json:

1
2
3
4
5
$response = $client->request('POST', 'https://...', [
    'json' => ['param1' => 'value1', '...'],
]);

$decodedPayload = $response->toArray();

Чтобы отправить форму с загруженными файлами, вы должны зашифровать тело, в соответствии с типом содержания multipart/form-data. Компонент Symfony Mime превращает это в несколько строчек кода:

1
2
3
4
5
6
7
8
9
10
11
12
use Symfony\Component\Mime\Part\DataPart;
use Symfony\Component\Mime\Part\Multipart\FormDataPart;

$formFields = [
    'regular_field' => 'some value',
    'file_field' => DataPart::fromPath('/path/to/uploaded/file'),
];
$formData = new FormDataPart($formFields);
$client->request('POST', 'https://...', [
    'headers' => $formData->getPreparedHeaders()->toArray(),
    'body' => $formData->bodyToIterable(),
]);

Tip

При использовании многомерных массивов, класс FormDataPart автоматически добавляет [key] в начало имени поля:

1
2
3
4
5
6
7
8
9
$formData = new FormDataPart([
    'array_field' => [
        'some value',
        'other value',
    ],
]);

$formData->getParts(); // Возвращает два экземпляра TextPart
                       // с именами "array_field[0]" и "array_field[1]"

Это поведение можно обойти, использоуя следующую структуру массива:

1
2
3
4
5
6
7
$formData = new FormDataPart([
    ['array_field' => 'some value'],
    ['array_field' => 'other value'],
]);

$formData->getParts(); // Возвращает два экземпляра TextPart
                       // оба с именем "array_field"

По умолчанию, HttpClient стримит содержание тела при их загрузке. Это может работать не со всеми серверами, что приведет к HTTP статус-коду 411 ("Необходимая длина"), так как нет заголовка Content-Length. Решение - превратить тело в строку с помощью следующего метода (что увеличит потребление памяти, если потоки большие):

1
2
3
4
$client->request('POST', 'https://...', [
    // ...
    'body' => $formData->bodyToString(),
]);

Если вам нужно добавить пользовательский HTTP-заголовок к загрузке, вы можете:

1
2
$headers = $formData->getPreparedHeaders()->toArray();
$headers[] = 'X-Foo: bar';

Куки

HTTP-клиент, предоставленный данным компонентом, не фиксирует состояние, но обработка куки требует хранилища фиксации состояния (потому как ответы могут обновлять куки, и они должны быть использованы для вытекающих запросов). Поэтому этот компонент не обрабатывает куки автоматически.

Вы можете либо обрабатывать куки самостоятельно, используя HTTP-заголовокё Cookie, или использовать компонент BrowserKit, который предоставляет эту функцию и безупречно интегрируется с компонентом HttpClient.

Перенаправления

По умолчанию, HTTP-клиент следует перенаправлениям (максмум 20-ти), при выполнении запроса. Используйте настройку max_redirects, чтобы сконфигурировать данное поведение (если количество перенаправлений больше, чем сконфигурированное значение, вы получите RedirectionException):

1
2
3
4
$response = $client->request('GET', 'https://...', [
    // 0 означает не следовать перенаправлениям
    'max_redirects' => 0,
]);

Повторная попытка неудачных запросов

Иногда, запросы терпят неудачу из-за проблем с сетью или временных ошибок сервера. HttpClient Symfony позволяет повторно пытаться обработать неудачные запросы автоматически, используя опцию retry_failed .

По умолчанию, неудачные запросы имеют до трех повторных попыток с растущим промежутком между попытками (первая попытка = 1 секунда; третья попытка: 4 секунды) и только для следующих HTTP статус-кодов: 423, 425, 429, 502 и 503 при использовании любого HTTP-метода, и для 500, 504, 507 и 510 при использовании HTTP метода idempotent.

Просмотрите полный список конфигурируемых опций retry_failed , чтобы узнать, как настроить каждую из них так, чтобы она соответствовала нуждам вашего приложения.

При использовании HttpClient вне приложения Symfony, используйте класс RetryableHttpClient, чтобы обернуть вашего изначального HTTP-клиента:

1
2
3
use Symfony\Component\HttpClient\RetryableHttpClient;

$client = new RetryableHttpClient(HttpClient::create());

RetryableHttpClient использует RetryStrategyInterface, чтобы решить, нужна ли запросу повторная попытка, и определить время ожидания между всеми повторными попытками.

HTTP-прокси

По умолчанию, этот компонент уважает стандартные переменные окружения, определенные вашей ОС, для направления HTTP-траффика через ваш локальный прокси. Это означает, что обычно тут нечего конфигурировать, для работы клиента с прокси, при условии, что эти переменные окружения сконфигурированы правильно.

Вы все еще можете устанавливать или переопределять эти настройки используя опции proxy и no_proxy:

  • proxy должна быть установлена как URL прокси http://...
  • no_proxy отключает прокси для списка хостов, разделенных запятыми, доступ к которым не нужен.

Прогресс обратного вызова

Предоставив вызываемое опции on_progress, вы можете отследить загрузки/выгрузки по мере их завершения. Этот обратный вызов гарантированно будет вызван при разрешении DNS, поступлении заголовков и завершению работы; кроме того, он вызывается когда загружаются или выгрудаются новые данные, как минимум раз в секунду:

1
2
3
4
5
6
7
$response = $client->request('GET', 'https://...', [
    'on_progress' => function (int $dlNow, int $dlSize, array $info): void {
        // $dlNow - количество уже скачанных байтов
        // $dlSize - общий размер загрузки, или -1, если это неизвестно
        // $info - это то, что вернет $response->getInfo() в данное конкретное время
    },
]);

Все исключения, вызванные обратным вызовом, будут обернуты в экземпляр TransportExceptionInterface и прервут запрос.

Сертификаты HTTPS

HttpClient использует хранилище сертификатов системы для валидации SSL-сертификатов (а браузеры используют собственные хранилища). При использовании самоподписанных сертивикатов во время разработки, рекомендуется создавать собственный авторитет сертификатов (CA) и добавлять его в хранилище вашей системы.

Как вариант, вы также можете отключить verify_host и verify_peer (см. http_client config reference ), но это не рекомендуется в производстве.

Работа с SSRF (подделка запросов стороны сервера)

SSRF позволяет хакеру вынудить приложение бекэнда делать HTTP-запросы к произвольному домену. Такие атаки также могут быть нацелены на внутренние хостинги и IP атакованного сервера.

Если вы используете HttpClient вместе с предоставленными пользователями URI, скорее всего хорошей идеей будет облачить его в NoPrivateNetworkHttpClient. Это гарантирует, что локальные сети будут недоступны HTTP-клиенту:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
use Symfony\Component\HttpClient\HttpClient;
use Symfony\Component\HttpClient\NoPrivateNetworkHttpClient;

$client = new NoPrivateNetworkHttpClient(HttpClient::create());
// ничего не меняется при запросе к публичным сетям
$client->request('GET', 'https://example.com/');

// однако, все запросы к приватным сетям теперь блокируются по умолчанию
$client->request('GET', 'http://localhost/');

// второй необязательный аргумент определяет сети для блокировки
// в этом примере, запросы с 104.26.14.0 до 104.26.15.255 приведут к исключению,
// но все другие запросы, включая другие внутренние сети, будут позволены
$client = new NoPrivateNetworkHttpClient(HttpClient::create(), ['104.26.14.0/23']);

Профилирование

Когда вы используете TraceableHttpClient, содержание ответа будет сохраняться в памяти и может истощить её.

Вы можете отключить это поведение, установив опцию extra.trace_content как false в ваших запосах:

1
2
3
$response = $client->request('GET', 'https://...', [
    'extra' => ['trace_content' => false],
]);

Эта настройка не повлияет на других клиентов.

Производительность

Компонент создан для максимальной HTTP-производительности. Он совместим с HTTP/2 и с созданием пересекающихся асинхронных потоковых и мультиплексных запросов/ответов. Даже при регулярных синхронных вызовах, он позволяет оставлять соединения с удаленными хостами открытыми между запросами, что улучшает производительность, сохраняя повторяющиеся DNS разрешение, SSL переговоры, и т.д. Чтобы пользоваться всеми этими преимуществами, неохобимо расширение cURL.

Подключение поддержки cURL

Этот компонент поддерживает как нативные PHP-потоки, так и cURL, чтобы делать HTTP-запросы. Хотя они взаимозаменяемы и предоставляют одинаковые функции, включая пересекающиеся запросы, HTTP/2 поддерживается только при использовании cURL.

HttpClient::create() выбирает транспорт cURL, если включено PHP-расширение cURL, и использует PHP-потоки - если нет. Если вы предпочитаете ясно выбирать транспорт, используйте следующие классы для создания клиента:

1
2
3
4
5
6
7
8
use Symfony\Component\HttpClient\CurlHttpClient;
use Symfony\Component\HttpClient\NativeHttpClient;

// использует нативные PHP-потоки
$client = new NativeHttpClient();

// использует PHP-расширение cURL
$client = new CurlHttpClient();

При использовании этого компонента в полностековом приложении Symfony, данное поведение невозможно сконфигурировать, и cURL будет использован автоматически, если PHP-расширение cURL установлено и подключено. В других случаях будут использованы нативные PHP-потоки.

Конфигурация опций CurlHttpClient

PHP позволяет конфигурировать множество опций cURL через функцию curl_setopt. Для того, чтобы компонент был более портативным без использования cURL, CurlHttpClient использует только некоторые из этих опций (и они игнорируются во всех других клиентах).

Добавьте опцию extra.curl в вашей конфигурации, чтобы передать эти дополнительные опции:

1
2
3
4
5
6
7
8
9
10
11
12
use Symfony\Component\HttpClient\CurlHttpClient;

$client = new CurlHttpClient();

$client->request('POST', 'https://...', [
    // ...
    'extra' => [
        'curl' => [
            CURLOPT_IPRESOLVE => CURL_IPRESOLVE_V6,
        ],
    ],
]);

Note

Некоторые опции cURL невозможно переопределить (например, из-за безопасности потоков) и вы получит исключение при попытке их переопределить.

HTTP-сжатие

HTTP-заголовок Accept-Encoding: gzip добавляется автоматически, если:

  • При использовании клиента cURL: cURL был скомпилирован с поддержкой ZLib (см. php --ri curl)
  • При использовании нативного клиента HTTP: устанавливается PHP-расширение Zlib

Если сервер не отвечает ответом gzip, он дешифруется прорзрачно. Чтобы отключить сжатие HTTP, отправьте HTTP-заголовок Accept-Encoding: identity.

Шифрование трансфера кусками включается автоматически, если это поддеживает и ваше время прогона PHP и удалённый сервер.

Поддержка HTTP/2

При запросе URL https URL, HTTP/2 включается по умолчанию, если установлен один из следующих инструментов:

  • Пакет libcurl версии 7.36 или выше;
  • Пакет Packagist amphp/http-client версии 4.2 или выше.

Чтобы форсировать HTTP/2 для URL http, вам нужно ясно его включить через опцию http_version:

  • YAML
  • XML
  • PHP
  • Standalone Use
1
2
3
4
5
# config/packages/framework.yaml
framework:
    http_client:
        default_options:
            http_version: '2.0'

Поддержка для PUSH HTTP/2 работает сразу после установки, если libcurl >= 7.61 используется с PHP >= 7.2.17 / 7.3.4: пуш-ответы помещаются во временный кеш и используются, когда запускается последующий запрос для соответствующих URL.

Обработка ответов

Ответ, который возвращается всеми HTTP-клиентами, - это объект типа ResponseInterface, предоставляющий следующие методы:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
$response = $client->request('GET', 'https://...');

// получает HTTP статус-код ответа
$statusCode = $response->getStatusCode();

// получает HTTP-заголовки в виде строки[][] с именами заголовков в нижнем регистре
$headers = $response->getHeaders();

// получает тело ответа в виде строки
$content = $response->getContent();

// приводит JSON-содержание ответа в PHP-массив
$content = $response->toArray();

// приводит содержание ответа в источник PHP-потока
$content = $response->toStream();

// отменяет запрос/ответ
$response->cancel();

// возвращает информацию, исходяющую из слоя транспорта, вроде "response_headers",
// "redirect_count", "start_time", "redirect_url", и т.д.
$httpInfo = $response->getInfo();

// вы также можете получить индивидуальную информацию
$startTime = $response->getInfo('start_time');
// например, это вернет URL финального ответа (разрешая перенаправления при необходимости)
$url = $response->getInfo('url');

// возвращает детальные логи о запросах и ответах HTTP-транзакции
$httpLogs = $response->getInfo('debug');

Note

$response->getInfo() является неблокирующим: он возвращает живую информацию об ответе. Некоторая из них может быть еще неизвестна (к примеру, http_code), во время ее вызова.

Потоковые ответы

Вызовите метод stream() HTTP-клиента, чтобы получать куски ответа последовательно, а не ждать ответа целиком:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
$url = 'https://releases.ubuntu.com/18.04.1/ubuntu-18.04.1-desktop-amd64.iso';
$response = $client->request('GET', $url);

// Ответы ленивы: этот код выполняется сразу после получения заголовков
if (200 !== $response->getStatusCode()) {
    throw new \Exception('...');
}

// получите содержание ответа кусками и сохраните их в файл
// куски ответа реализуют Symfony\Contracts\HttpClient\ChunkInterface
$fileHandler = fopen('/ubuntu.iso', 'w');
foreach ($client->stream($response) as $chunk) {
    fwrite($fileHandler, $chunk->getContent());
}

Note

По умолчанию, тело ответов text/*, JSON и XML буферизуются в локальном потоке php://temp. Вы можете контролировать это поведение, используя опцию buffer: установите ее как true/false чтобы включить/отключить буферизацию, или как замыкание, которое должно вернуть то же самое, основываясь на полученных в качестве аргументы заголовках.

Отмена ответов

Чтобы прервать запрос (например, потому что он не был выполнен вовремя, или если вы хотите извлечь только первые байти информации и т.д.), вам нужно либо использовать метод cancel() ResponseInterface:

1
$response->cancel();

Либо вызвать исключение из прогрессивного обратного вызова:

1
2
3
4
5
6
7
$response = $client->request('GET', 'https://...', [
    'on_progress' => function (int $dlNow, int $dlSize, array $info): void {
        // ...

        throw new \MyException();
    },
]);

Исключение будет обернуто в экземпляр TransportExceptionInterface, и прервет запрос.

В случае, если ответ был отменен используя $response->cancel(), $response->getInfo('canceled') вернет true.

Обработка исключений

Существует три типа исключений, все из которых реализуют ExceptionInterface:

  • Исключения, реализующие HttpExceptionInterface, вызываются, когда ваш код не обрабатывает статус-коды в диапазоне 300-599.
  • Исключения, реализующие TransportExceptionInterface, вызываются, когда возникает ошибка низлежащего уровня.
  • Исключения, реализующие DecodingExceptionInterface, вызываются, когда тип содержания не может быть зашифрован в ожидаемый вид.

Когда HTTP статус-код ответа находится в диапазоне 300-599 (т.е. 3xx, 4xx или 5xx) ваш код должен его обработать. Если вы этого не сделаете, методы getHeaders(), getContent() и toArray() вызовут соответствующие исключения, все из которых реализуют HttpExceptionInterface:

Чтобы избежать этого исключения и самостоятельно разобраться со статус-кодами 300-599, передайте false в качестве необязательного аргумента каждому из этих методов, например, $response->getHeaders(false);.

Если вы вообще не вызовете ни один из этих 3 методов, исключение все равно будет вызываться при деструкции объекта $response.

Вызова $response->getStatusCode() достаточно для отключения такого поведения (но затем не забывайте самостоятельно проверять статус-код).

Хотя ответы ленивы, их деструктор будет всегда ждать возвращения заголовков. Это означает, что следующий запрос будет выполнен; и если, к примеру, вернется 404, будет вызвано исключение:

1
2
3
4
// так как возвращенное значение не назначено переменной, деструктор
// возвращенного ответа будет вызван немедленно, и вызовет исключение, если
// статус-код будет в диапазоне 300-599
$client->request('POST', 'https://...');

Это, в свою очередь, означает, что неопределенные ответы будут откатываться до синхронных запросов. Если вы хотите сделать эти запросы параллельными, вы можете хранить их соответствующие ответы в массиве:

1
2
3
4
5
6
7
8
$responses[] = $client->request('POST', 'https://.../path1');
$responses[] = $client->request('POST', 'https://.../path2');
// ...

// Эта строчка запустит деструктор всех ответов, хранящихся в массиве;
// они будут выполнены параллельно, а исключение будет вызвано в случае,
// если будет возвращен статус-код в диапазоне 300-599
unset($responses);

Это поведение, предоставленное во время деструкции, является частью безаварийного проектирования компонента. Ни одна ошибка не будет незамеченной: если вы не напишете код для обработки ошибок, исключения уведомят вас при необходимости. С другой стороны, если вы напишите код обработки ошибок (вызвав $response->getStatusCode()), вы откажетесь от этих резервных механизмов, так как деструктору не будет что делать.

Параллельные запросы

Благодаря тому, что ответы ленивы, запросы всегда обрабатываются параллельно. В достаточно быстрой сети, следующий код делает 379 запросов менее, чем за полсекунды, когда используется cURL:

1
2
3
4
5
6
7
8
9
10
$responses = [];
for ($i = 0; $i < 379; ++$i) {
    $uri = "https://http2.akamai.com/demo/tile-$i.png";
    $responses[] = $client->request('GET', $uri);
}

foreach ($responses as $response) {
    $content = $response->getContent();
    // ...
}

Как вы можете увидеть в первом цикле "for", запросы выпускаются, но еще не потребляются. Это фокус параллельности: запросы должны быть вначале отправлены, а прочитаны позже. Это позволит клиенту мониторить все ожидающие запросы в то время как ваш код ждет конкретного, как делается в каждой итерации вышенаписанного цикла "foreach".

Note

Максимальное количество параллельных запросов, которое вы можете выполнить, зависит от ресурсов вашей машины (например, ваша ОС может ограничивать количество одновременных чтений файла, хранящего файл сертификатов). Делайте запросы партиями, чтобы избежать проблем.

Мультиплексирование ответов

Если вы снова посмотрите на отрывок кода наверху, ответы читаются в порядке запросов. Но может второй ответ пришел до первого. Полностью асинхронные опарции требуют возможности обработки ответов в любом порядке, в котором они возвращаются.

Для того, чтобы сделать это, метод stream() HTTP-клиентов принимает список ответов для мониторинга. Как было упомянуто ранее , этот метод создает куски ответов по мере их поступления из сети. Заменив "foreach" в отрывке на это, код станет полностью асинхронным:

1
2
3
4
5
6
7
8
9
10
11
12
foreach ($client->stream($responses) as $response => $chunk) {
    if ($chunk->isFirst()) {
        // заголовки $response только что прибыли
        // $response->getHeaders() теперь является неблокирующим вызовом
    } elseif ($chunk->isLast()) {
        // полное содержание $response только что было завершено
        // $response->getContent() теперь является неблокирующим вызовом
    } else {
        // $chunk->getContent() вернет кусок
        // тела ответа, который только что прибыл
    }
}

Tip

Используйте опцию user_data в сочетании с $response->getInfo('user_data') для отслеживания идентичности ответа в ваших циклах foreach.

Работа с тайм-аутами соединения

Этот компонент позволяет работать как с таймаутами запросов, так и ответов.

Тайм-аут может произойти когда, к примеру, разрешение DNS занимает слишком много времени, когда соединение TCP не может быть открыто в заданное время, или когда содержание ответа слишком надолго находится в паузе. Это может быть сконфигурировано с помощью опции запроса timeout:

1
2
3
// Будет выпущен TransportExceptionInterface, если ничего
// не произойдет за 2.5 секунды при доступе из $response
$response = $client->request('GET', 'https://...', ['timeout' => 2.5]);

Настройка PHP ini default_socket_timeout используется, если опция не установлена.

Опция может быть переопределена с использованием второго аргумента метода stream(). Это позволяет мониторить несколько ответов одноврменно и применять таймаут ко всем, находящимся в группе. Если все ответы станут неактивными на заданное количество времени, метод создаст специальный кусок, чей isTimeout() вернет true:

1
2
3
4
5
foreach ($client->stream($responses, 1.5) as $response => $chunk) {
    if ($chunk->isTimeout()) {
        // $response был просрочен больше, чем на 1.5 секунды
    }
}

Тайм-аут не обязательно является ошибкой: вы можете решить снова запустить поток ответа и получить оставшееся содержание, которое может вернуться в новом таймауте, и т.д.

Tip

Передача 0 в качестве таймаута позволяет мониторить ответы неблокирующим образом.

Note

Тайм-ауты контролируют сколько кто-то готов ждать, во время бездействия HTTP-транзакции. Большие ответы могут занимать столько времени, сколько им нужно для завершения, если они остаются активными во время передачи и не делают паузу дольше, чем указано.

Используйте опцию max_duration, чтобы ограничить время, которое может занимать полный запрос/ответ.

Работа с ошибками сети

Ошибки сети (сломанные трубы, неудача разрешения DNS и т.д.) вызываются как экземпляры TransportExceptionInterface.

Для начала, вам не обязательно обрабатывать их: вы можете позволить ошибкам собираться в вашем общем стеке обработке исключений, и это может быть нормально в большинстве случаев использования.

Если же вы хотите обработать их, вот, что вам нужно знать:

Чтобы поймать ошибки, вам нужно обернуть вызовы в $client->request(), но также вызовы к любым методам возвращенных ответов. Так как ответы ленивы, ошибки сети могут возникать и во время вызова, к примеру, getStatusCode():

1
2
3
4
5
6
7
8
9
10
11
use Symfony\Contracts\HttpClient\Exception\TransportExceptionInterface;

// ...
try {
    // обе строчки могут потенциально вызвать
    $response = $client->request(...);
    $headers = $response->getHeaders();
    // ...
} catch (TransportExceptionInterface $e) {
    // ...
}

Note

Так как $response->getInfo() является неблокирующим, он не должен вызывать ошибку.

При мульиплексировании ответов, вы можете работать с ошибками для конкретных потоков, отлавливая TransportExceptionInterface в цикле foreach:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
foreach ($client->stream($responses) as $response => $chunk) {
    try {
        if ($chunk->isTimeout()) {
            // ... решите, что делать, когда произойдет таймаут
            // если вы хотите остановить ответ, который вызвал таймаут, не забудьте
            // вызывать $response->cancel(), иначе деструктор ответа
            // попробует завершить его еще раз
        } elseif ($chunk->isFirst()) {
            // если вы хотите проверить статус-код, вы должны сделать это по прибытию
            // первого куска, используя $response->getStatusCode();
            // если вы этого не сделаете, это может запустить HttpExceptionInterface
        } elseif ($chunk->isLast()) {
            // ... сделайте что-то с $response
        }
    } catch (TransportExceptionInterface $e) {
        // ...
    }
}

Кеширование запросов и ответов

Данный компонент предоставляет декоратор CachingHttpClient, который позволяет кешировать ответы и подавать их из локального хранилища по следующим запросам. Реализация по сути использует преимущества класса HttpCache, поэтому в вашем приложении должен быть установлен компонент HttpKernel:

1
2
3
4
5
6
7
8
9
10
use Symfony\Component\HttpClient\CachingHttpClient;
use Symfony\Component\HttpClient\HttpClient;
use Symfony\Component\HttpKernel\HttpCache\Store;

$store = new Store('/path/to/cache/storage/');
$client = HttpClient::create();
$client = new CachingHttpClient($client, $store);

// это не дойдет до сети, если источник уже находится в кеше
$response = $client->request('GET', 'https://example.com/cacheable-resource');

CachingHttpClient принимает третий аргумент, чтобы установить опции HttpCache.

Потребление событий, отправленных сервером

События, отправленные сервером - это интернет-стандарт для загрузки данных на веб-страницы. Его API JavaScript построен вокруг объекта EventSource, который слушает события, отправленные с некоторого URL. События - это потоки данных (поданные с MIME-типом text/event-stream) со следующим форматом:

1
2
3
4
5
6
data: Это первое сообщение.

data: Это второе сообщение, оно
data: имеет две строчки.

data: Это третье сообщение.

HTTP-клиент Symfony предоставляет реализацию EventSource для потребления этих событий, отправленных сервером. Используйте EventSourceHttpClient, чтобы обернуть ваш HTTP-клиент, открыть соединение с сервером, который отвечает типом содержания text/event-stream, и потреблять поток следующим образом:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
use Symfony\Component\HttpClient\Chunk\ServerSentEvent;
use Symfony\Component\HttpClient\EventSourceHttpClient;

// второй необязательный аргумент - время повторного соединения в секундах (по умолчанию = 10)
$client = new EventSourceHttpClient($client, 10);
$source = $client->connect('https://localhost:8080/events');
while ($source) {
    foreach ($client->stream($source, 2) as $r => $chunk) {
        if ($chunk->isTimeout()) {
            // ...
            continue;
        }

        if ($chunk->isLast()) {
            // ...

            return;
        }

        // это специальный кусок ServerSentEvent, содержащий отправленное сообщение
        if ($chunk instanceof ServerSentEvent) {
            // сделайте что-то с событием сервера ...
        }
    }
}

Взаимосовместимость

Компонент взаимосовместим с четырьмя разными абстракциями для HTTP-клиентов: Symfony Contracts, PSR-18, HTTPlug v1/v2 и нативными PHP-потоками. Если ваше приложение использует библиотеки, которые нуждаются в любой из них, компонент совместим с ними всеми. Они также пользуются преимуществами псевдонимов автомонтирования , когда используется пакет фреймворка .

Если вы пишете или содержите библиотеку, которая делает HTTP-запросы, вы можете отделить ее от любой конкретной реализации HTTP-клиента, кодируя в соответствии с Контрактами Symfony (рекомендовано), PSR-18 или HTTPlug v2.

Контракты Symfony

Интерфейсы, которые находятся в пакете symfony/http-client-contracts, определяют главные абстракции, реализованные компонентом. Точкой входа является HttpClientInterface. Это тот интерфейс, в соответствии с которым вам надо писать код, когда необходим клиент:

1
2
3
4
5
6
7
8
9
10
11
12
13
use Symfony\Contracts\HttpClient\HttpClientInterface;

class MyApiLayer
{
    private $client;

    public function __construct(HttpClientInterface $client)
    {
        $this->client = $client;
    }

    // [...]
}

Все опции запроса, упомянутые выше (например, управление таймаутом), также определяются в текстовом пояснении интерфейса, поэтому все соответствующие реализации (вроде данного компонента) гарантированно их предоставляют. Это существенное отличие от других абстракций, которые не предоставляют ничего, связанного с самим транспортом.

Другая значимая функция, предоставленная Контрактами Symfony, - асинхронность/ мультиплексирование, что было описано в предыдущих разделах.

PSR-18 и PSR-17

Данный компонент реализует спецификации PSR-18 (HTTP-клиент) через класс Psr18Client, который является адаптером, превращающим Symfony HttpClientInterface в PSR-18 ClientInterface. Этот класс также реализует соответствующие методы PSR-17, чтобы облегчить создание объектов запроса.

Чтобы использовать его, вам нужен пакет psr/http-client и реализация PSR-17:

1
2
3
4
5
6
7
8
9
10
# устанавливает PSR-18 ClientInterface
$ composer require psr/http-client

# устанавливает действенную реализацию ответа и фабрики потоков
# c псевдонимами автомонтирования, предоставленными Symfony Flex
$ composer require nyholm/psr7

# как вариант, установите пакет php-http/discovery, чтобы автоматически обнаруживать
# любые уже установленные реализации от общих поставщиков:
# composer require php-http/discovery

Теперь вы можете делать HTTP-запросы с клиентом PSR-18 следующим образом:

  • Framework Use
  • Standalone Use
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
use Psr\Http\Client\ClientInterface;

class Symfony
{
    private $client;

    public function __construct(ClientInterface $client)
    {
        $this->client = $client;
    }

    public function getAvailableVersions(): array
    {
        $request = $this->client->createRequest('GET', 'https://symfony.com/versions.json');
        $response = $this->client->sendRequest($request);

        return json_decode($response->getBody()->getContents(), true);
    }
}

HTTPlug

Спецификация HTTPlug v1 была опубликована до PSR-18 и была вытеснена ею. Таким образом, вам не стоит использовать ее в свеженаписанном коде. Компонент все еще взаимосовместим с библиотеками, которые ее требуют, благодаря классу HttplugClient. Схоже с Psr18Client, реализующим части PSR-17, HttplugClient также реализует методы фабрики, определенные в связанном пакете php-http/message-factory.

1
2
3
4
5
6
7
8
9
# Давайте представим, что php-http/httplug уже требуется библиотеке, которую вы хотите использовать

# устанавливает эффективную реализацию ответа и фабрики потоков
# с псевдонимами автомонтирования, предоставленными Symfony Flex
$ composer require nyholm/psr7

# как вариант, установите пакет php-http/discovery, чтобы автоматически обнаруживать
# любые уже установленные реализации от общих поставщиков:
# composer require php-http/discovery

Предположим, что вы хотите инстанциировать класс со следующим конструктором, который требует зависимостей HTTPlug:

1
2
3
4
5
6
7
8
9
10
11
12
13
use Http\Client\HttpClient;
use Http\Message\RequestFactory;
use Http\Message\StreamFactory;

class SomeSdk
{
    public function __construct(
        HttpClient $httpClient,
        RequestFactory $requestFactory,
        StreamFactory $streamFactory
    )
    // [...]
}

Так как HttplugClient реализует три интерфейса, вы можете использовать его так:

1
2
3
4
use Symfony\Component\HttpClient\HttplugClient;

$httpClient = new HttplugClient();
$apiClient = new SomeSdk($httpClient, $httpClient, $httpClient);

Если вы хотите работать с обещаниями, HttplugClient также реализует интерфейс HttpAsyncClient. Чтобы использовать его, вам нужно установить пакет guzzlehttp/promises:

1
$ composer require guzzlehttp/promises

У вас всё готово:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
use Psr\Http\Message\ResponseInterface;
use Symfony\Component\HttpClient\HttplugClient;

$httpClient = new HttplugClient();
$request = $httpClient->createRequest('GET', 'https://my.api.com/');
$promise = $httpClient->sendAsyncRequest($request)
    ->then(
        function (ResponseInterface $response) {
            echo 'Got status '.$response->getStatusCode();

            return $response;
        },
        function (\Throwable $exception) {
            echo 'Error: '.$exception->getMessage();

            throw $exception;
        }
    );

// когда вы закончите с отправкой нескольких запросов,
// вы должны подождать, чтобы они закончились параллельно

// подождите разрешения конкретного обещания, мониторя все
$response = $promise->wait();

// подождите максимум 1 секунду для разрешения повисших обещаний
$httpClient->wait(1.0);

// подождите разрешения всех оставшихся обещаний
$httpClient->wait();

Нативные PHP-потоки

Ответы, реализующие ResponseInterface, могут быть образованы в нативные PHP-потоки с помощью createResource(). Это позволяет использовать их там, где необходимы нативные PHP-потоки:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
use Symfony\Component\HttpClient\HttpClient;
use Symfony\Component\HttpClient\Response\StreamWrapper;

$client = HttpClient::create();
$response = $client->request('GET', 'https://symfony.com/versions.json');

$streamResource = StreamWrapper::createResource($response, $client);

// в качестве альтернативы и противоположности предыдущему, это возвращает
// источник, по которому можно проводить поиск и потенциально можно сделать stream_select()
$streamResource = $response->toStream();

echo stream_get_contents($streamResource); // outputs the content of the response

// далее, если вам понадобится, вы можете получить доступ к ответу из потока
$response = stream_get_meta_data($streamResource)['wrapper_data']->getResponse();

Расширяемость

Если вы хотите расширить поведение базового HTTP-клиента, вы можете использовать декорирование сервисов:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
class MyExtendedHttpClient implements HttpClientInterface
{
    private $decoratedClient;

    public function __construct(HttpClientInterface $decoratedClient = null)
    {
        $this->decoratedClient = $decoratedClient ?? HttpClient::create();
    }

    public function request(string $method, string $url, array $options = []): ResponseInterface
    {
        // обработайте и/или измените $method, $url и/или $options, как вам необходимо
        $response = $this->decoratedClient->request($method, $url, $options);

        // если здесь вы вызовете любой метод в $response, HTTP-запрос не будет
        // асинхронным; см. ниже, чтобы увидеть способ лучше

        return $response;
    }

    public function stream($responses, float $timeout = null): ResponseStreamInterface
    {
        return $this->decoratedClient->stream($responses, $timeout);
    }
}

Декоратор вроде этого полезен в случаях, когда обработки аргументов запросов недостаточно. Декорировав опцию on_progress, вы можете даже реализовать базовый мониторинг ответа. Однако, так как вызов методов ответов форсирует синхронные операции, сделав это внутри request(), вы нарушите асинхронность.

Решением будет также декорировать сам объект ответа. TraceableHttpClient и TraceableResponse являются хорошими примерами для начала.

Для того, чтобы помочь с написанием более продвинутых процессоров ответов, компонент предоставляет AsyncDecoratorTrait. Эта черта позволяет обрабатывать поток кусков по мере их возвращения из сети:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
class MyExtendedHttpClient implements HttpClientInterface
{
    use AsyncDecoratorTrait;

    public function request(string $method, string $url, array $options = []): ResponseInterface
    {
        // обработать и/или изменить $method, $url и/или $options как необходимо

        $passthru = function (ChunkInterface $chunk, AsyncContext $context) {
            // сделайте с кусками, что хотите, например, разделите
            // их на меньшие, сгруппируйте, пропустите некоторые и т.д.

            yield $chunk;
        };

        return new AsyncResponse($this->client, $method, $url, $options, $passthru);
    }
}

Так как черта уже реализует конструктор и метод stream(), вам не нужно их добавлять. Метод request() все еще должен быть определен; он должен возвращать AsyncResponse.

Пользовательская обработка кусков должна происходить в $passthru: этот генератор - это то, где вам нужно писать вашу логику. Он будет вызыван для каждого куска, созданного подлежащим клиентом. $passthru, который ничего не делает, просто создаст $chunk;. Вы также можете создать измененный кусок, разделить кусок на множество, создав их несколько раз, или даже пропустить кусок в целом, выпустив return; вместо создания.

Для того, чтобы контролировать поток, транзит куска получает AsyncContext в качестве второго аргумента. Этот объект контекста имеет методы для чтения текущего состояния ответа. Он также позволяет изменять поток ответа методами для создания новых кусков содержания, паузы потока, отмены потока, изменения информации ответа, замены текущего запроса на другой или изменения самого транзита куска.

Проверка примеров тестирования, реализованных в AsyncDecoratorTraitTest, может быть хорошей точкой начала для получения множества рабочих примеров для лучшего понимания. Вот некоторые примеры использования, которые он симулирует:

  • повторная попытка неудачного запроса;
  • отправка предполетного запроса, например, для нужд аутентификации;
  • выпуск субзапросов и добавление их содержания в тело основного ответа.

Логика в AsyncResponse имеет много проверок безопасности, которые вызывают LogicException, если транзит куска ведет себя некорректно; например, если кусок создается после isLast(), или если содержания кусока создается до isFirst(), и т.д.

Тестирование

Этот компонент включает в себя классы MockHttpClient и MockResponse для использования в тестах, которые не должны делать настоящие HTTP-запросы. Такие тесты могут быть полезны, так как они будут выполняться быстрее и производить стойкие результаты, так как они не зависят от внешнего сервиса. Так как настоящих HTTP-запросов нет, нет необходимости беспокоиться о том, чтобы сервис был онлайн или об изменениях из-за запроса, вроде удаления источника.

MockHttpClient реализует HttpClientInterface, так как и любой настоящий HTTP-клиент в данном компоненте. Когда вы введете HttpClientInterface, ваш код примет реального клиента вне тестов, заменяя его на MockHttpClient в тесте.

Когда метод request используется в MockHttpClient, он ответит с помощью предоставленного MockResponse. Есть несколько способов его использования, как описано ниже.

HTTP-клиент и ответы

Первый способ использования MockHttpClient - передать список ответов его конструктору. Это будет предоставлено в своем порядке при совершении запросов:

1
2
3
4
5
6
7
8
9
10
11
12
use Symfony\Component\HttpClient\MockHttpClient;
use Symfony\Component\HttpClient\Response\MockResponse;

$responses = [
    new MockResponse($body1, $info1),
    new MockResponse($body2, $info2),
];

$client = new MockHttpClient($responses);
// ответы возвращаются в том же порядке, что переданы в MockHttpClient
$response1 = $client->request('...'); // returns $responses[0]
$response2 = $client->request('...'); // returns $responses[1]

Другой способ использовать MockHttpClient - передать обратный вызов, который генерирует ответы динамически при вызове:

1
2
3
4
5
6
7
8
9
use Symfony\Component\HttpClient\MockHttpClient;
use Symfony\Component\HttpClient\Response\MockResponse;

$callback = function ($method, $url, $options) {
    return new MockResponse('...');
};

$client = new MockHttpClient($callback);
$response = $client->request('...'); // calls $callback to get the response

Tip

Instead of using the first argument, you can also set the (list of) responses or callbacks using the setResponseFactory() method:

1
2
3
4
5
6
7
$responses = [
    new MockResponse($body1, $info1),
    new MockResponse($body2, $info2),
];

$client = new MockHttpClient();
$client->setResponseFactory($responses);

Если вам нужно протестировать ответы с HTTP статус-кодами, отличающимися от 200, определите опцию http_code:

1
2
3
4
5
6
7
8
9
use Symfony\Component\HttpClient\MockHttpClient;
use Symfony\Component\HttpClient\Response\MockResponse;

$client = new MockHttpClient([
    new MockResponse('...', ['http_code' => 500]),
    new MockResponse('...', ['http_code' => 404]),
]);

$response = $client->request('...');

Ответы, предоставленные клиенту-симулятору, не должны быть экземплярами MockResponse. Любой класс, реализующий ResponseInterface, будет работать (например, $this->createMock(ResponseInterface::class)).

Однако, использование MockResponse позволяет симулирование ответов в кусках и таймаутов:

1
2
3
4
5
6
7
8
$body = function () {
    yield 'hello';
    // пустые строки превращаются в таймауты, чтобы их легко было тестировать
    yield '';
    yield 'world';
};

$mockResponse = new MockResponse($body());

Наконец, вы также можете создать вызываемый или итерабельный класс, который генерирует ответы, и использовать его в качестве обратного вызова в функциональных тестах:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
namespace App\Tests;

use Symfony\Component\HttpClient\Response\MockResponse;
use Symfony\Contracts\HttpClient\ResponseInterface;

class MockClientCallback
{
    public function __invoke(string $method, string $url, array $options = []): ResponseInterface
    {
        // загрузите файл набора тестов или сгенерируйте данные
        // ...
        return new MockResponse($data);
    }
}

Затем, сконфигурируйте Symfony для использования вашего обратного вызова:

  • YAML
  • XML
  • PHP
1
2
3
4
5
6
7
8
9
# config/services_test.yaml
services:
    # ...
    App\Tests\MockClientCallback: ~

# config/packages/test/framework.yaml
framework:
    http_client:
        mock_response_factory: App\Tests\MockClientCallback

Тестирование данных запроса

Класс MockResponse поставляется с некоторыми хелпер-методами для тестирования запроса:

  • getRequestMethod() - возвращает HTTP-метод;
  • getRequestUrl() - возвращает URL, по которому будет отправлен запрос;
  • getRequestOptions() - возвращает массив, содержащий другую информацию о запросе, вроде заголовков, параметров запроса, содержания тела и т.д.

Пример использования:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
$mockResponse = new MockResponse('', ['http_code' => 204]);
$httpClient = new MockHttpClient($mockResponse, 'https://example.com');

$response = $httpClient->request('DELETE', 'api/article/1337', [
    'headers' => [
        'Accept: */*',
        'Authorization: Basic YWxhZGRpbjpvcGVuc2VzYW1l',
    ],
]);

$mockResponse->getRequestMethod();
// возвращает "DELETE"

$mockResponse->getRequestUrl();
// возвращает "https://example.com/api/article/1337"

$mockResponse->getRequestOptions()['headers'];
// возвращает ["Accept: */*", "Authorization: Basic YWxhZGRpbjpvcGVuc2VzYW1l"]

Полный пример

Следующий отдельный пример демонстрирует способ использования HTTP-клиента и его тестирования в реальном приложении:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
// ExternalArticleService.php
use Symfony\Contracts\HttpClient\HttpClientInterface;

final class ExternalArticleService
{
    private HttpClientInterface $httpClient;

    public function __construct(HttpClientInterface $httpClient)
    {
        $this->httpClient = $httpClient;
    }

    public function createArticle(array $requestData): array
    {
        $requestJson = json_encode($requestData, JSON_THROW_ON_ERROR);

        $response = $this->httpClient->request('POST', 'api/article', [
            'headers' => [
                'Content-Type: application/json',
                'Accept: application/json',
            ],
            'body' => $requestJson,
        ]);

        if (201 !== $response->getStatusCode()) {
            throw new Exception('Response status code is different than expected.');
        }

        // ... other checks

        $responseJson = $response->getContent();
        $responseData = json_decode($responseJson, true, 512, JSON_THROW_ON_ERROR);

        return $responseData;
    }
}

// ExternalArticleServiceTest.php
use PHPUnit\Framework\TestCase;
use Symfony\Component\HttpClient\MockHttpClient;
use Symfony\Component\HttpClient\Response\MockResponse;

final class ExternalArticleServiceTest extends TestCase
{
    public function testSubmitData(): void
    {
        // Arrange
        $requestData = ['title' => 'Testing with Symfony HTTP Client'];
        $expectedRequestData = json_encode($requestData, JSON_THROW_ON_ERROR);

        $expectedResponseData = ['id' => 12345];
        $mockResponseJson = json_encode($expectedResponseData, JSON_THROW_ON_ERROR);
        $mockResponse = new MockResponse($mockResponseJson, [
            'http_code' => 201,
            'response_headers' => ['Content-Type: application/json'],
        ]);

        $httpClient = new MockHttpClient($mockResponse, 'https://example.com');
        $service = new ExternalArticleService($httpClient);

        // Act
        $responseData = $service->createArticle($requestData);

        // Assert
        self::assertSame('POST', $mockResponse->getRequestMethod());
        self::assertSame('https://example.com/api/article', $mockResponse->getRequestUrl());
        self::assertContains(
            'Content-Type: application/json',
            $mockResponse->getRequestOptions()['headers']
        );
        self::assertSame($expectedRequestData, $mockResponse->getRequestOptions()['body']);

        self::assertSame($responseData, $expectedResponseData);
    }
}

// ExternalArticleServiceTest.php
use PHPUnit\Framework\TestCase;
use Symfony\Component\HttpClient\MockHttpClient;
use Symfony\Component\HttpClient\Response\MockResponse;

final class ExternalArticleServiceTest extends TestCase
{
    public function testSubmitData(): void
    {
        // Arrange
        $requestData = ['title' => 'Testing with Symfony HTTP Client'];
        $expectedRequestData = json_encode($requestData, JSON_THROW_ON_ERROR);

        $expectedResponseData = ['id' => 12345];
        $mockResponseJson = json_encode($expectedResponseData, JSON_THROW_ON_ERROR);
        $mockResponse = new MockResponse($mockResponseJson, [
            'http_code' => 201,
            'response_headers' => ['Content-Type: application/json'],
        ]);

        $httpClient = new MockHttpClient($mockResponse, 'https://example.com');
        $service = new ExternalArticleService($httpClient);

        // Act
        $responseData = $service->createArticle($requestData);

        // Assert
        self::assertSame('POST', $mockResponse->getRequestMethod());
        self::assertSame('https://example.com/api/article', $mockResponse->getRequestUrl());
        self::assertContains(
            'Content-Type: application/json',
            $mockResponse->getRequestOptions()['headers']
        );
        self::assertSame($expectedRequestData, $mockResponse->getRequestOptions()['body']);

        self::assertSame($responseData, $expectedResponseData);
    }
}