Обмен данными с веб-сервером по протоколу HTTP/HTTPS

MQL5 позволяет интегрировать программы с веб-сервисами и запрашивать данные из Интернет. Отправка и получение данных по протоколам HTTP/HTTPS осуществляется функцией WebRequest, имеющей две версии: для упрощенного и для продвинутого взаимодействия с веб-серверами.

int WebRequest(const string method, const string url, const string cookie, const string referer,
  int timeout, const char &data[], int size, char &result[], string &response)

int WebRequest(const string method, const string url, const string headers, int timeout,
  const char &data[], char &result[], string &response)

Основное отличие между двумя функциями в том что, упрощенный вариант позволяет указать в запросе заголовки только двух типов: так называемые "куки" (cookie) и ссылку на адрес, откуда производится переход (referer, здесь нет опечатки, так исторически сложилось, что английское слово "referrer" пишут в HTTP-заголовках через одно 'r'). Расширенный вариант принимает обобщенный параметр headers для передачи произвольного набора заголовков. Заголовки запроса имеют вид "имя: значение" и соединяются переносом строки "\r\n", если их больше одного.

В частности, если предположить, что строка cookie должна содержать "name1=value1; name2=value2", а ссылка referer равна "google.com", то для вызова второго варианта функции с тем же эффектом, что и первого, следует в параметр headers добавить: "Cookie: name1=value1; name2=value2\r\nReferer: google.com".

В параметре method указывается один из методов протокола, "HEAD", "GET" или "POST". Адрес запрашиваемого ресурса или сервиса передается в параметре url. По спецификации HTTP длина сетевого идентификатора ресурса ограничена 2048 байтами, однако на момент написания книги в MQL5 было свое ограничение - 1024 байта.

Максимальная длительность запроса определяется таймаутом (timeout) в миллисекундах.

Оба варианта функции позволяют передать на сервер данные из массива data. Первый вариант дополнительно требует указания размера этого массива в байтах (size).

Для отправки простых запросов со значениями нескольких переменных можно их соединить в строку вида "имя1=значение1&имя2=значение2&..." и добавить в адрес GET-запроса, после символа-разделителя '?' или поместить в массив data для POST-запроса с использованием заголовка "Content-Type: application/x-www-form-urlencoded". В более сложных случаях, когда требуется передать, например, файлы, используйте POST-запрос и "Content-Type: multipart/form-data".

Приемный массив result получит тело ответа сервера (если оно есть). Заголовки ответа сервера помещаются в строку response.

Функция возвращает HTTP-код ответа сервера или -1 в случае системной ошибки (например, проблемы со связью, ошибка в параметрах). Возможные коды в _LastError:

  • 5200 — ERR_WEBREQUEST_INVALID_ADDRESS — некорректный URL;
  • 5201 — ERR_WEBREQUEST_CONNECT_FAILED — не удалось подключиться к указанному URL;
  • 5202 — ERR_WEBREQUEST_TIMEOUT — превышен таймаут получения ответа от сервера;
  • 5203 — ERR_WEBREQUEST_REQUEST_FAILED — иная ошибка в результате выполнения запроса.

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

Для использования функции WebRequest следует добавить адреса серверов в список разрешенных URL во вкладке Советники в настройках терминала. Порт сервера выбирается автоматически на основе указанного протокола: 80 для адресов "http://" и 443 — для "https://".

Функция WebRequest является синхронной, то есть она приостанавливает выполнение программы, пока ждет ответа от сервера. В связи с этим функция запрещена для вызова из индикаторов, поскольку они работают в общих потоках по каждому символу. Задержка выполнения одного индикатора приведет к остановке обновления всех графиков по данному символу.

При работе в тестере стратегий функция WebRequest не выполняется.

Начнем практику с простого скрипта WebRequestTest.mq5, выполняющего единичный запрос. Во входных параметрах предоставим выбор для метода (по умолчанию "GET"), адреса тестовой веб-страницы, дополнительных заголовков (по желанию), а также таймаута.

input string Method = "GET"// Method (GET,POST)
input string Address = "https://httpbin.org/headers";
input string Headers;
input int Timeout = 5000;

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

Также добавим опцию DumpDataToFiles: когда она равна true, скрипт сохранит ответ сервера в отдельный файл, поскольку он может быть довольно большим. Значение false предписывает выводить данные напрямую в журнал.

input bool DumpDataToFiles = true;

Сразу стоит сказать, что тестирование подобных скриптов требует наличия сервера. Желающие могут установить локальный веб-сервер, например, node.js, но это предполагает самостоятельную подготовку или установку серверных скриптов (в данном случае, с подключением модулей на JavaScript). Более простой способ заключается в использовании публичных тестовых веб-серверов, доступных в Интернет. Среди них, например, httpbin.org, httpbingo.org, webhook.site, putsreq.com, www.mockable.io, reqbin.com. Они предоставляют разный набор возможностей. Выберите или найдите для себя подходящий (удобный и понятный или максимально гибкий).

В параметре Address по умолчанию находится адрес "конечной точки" (endpoint) серверного API httpbin.org — эта динамическая "веб-страница" возвращает клиенту HTTP-заголовки его запроса (в формате JSON). Таким образом, мы сможем в своей программе увидеть, что именно пришло на веб-сервер из терминала.

Не забудьте добавить домен "httpbin.org" в список разрешенных в настройках терминала.

Кстати говоря, текстовый формат JSON является стандартом де-факто для веб-сервисов. Готовые реализации классов для разбора JSON можно найти на сайте mql5.com, но мы сейчас будем просто показывать JSON "как есть".

В обработчике OnStart вызовем WebRequest с заданными параметрами и обработаем результат, если код ошибки неотрицательный. Заголовки ответ сервера (response) всегда выводятся в журнал.

void OnStart()
{
   uchar data[], result[];
   string response;
   
   int code = PRTF(WebRequest(MethodAddressHeadersTimeoutdataresultresponse));
   if(code > -1)
   {
      Print(response);
      if(ArraySize(result) > 0)
      {
         PrintFormat("Got data: %d bytes"ArraySize(result));
         if(DumpDataToFiles)
         {
            string parts[];
            URL::parse(Addressparts);
            
            const string filename = parts[URL_HOST] +
               (StringLen(parts[URL_PATH]) > 1 ? parts[URL_PATH] : "/_index_.htm");
            Print("Saving "filename);
            PRTF(FileSave(filenameresult));
         }
         else
         {
            Print(CharArrayToString(result080CP_UTF8));
         }
      }
   }
}

Для формирования имени файла мы используем вспомогательный класс URL из заголовочного файла URL.mqh (здесь не будет описан полностью). Метод URL::parse разбирает переданную строку на составляющие URL согласно спецификации — общий вид URL всегда такой: "protocol://domain.com:port/path?query#hash", причем многие фрагменты опциональны. Результаты помещаются в приемный массив, индексы в котором соответствуют конкретным частям URL и описаны в перечислении URL_PARTS:

enum URL_PARTS
{
   URL_COMPLETE,   // полный адрес
   URL_SCHEME,     // протокол
   URL_USER,       // имя/пароль пользователя (устарело, не поддерживается)
   URL_HOST,       // сервер
   URL_PORT,       // номер порта
   URL_PATH,       // путь/каталоги
   URL_QUERY,      // строка запроса после '?'
   URL_FRAGMENT,   // фрагмент после '#' (не выделяется)
   URL_ENUM_LENGTH
};

Таким образом, когда получаемые данные должны записаться в файл, скрипт создает его в папке по имени сервера (parts[URL_HOST]) и далее с сохранением иерархии пути в URL (parts[URL_PATH]): в простейшем случае это будет просто имя "конечной точки". Когда запрашивается главная страница сайта (путь содержит только наклонную черту '/'), файл получает название "_index_.htm".

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

WebRequest(Method,Address,Headers,Timeout,data,result,response)=200 / ok
Date: Fri, 22 Jul 2022 08:45:03 GMT
Content-Type: application/json
Content-Length: 291
Connection: keep-alive
Server: gunicorn/19.9.0
Access-Control-Allow-Origin: *
Access-Control-Allow-Credentials: true
   
Got data: 291 bytes
Saving httpbin.org/headers
FileSave(filename,result)=true / ok

Внутри файла httpbin.org/headers находятся заголовки нашего запроса, каким его увидел сервер (форматирование JSON сервер добавил сам при ответе нам).

{
  "headers":
  {
    "Accept": "*/*", 
    "Accept-Encoding": "gzip, deflate", 
    "Accept-Language": "ru,en", 
    "Host": "httpbin.org", 
    "User-Agent": "MetaTrader 5 Terminal/5.3333 (Windows NT 10.0; Win64; x64)", 
    "X-Amzn-Trace-Id": "Root=1-62da638f-2554..." // <- это добавил реверс-прокси сервера
  }
}

Таким образом, терминал сообщает о том, что готов принять данные любого типа, с поддержкой сжатия конкретными методами и списком предпочтительных языков. Кроме того, он представляется в поле User-Agent как MetaTrader 5. Последнее может быть нежелательным при работе с некоторыми сайтами, которые оптимизированы для работы исключительно с браузерами. Тогда мы можем во входном параметре Headers указать фиктивное название, например, "User-Agent: Mozilla/5.0 (Windows NT 10.0) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/103.0.0.0 Safari/537.36".

Некоторые из перечисленных выше тестовых сайтов позволяют организовать на сервере временное тестовое окружение со случайным именем под ваш личный эксперимент: для этого нужно зайти на сайт из браузера и получить уникальную ссылку, рабочую, как правило, в пределах 24 часов. Тогда вы сможете использовать эту ссылку в качестве адреса для запросов из MQL5 и отслеживать поведение запросов непосредственно из браузера. Там же вы сможете настраивать ответы сервера, в частности, пробовать отправку форм.

Слегка усложним пример. Сервер может потребовать от клиента дополнительных действий для выполнения запроса, в частности, авторизоваться, выполнить "редирект" (переход по другому адресу), снизить частоту запросов и т.д. Все подобные "сигналы" обозначаются особыми HTTP-кодами, которые возвращает функция WebRequest. Например, коды 301 и 302 — это редирект по разным причинам, и функция WebRequest выполняет его внутри автоматически, перезапрашивая страницу по указанному сервером адресу (поэтому коды редиректа никогда не попадают в код MQL-программы). А код 401 требует от клиента предоставить имя пользователя и пароль, и здесь вся ответственность лежит на нас. Способов послать эти данные существует множество. В новом скрипте WebRequestAuth.mq5 продемонстрирована обработка двух вариантов авторизации, которые сервер запрашивает с помощью ответных HTTP-заголовков: "WWW-Authenticate: Basic" или "WWW-Authenticate: Digest". В заголовках это может выглядеть так:

WWW-Authenticate:Basic realm="DemoBasicAuth"

Или так:

WWW-Authenticate:Digest realm="DemoDigestAuth",qop="auth", »
»  nonce="cuFAuHbb5UDvtFGkZEb2mNxjqEG/DjDr",opaque="fyNjGC4x8Zgt830PpzbXRvoqExsZeQSDZj"

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

Authorization: Basic dXNlcjpwYXNzd29yZA==

Здесь после ключевого слова "Basic" идет Base64-кодировка строки "user:password" с актуальным именем и паролем, а символ ':' вставляется здесь и далее "как есть" в качестве связующего звена. Более наглядно процесс взаимодействия представлен на изображении.

Схема простой авторизации на веб-сервере

Схема простой авторизации на веб-сервере

Более продвинутой считается схема авторизации Digest. В этом случае сервер предоставляет некоторую дополнительную информацию в своем ответе:

  • realm — название сайта (области сайта), куда производится вход;
  • qop — разновидность метода Digest (мы затронем только "auth");
  • nonce — случайная строка, которая будет использоваться для генерации данных авторизации;
  • opaque — случайная строка, которую мы в своих заголовках передадим обратно "как есть";
  • algorithm — опциональное название алгоритма хэширования, по умолчанию подразумевается MD5;

Для авторизации нужно выполнить следующие действия:

  1. Сгенерировать собственную случайную строку cnonce;
  2. Инициализировать или увеличить счетчик своих запросов nc;
  3. Вычислить hash1 = MD5(user:realm:password);
  4. Вычислить hash2 = MD5(method:uri), здесь uri — это путь и имя страницы;
  5. Вычислить response = MD5(hash1:nonce:nc:cnonce:qop:hash2).

После этого клиент может повторить запрос к серверу, добавив в свои заголовки строку вида:

Authorization: Digest username="user",realm="realm",nonce="...", »
»  uri="/path/to/page",qop=auth,nc=00000001,cnonce="...",response="...",opaque="..."

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

В параметры скрипта добавим переменные для ввода имени пользователя и пароля. В параметре Address по умолчанию прописан адрес "конечной точки" digest-auth, которая умеет запрашивать авторизацию с параметрами qop ("auth"), логином ("test") и паролем ("pass") это все задается на выбор в самом пути "конечной точки" (вы можете тестировать другие методы и реквизиты пользователя, например, так: "https://httpbin.org/digest-auth/auth-int/mql5client/mql5password").

const string Method = "GET";
input string Address = "https://httpbin.org/digest-auth/auth/test/pass";
input string Headers = "User-Agent: noname";
input int Timeout = 5000;
input string User = "test";
input string Password = "pass";
input bool DumpDataToFiles = true;

В параметре Headers мы просто так указали фиктивное название браузера для демонстрации возможности.

В функции OnStart добавим обработку HTTP-кода 401. Если имя пользователя и пароль не предоставлены, мы не сможем продолжить.

void OnStart()
{
   string parts[];
   URL::parse(Addressparts);
   uchar data[], result[];
   string response;
   int code = PRTF(WebRequest(MethodAddressHeadersTimeoutdataresultresponse));
   Print(response);
   if(code == 401)
   {
      if(StringLen(User) == 0 || StringLen(Password) == 0)
      {
         Print("Credentials required");
         return;
      }
      ...

Далее следует проанализировать заголовки, полученные с сервера. Для удобства был написан класс HttpHeader (HttpHeader.mqh). В его конструктор передается полный текст, а также разделитель элементов (в данном случае, символ перевода строки '\n') и символ, используемый между именем и значением внутри каждого элемента (в данном случае, двоеточие ':'). В процессе своего создания объект "парсит" текст, и затем элементы становятся доступны через перегруженный оператор [], причем тип его аргумента — строка. В результате, мы можем проверить наличие требования авторизации по имени "WWW-Authenticate". Если такой элемент есть в тексте и равен "Basic", формируем ответный заголовок "Authorization: Basic " с закодированными в Base64 логином и паролем.

      code = -1;
      HttpHeader header(response, '\n', ':');
      const string auth = header["WWW-Authenticate"];
      if(StringFind(auth"Basic ") == 0)
      {
         string Header = Headers;
         if(StringLen(Header) > 0Header += "\r\n";
         Header += "Authorization: Basic ";
         Header += HttpHeader::hash(User + ":" + PasswordCRYPT_BASE64);
         PRTF(Header);
         code = PRTF(WebRequest(MethodAddressHeaderTimeoutdataresultresponse));
         Print(response);
      }
      ...

Для Digest-авторизации все немного сложнее, в соответствии с изложенным выше алгоритмом.

      else if(StringFind(auth"Digest ") == 0)
      {
         HttpHeader params(StringSubstr(auth7), ',', '=');
         string realm = HttpHeader::unquote(params["realm"]);
         if(realm != NULL)
         {
            string qop = HttpHeader::unquote(params["qop"]);
            if(qop == "auth")
            {
               string h1 = HttpHeader::hash(User + ":" + realm + ":" + Password);
               string h2 = HttpHeader::hash(Method + ":" + parts[URL_PATH]);
               string nonce = HttpHeader::unquote(params["nonce"]);
               string counter = StringFormat("%08x"1);
               string cnonce = StringFormat("%08x"MathRand());
               string h3 = HttpHeader::hash(h1 + ":" + nonce + ":" + counter + ":" +
                  cnonce + ":" + qop + ":" + h2);
               
               string Header = Headers;
               if(StringLen(Header) > 0Header += "\r\n";
               Header += "Authorization: Digest ";
               Header += "username=\"" + User + "\",";
               Header += "realm=\"" + realm + "\",";
               Header += "nonce=\"" + nonce + "\",";
               Header += "uri=\"" + parts[URL_PATH] + "\",";
               Header += "qop=" + qop + ",";
               Header += "nc=" + counter + ",";
               Header += "cnonce=\"" + cnonce + "\",";
               Header += "response=\"" + h3 + "\",";
               Header += "opaque=" + params["opaque"] + "";
               PRTF(Header);
               code = PRTF(WebRequest(Method, Address, Header, Timeout, data, result, response));
               Print(response);
            }
         }
      }

Статический метод HttpHeader::hash получает строку с шестнадцатеричным представлениям хэша (по умолчанию MD5) для всех требуемых составных строк. На основе этих данных формируется заголовок для очередного вызова WebRequest. Статический метод HttpHeader::unquote убирает обрамляющие кавычки.

Остальная часть скрипта осталась без изменений. Повторный HTTP-запрос может оказать успешным, и тогда мы получим содержимое защищенной страницы, либо в авторизации будет отказано, и сервер напишет что-то вроде "Access denied".

Поскольку параметры по умолчанию содержат правильные значения ("/digest-auth/auth/test/pass" соответствует пользователю "test" и паролю "pass"), мы должны получить следующий результат запуска скрипта (все основные этапы и данные выводятся в лог). Рассмотрим его по порядку.

WebRequest(Method,Address,Headers,Timeout,data,result,response)=401 / ok
Date: Fri, 22 Jul 2022 10:45:56 GMT
Content-Type: text/html; charset=utf-8
Content-Length: 0
Connection: keep-alive
Server: gunicorn/19.9.0
WWW-Authenticate: Digest realm="me@kennethreitz.com" »
»  nonce="87d28b529a7a8797f6c3b81845400370", qop="auth",
»  opaque="4cb97ad7ea915a6d24cf1ccbf6feeaba", algorithm=MD5, stale=FALSE
...

Первый вызов WebRequest закончился с кодом 401, и среди ответных заголовков находится требование об авторизации ("WWW-Authenticate") с необходимыми параметрами. На их основе мы посчитали правильный ответ и подготовили заголовки для нового запроса.

Header=User-Agent: noname
Authorization: Digest username="test",realm="me@kennethreitz.com" »
»  nonce="87d28b529a7a8797f6c3b81845400370",uri="/digest-auth/auth/test/pass",
»  qop=auth,nc=00000001,cnonce="00001c74",
»  response="c09e52bca9cc90caf9a707d046b567b2",opaque="4cb97ad7ea915a6d24cf1ccbf6feeaba" / ok
...

Второй запрос уже возвращает код 200 и полезные данные, которые мы записываем в файл.

WebRequest(Method,Address,Header,Timeout,data,result,response)=200 / ok
Date: Fri, 22 Jul 2022 10:45:56 GMT
Content-Type: application/json
Content-Length: 47
Connection: keep-alive
Server: gunicorn/19.9.0
...
Got data: 47 bytes
Saving httpbin.org/digest-auth/auth/test/pass
FileSave(filename,result)=true / ok

Внутри файла MQL5/Files/httpbin.org/digest-auth/auth/test/pass можно найти "веб-страницу", а точнее статус успешной авторизации в формате JSON.

{
  "authenticated": true, 
  "user": "test"
}

Если при запуске скрипта указать неправильный пароль, получим пустой ответ от сервера, и файл не будет записан.

При использовании WebRequest мы автоматически оказываемся в области распределенных программных систем, в которых правильная работа зависит не только от нашего клиентского MQL-кода, но и сервера (не говоря уже о промежуточных звеньях, вроде прокси). Поэтому нужно быть готовым к возникновению чужих ошибок. В частности, на момент написания книги в реализации "конечной точки" digest-auth на httpbin.org имелась проблема: введенное в запрос имя пользователя не участвует в проверке авторизации, и потому любой логин приводит к успешной авторизации при указании правильного пароля. Чтобы все-таки проверить наш скрипт воспользуйтесь другими сервисами, например, аналогичным httpbingo.org/digest-auth/auth/test/pass. Также вы можете настроить скрипт на адрес jigsaw.w3.org/HTTP/Digest/ — он ожидает логин/пароль "guest"/"guest".

На практике большинство сайтов реализует авторизацию с помощью форм, встроенных непосредственно в веб-страницы: внутри HTML-кода они представляют собой тег-контейнер form с набором полей ввода (теги input, select и другие, см. далее), которые заполняются пользователем и отправляются на сервер методом POST. В связи с этим имеет смысл разобрать пример с отправкой формы. Однако, прежде чем заняться этим вплотную, желательно осветить еще один технический прием.

Дело в том, что взаимодействие клиента и сервера обычно сопровождается изменением состояния как клиента, так и сервера. На примере авторизации это можно понять наиболее наглядно, так как до авторизации пользователь был для системы неизвестным, а после — система уже знает его логин, и может применить предпочтительные настройки для сайта (например, язык, цвет, способ отображения форума), а также разрешить доступ к тем страницам, куда неавторизованные посетители попасть не могут (сервер такие попытки пресекает, возвращая HTTP-статус 403, Forbidden).

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

Любая из сторон — сервер и клиент — может добавлять cookie в HTTP-заголовок. Сервер это делает с помощью строки вида:

Set-Cookie: имя=значение; ⌠Domain=домен; Path=путь; Expires=дата; Max-Age=количество_секунд ...⌡ᵒᵖᵗ

Только имя и значение являются обязательными, а остальные атрибуты опциональны: здесь приведены основные — Domain, Path, Expires и Max-Age, но по факту их больше.

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

В исходящем от клиента HTTP-запросе cookie передаются строкой вида:

Cookie: имя⁽№⁾=значение⁽№⁾ [; имя⁽ⁱ⁾=значение⁽ⁱ⁾ ...]ᵒᵖᵗ

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

Сервер и клиент обмениваются всеми нужными куками при каждом HTTP-запросе — именно поэтому данный архитектурный стиль распределенных систем называется REST (Representational State Transfer или "передача самодостаточного состояния"). Например, после того как пользователь успешно авторизуется на сервере, последний установит (через заголовок "Set-Cookie:") специальную куку с идентификатором пользователя, после чего веб-браузер (или, в нашем случае, терминал с MQL-программой) будет отсылать её в следующих запросах (добавляя соответствующую строку в заголовок "Cookie:").

Функция WebRequest незаметно проделывает для нас всю эту работу: собирает "куки" из приходящих заголовков и добавляет подходящие куки в исходящие HTTP-запросы.

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

Внимание, куки хранятся в привязке к сайту и потому незаметно подставляются в исходящие заголовки всех MQL-программ, которые используют WebRequest для того же сайта.

Для упрощения последовательных запросов имеет смысл формализовать популярные действия в специальном классе HTTPRequest (HTTPRequest.mqh). В нем будем хранить общие HTTP-заголовки, которые, скорее всего, понадобятся для всех запросов (например, поддерживаемые языки, инструкции для прокси и т.д.). Кроме того, такая настройка как таймаут также является общей. Обе настройки передаются в конструктор объекта.

class HTTPRequestpublic HttpCookie
{
protected:
   string common_headers;
   int timeout;
   
public:
   HTTPRequest(const string hconst int t = 5000):
      common_headers(h), timeout(t) { }
   ...

По умолчанию таймаут установлен в 5 секунд. Основной, в некотором смысле универсальный метод класса — request.

   int request(const string methodconst string address,
      string headersconst uchar &data[], uchar &result[], string &response)
   {
      if(headers == NULLheaders = common_headers;
      
      ArrayResize(result0);
      response = NULL;
      Print(">>> Request:\n"method + " " + address + "\n" + headers);
      
      const int code = PRTF(WebRequest(methodaddressheaderstimeoutdataresultresponse));
      Print("<<< Response:\n"response);
      return code;
   }
};

Опишем еще пару методов для запросов конкретных типов.

Запросы GET используют только заголовки, а тело документа (часто встречается термин payload, "нагрузка") у них пустое.

   int GET(const string addressuchar &result[], string &response,
      const string custom_headers = NULL)
   {
      uchar nodata[];
      return request("GET"addresscustom_headersnodataresultresponse);
   }

В запросах POST "нагрузка", как правило, есть.

   int POST(const string addressconst uchar &payload[],
      uchar &result[], string &responseconst string custom_headers = NULL)
   {
      return request("POST"addresscustom_headerspayloadresultresponse);
   }

Но отправка форм возможна в разных форматах. Наиболее простой из них "application/x-www-form-urlencoded". Он подразумевает, что полезная "нагрузка" будет представлять собой строку (может быть и очень длинную, так как спецификации не накладывают ограничений, и все зависит от настроек веб-серверов). Для таких форм предоставим более удобную перегрузку метода POST со строковым параметром "нагрузки".

   int POST(const string addressconst string payload,
      uchar &result[], string &responseconst string custom_headers = NULL)
   {
      uchar bytes[];
      const int n = StringToCharArray(payloadbytes0, -1CP_UTF8);
      ArrayResize(bytesn - 1); // удаляем терминальный ноль
      return request("POST"addresscustom_headersbytesresultresponse);
   }

Для проверки нашего клиентского веб-движка напишем простой скрипт WebRequestCookie.mq5. Его задачей будет запросить одну и ту же веб-страницу дважды: первый раз сервер, скорее всего, предложит установить свои куки, и тогда они будут автоматически подставлены во второй запрос. Во входных параметрах укажем адрес страницы для теста — пусть это будет сайт mql5.com. Также сымитируем заголовки по умолчанию — пусть это будет откорректированная строка "User-Agent".

input string Address = "https://www.mql5.com";
input string Headers = "User-Agent: Mozilla/5.0 (Windows NT 10.0) Chrome/103.0.0.0"// Headers (use '|' as separator, if many specified)

В основной функции скрипта опишем объект HTTPRequest и выполним в цикле два запроса GET.

Внимание! Данный тест работает в предположении, что MQL-программы еще не заходили на сайт www.mql5.com и не получали с него куки. После однократного запуска скрипта куки останутся в кэше терминала, и воспроизвести пример станет невозможно: на обеих итерациях цикла мы получим одинаковые записи в журнале.
 
Не забудьте добавить домен "www.mql5.com" в список разрешенных в настройках терминала.

void OnStart()
{
   uchar result[];
   string response;
   HTTPRequest http(Headers);
   
   for(int i = 0i < 2; ++i)
   {
      if(http.GET(Addressresultresponse) > -1)
      {
         if(ArraySize(result) > 0)
         {
            PrintFormat("Got data: %d bytes"ArraySize(result));
            if(i == 0// покажем начало документа только первый раз
            {
               const string s = CharArrayToString(result0160CP_UTF8);
               int j = -1k = -1;
               while((j = StringFind(s"\r\n"j + 1)) != -1k = j;
               Print(StringSubstr(s0k));
            }
         }
      }
   }
}

Первая итерация цикла породит такие записи в журнале (с сокращениями):

>>> Request:
GET https://www.mql5.com
User-Agent: Mozilla/5.0 (Windows NT 10.0) Chrome/103.0.0.0
WebRequest(method,address,headers,timeout,data,result,response)=200 / ok
<<< Response:
Server: nginx
Date: Sun, 24 Jul 2022 19:04:35 GMT
Content-Type: text/html; charset=utf-8
Transfer-Encoding: chunked
Connection: keep-alive
Cache-Control: no-cache,no-store
Content-Encoding: gzip
Expires: -1
Pragma: no-cache
Set-Cookie: sid=CfDJ8O2AwC...Ne2yP5QXpPKA2; domain=.mql5.com; path=/; samesite=lax; httponly
Vary: Accept-Encoding
Strict-Transport-Security: max-age=31536000; includeSubDomains; preload
Content-Security-Policy: default-src 'self'; script-src 'self' ... 
Generate-Time: 2823
Agent-Type: desktop-ru-en
X-Cache-Status: MISS
Got data: 184396 bytes
   
<!DOCTYPE html>
<html lang="ru">
<head>
  <meta http-equiv="X-UA-Compatible" content="IE=edge" />

Мы получили одну новую куку с именем sid. Чтобы убедиться в её эффективности, можно перейти к просмотру второй части журнала, для второй итерации цикла.

>>> Request:
GET https://www.mql5.com
User-Agent: Mozilla/5.0 (Windows NT 10.0) Chrome/103.0.0.0
WebRequest(method,address,headers,timeout,data,result,response)=200 / ok
<<< Response:
Server: nginx
Date: Sun, 24 Jul 2022 19:04:36 GMT
Content-Type: text/html; charset=utf-8
Transfer-Encoding: chunked
Connection: keep-alive
Cache-Control: no-cache, no-store, must-revalidate, no-transform
Content-Encoding: gzip
Expires: -1
Pragma: no-cache
Vary: Accept-Encoding
Strict-Transport-Security: max-age=31536000; includeSubDomains; preload
Content-Security-Policy: default-src 'self'; script-src 'self' ... 
Generate-Time: 2950
Agent-Type: desktop-ru-en
X-Cache-Status: MISS

К сожалению, нам здесь не видны полные исходящие заголовки, формируемые внутри WebRequest, но то, что кука отправилась на сервер с помощью заголовка "Cookie:", доказывает тот факт, что сервер в своем втором ответе уже не просит её установить.

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

Напомним, что для отправки формы мы можем воспользоваться методом POST со строковым параметром payload. Принцип подготовки данных по стандарту "x-www-form-urlencoded" заключается в том, что именованные переменные и их значения записываются в одну сплошную строку (в чем-то похожую на куки).

имя⁽№⁾=значение⁽№⁾[&имя⁽ⁱ⁾=значение⁽ⁱ⁾...]ᵒᵖᵗ

Имя и значение связаны знаком равно '=', а пары состыковываются с помощью символа амперсанда '&'. Значение может отсутствовать. Например,

Name=John&Age=33&Education=&Address=

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

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

Тестовая форма имеется по адресу https://httpbin.org/forms/post. Она представляет собой диалог для заказа пиццы.

Тестовая веб-форма

Тестовая веб-форма

Её внутреннее устройство и поведение описывается следующим HTML-кодом. В нем нас в первую очередь интересуют теги input, которые и задают ожидаемые сервером переменные. Кроме того следует обратить внимание на атрибут action в самом теге form, так как он определяет адрес, на который должен отправляться POST-запрос, и в данном случае это "/post", что вместе с доменом дает строку "httpbin.org/post" — именно её будем использовать в MQL-программе.

<!DOCTYPE html>
<html>
  <body>
  <form method="post" action="/post">
    <p><label>Customer name: <input name="custname"></label></p>
    <p><label>Telephone: <input type=tel name="custtel"></label></p>
    <p><label>E-mail address: <input type=email name="custemail"></label></p>
    <fieldset>
      <legend> Pizza Size </legend>
      <p><label> <input type=radio name=size value="small"> Small </label></p>
      <p><label> <input type=radio name=size value="medium"> Medium </label></p>
      <p><label> <input type=radio name=size value="large"> Large </label></p>
    </fieldset>
    <fieldset>
      <legend> Pizza Toppings </legend>
      <p><label> <input type=checkbox name="topping" value="bacon"> Bacon </label></p>
      <p><label> <input type=checkbox name="topping" value="cheese"> Extra Cheese </label></p>
      <p><label> <input type=checkbox name="topping" value="onion"> Onion </label></p>
      <p><label> <input type=checkbox name="topping" value="mushroom"> Mushroom </label></p>
    </fieldset>
    <p><label>Preferred delivery time: <input type=time min="11:00" max="21:00" step="900" name="delivery"></label></p>
    <p><label>Delivery instructions: <textarea name="comments"></textarea></label></p>
    <p><button>Submit order</button></p>
  </form>
  </body>
</html>

В скрипте WebRequestForm.mq5 мы подготовили аналогичные входные переменные для указания пользователем перед отправкой на сервер.

input string Address = "https://httpbin.org/post";
   
input string Customer = "custname=Vincent Silver";
input string Telephone = "custtel=123-123-123";
input string Email = "custemail=email@address.org";
input string PizzaSize = "size=small"// PizzaSize (small,medium,large)
input string PizzaTopping = "topping=bacon"// PizzaTopping (bacon,cheese,onion,mushroom)
input string DeliveryTime = "delivery=";
input string Comments = "comments=";

Уже установленные строки приведены только для тестирования в одно нажатие: вы можете заменить их на собственные, но учтите, что внутри каждой строки следует редактировать только величину справа от '=', а имя слева от '=' нужно сохранить (неизвестные имена сервер проигнорирует).

В функции OnStart опишем HTTP-заголовок "Content-Type:" и подготовим объединенную строку со всеми переменными.

void OnStart()
{
   uchar result[];
   string response;
   string header = "Content-Type: application/x-www-form-urlencoded";
   string form_fields;
   StringConcatenate(form_fields,
      Customer"&",
      Telephone"&",
      Email"&",
      PizzaSize"&",
      PizzaTopping"&",
      DeliveryTime"&",
      Comments);
   HTTPRequest http;
   if(http.POST(Addressform_fieldsresultresponse) > -1)
   {
      if(ArraySize(result) > 0)
      {
         PrintFormat("Got data: %d bytes"ArraySize(result));
         // NB: UTF-8 подразумевается для многих типов content-types,
         // но в некоторых может быть другая, анализируйте ответные заголовки
         Print(CharArrayToString(result0WHOLE_ARRAYCP_UTF8));
      }
   }
}

Затем выполним метод POST и выведем ответ сервера в журнал. Вот пример результата.

>>> Request:
POST https://httpbin.org/post
Content-Type: application/x-www-form-urlencoded
WebRequest(method,address,headers,timeout,data,result,response)=200 / ok
<<< Response:
Date: Mon, 25 Jul 2022 08:41:41 GMT
Content-Type: application/json
Content-Length: 780
Connection: keep-alive
Server: gunicorn/19.9.0
Access-Control-Allow-Origin: *
Access-Control-Allow-Credentials: true
   
Got data: 721 bytes
{
  "args": {}, 
  "data": "", 
  "files": {}, 
  "form": {
    "comments": "", 
    "custemail": "email@address.org", 
    "custname": "Vincent Silver", 
    "custtel": "123-123-123", 
    "delivery": "", 
    "size": "small", 
    "topping": "bacon"
  }, 
  "headers": {
    "Accept": "*/*", 
    "Accept-Encoding": "gzip, deflate", 
    "Accept-Language": "ru,en", 
    "Content-Length": "127", 
    "Content-Type": "application/x-www-form-urlencoded", 
    "Host": "httpbin.org", 
    "User-Agent": "MetaTrader 5 Terminal/5.3333 (Windows NT 10.0; x64)", 
    "X-Amzn-Trace-Id": "Root=1-62de5745-25bd1d823a9609f01cff04ad"
  }, 
  "json": null, 
  "url": "https://httpbin.org/post"
}

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

С помощью таких POST-запросов, но поменьше, обычно выполняется и авторизация. Правда, большинство веб-сервисов специально усложняет данный процесс в целях безопасности, и предварительно требует рассчитать несколько хэш-сумм от реквизитов пользователя. Публичные, специально разработанные API обычно имеют в документации описания всех необходимых алгоритмов. Но так бывает не всегда. В частности, у нас не получится авторизоваться с помощью WebRequest на mql5.com, потому что сайт не имеет открытого программного интерфейса.

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