HTTP: получить JSON, проверить ответ и отменить запрос
Готовый C#-клиент и локальный сервер: GET, POST, ошибки транспорта/HTTP/JSON, лимиты и отмена.
Запустить рабочий запрос
Нужен C#-проект Reckfell 1.0.0. Для локального стенда нужен Python 3 без дополнительных пакетов; если у вас уже есть API, используйте его URL и ответ вида {"message":"текст"}.
- Сохраните LocalHttpFixture.py в отдельной папке и выполните в ней
python LocalHttpFixture.py. Оставьте терминал открытым. Сервер слушает только 127.0.0.1:8765; это стенд, а не сервер для публикации игры. - Добавьте HttpJsonRequest.cs и HttpJsonDemo.cs в проект. Создайте отдельный уровень с корнем Entity, прикрепите HttpJsonDemo к корню, соберите C# и нажмите F6.
- В Output появятся
transport=Success,status=200иmessage=Привет 🌍. Если порт занят, остановите свой предыдущий стенд или выберите другой порт в create_server и поле Url.
HttpJsonDemo.cs
using Reckfell;
public partial class HttpJsonDemo : Entity
{
[Export] public string Url { get; set; } = "http://127.0.0.1:8765/message";
public override void _Ready()
{
var api = new HttpJsonRequest(); AddChild(api);
api.Completed += reply => ReckfellApi.Print($"HTTP: transport={reply.Transport}, status={reply.StatusCode}, failure={reply.Failure}, message={reply.Message}");
Error start = api.Start(Url);
if (start != Error.Ok) ReckfellApi.Print($"HTTP start: {start}");
}
}HttpJsonRequest.cs
using System;
using System.Text.Json;
using Reckfell;
public partial class HttpJsonRequest : Entity
{
public sealed record Reply(HttpRequest.Result Transport, int StatusCode, string Message, string Failure)
{
public bool IsSuccess => Failure.Length == 0;
}
public int MaxBytes { get; set; } = 65536;
public double TimeoutSeconds { get; set; } = 5;
public bool UseThreads { get; set; }
public TlsOptions? Trust { get; set; }
public bool Busy => _active is not null;
public Reply? LastReply { get; private set; }
public event Action<Reply>? Completed;
private HttpRequest? _active;
public Error Start(string url, HttpClient.Method method = HttpClient.Method.Get, string jsonBody = "")
{
if (Busy) return Error.Busy;
if (!IsInsideTree()) return Error.Unconfigured;
if (!Uri.TryCreate(url, UriKind.Absolute, out Uri? uri) ||
(uri.Scheme != "http" && uri.Scheme != "https") || uri.UserInfo.Length != 0 ||
MaxBytes <= 0 || !double.IsFinite(TimeoutSeconds) || TimeoutSeconds <= 0)
return Error.InvalidParameter;
var request = new HttpRequest { BodySizeLimit = MaxBytes, Timeout = TimeoutSeconds, MaxRedirects = 0, UseThreads = UseThreads };
if (Trust is not null) request.SetTlsOptions(Trust);
_active = request; LastReply = null; AddChild(request);
request.RequestCompleted += (result, code, headers, body) =>
{
if (_active != request) return;
_active = null;
var transport = (HttpRequest.Result)result;
Reply reply;
if (transport != HttpRequest.Result.Success) reply = new(transport, (int)code, "", "transport");
else if (code < 200 || code >= 300) reply = new(transport, (int)code, "", "http");
else
{
try
{
using JsonDocument doc = JsonDocument.Parse(body);
if (doc.RootElement.ValueKind == JsonValueKind.Object &&
doc.RootElement.TryGetProperty("message", out JsonElement value) &&
value.ValueKind == JsonValueKind.String && value.GetString() is string message && message.Length <= 128)
reply = new(transport, (int)code, message, "");
else reply = new(transport, (int)code, "", "schema");
}
catch (JsonException) { reply = new(transport, (int)code, "", "json"); }
}
LastReply = reply; request.QueueFree(); Completed?.Invoke(reply);
};
string[] headers = jsonBody.Length == 0 ? ["Accept: application/json"] :
["Accept: application/json", "Content-Type: application/json; charset=utf-8"];
Error error = request.Request(url, headers, method, jsonBody);
if (error != Error.Ok) { _active = null; request.CancelRequest(); request.QueueFree(); }
return error;
}
public void Cancel()
{
HttpRequest? request = _active; _active = null;
if (request is null) return;
request.CancelRequest(); request.QueueFree();
}
public override void _ExitTree() => Cancel();
}LocalHttpFixture.py
"""A loopback-only HTTP fixture. Python 3 standard library; no production data."""
import gzip
import json
import time
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
class Handler(BaseHTTPRequestHandler):
protocol_version = 'HTTP/1.1'
def log_message(self, *args):
pass
def handle(self):
try:
super().handle()
except ConnectionError:
self.close_connection = True
def reply(self, status, body, headers=None):
self.send_response(status)
self.send_header('Content-Type', 'application/json; charset=utf-8')
self.send_header('Content-Length', str(len(body)))
for name, value in (headers or {}).items():
self.send_header(name, value)
self.end_headers()
self.wfile.write(body)
def do_GET(self):
body = json.dumps({'message': 'Привет 🌍'}, ensure_ascii=False).encode('utf-8')
path = self.path.split('?', 1)[0]
try:
if path == '/slow':
time.sleep(0.5)
if path == '/missing':
self.reply(404, b'{"error":"missing"}')
elif path == '/bad-json':
self.reply(200, b'{')
elif path == '/wrong-schema':
self.reply(200, b'{"message":42}')
elif path == '/empty':
self.reply(204, b'')
elif path == '/large':
self.reply(200, b'x' * 70000)
elif path == '/gzip':
self.reply(200, gzip.compress(body), {'Content-Encoding': 'gzip'})
elif path == '/gzip-large':
self.reply(200, gzip.compress(b'x' * 70000), {'Content-Encoding': 'gzip'})
elif path == '/redirect':
self.reply(302, b'', {'Location': '/message'})
elif path in ('/chunked', '/eof', '/truncated'):
self.send_response(200)
self.send_header('Content-Type', 'application/json; charset=utf-8')
if path == '/chunked':
self.send_header('Transfer-Encoding', 'chunked')
elif path == '/truncated':
self.send_header('Content-Length', str(len(body) + 20))
else:
self.send_header('Connection', 'close')
self.end_headers()
if path == '/chunked':
for start in range(0, len(body), 4):
chunk = body[start:start + 4]
self.wfile.write(f'{len(chunk):X}\r\n'.encode() + chunk + b'\r\n')
self.wfile.write(b'0\r\n\r\n')
else:
self.wfile.write(body)
self.close_connection = True
else:
self.reply(200, body)
except ConnectionError:
self.close_connection = True
def do_POST(self):
length = int(self.headers.get('Content-Length', '0'))
if length > 65536:
self.reply(413, b'{}')
self.close_connection = True
return
self.reply(200, self.rfile.read(length))
def create_server(port=8765):
server = ThreadingHTTPServer(('127.0.0.1', port), Handler)
server.daemon_threads = True
return server
if __name__ == '__main__':
with create_server() as server:
print('HTTP fixture: http://127.0.0.1:8765/message', flush=True)
try:
server.serve_forever()
except KeyboardInterrupt:
passТри проверки результата
Start возвращает Error запуска. Error.Ok означает, что запрос принят, а не что сервер уже ответил. Completed приходит позднее и содержит три независимых уровня проверки:
| Проверка | Что означает ошибка |
|---|---|
| Transport != Success | Соединение, TLS, тайм-аут, лимит или передача не завершились успешно |
| StatusCode вне 200–299 | Сервер ответил, но запрос не принят приложением; 404 может прийти с Transport = Success |
| Failure = json или schema | Байты получены, но JSON повреждён либо нет строкового message длиной до 128 символов |
Этот клиент рассчитан на JSON-объект с message. Ответ 204 без тела допустим в HTTP, но не соответствует этому формату. Для другого API задайте его схему и правила статусов; не превращайте любой ответ в успешное игровое действие.
В Inspector меняйте только путь полного Url, например http://127.0.0.1:8765/missing: /missing → http/404; /bad-json → json; /wrong-schema → schema; /large и /gzip-large → BodySizeLimitExceeded; /chunked и /gzip → правильное сообщение. Для /slow в HttpJsonDemo замените создание клиента на new HttpJsonRequest { TimeoutSeconds = 0.1 }. Многобайтовые символы проверяются после получения полного тела, а не декодированием каждого сетевого фрагмента отдельно.
POST и двоичные данные
Start принимает метод и jsonBody. Для отправки сообщения вызовите Start(url, HttpClient.Method.Post, jsonBody) из своего обработчика; JSON создавайте сериализатором, а не склейкой строк с пользовательским вводом. Заголовок Content-Type добавляется, когда jsonBody не пуст. Локальный /echo возвращает полученные байты и позволяет проверить POST.
Строковый Request кодирует тело в UTF-8. Для изображения/архива/другого двоичного формата используйте HttpRequest.RequestRaw, byte[] и соответствующий Content-Type; JSON-клиент выше для такого ответа не подходит. При неоднозначности перегрузок string[]/ReadOnlySpan задайте массив заголовков явно.
Отмена, повтор и время жизни
Один экземпляр клиента обслуживает один запрос; повторный Start во время Busy возвращает Error.Busy. Для параллельных независимых запросов создайте отдельные экземпляры. Cancel снимает Busy и освобождает текущий HttpRequest. Сигнал Completed при отмене не обещан: в этом коде он не вызывается. Если интерфейс показывает отмену, обновите его из обработчика отмены.
Каждый запуск создаёт отдельный HttpRequest. Проверка _active != request отбрасывает старый ответ после отмены и замены. _ExitTree отменяет запрос при удалении владельца. Не оставляйте обработчик, который обращается к уже удалённому экрану.
Не запускайте повтор в каждом _Process. Ограничьте число попыток и задержку; повтор POST может повторить операцию сервера. Повтор выполнения должен быть безопасен по правилам вашего API.
Лимиты, перенаправления и файлы
Здесь лимит тела 65536 байт, TimeoutSeconds = 5, MaxRedirects = 0. HttpRequest автоматически распаковывает gzip/deflate при AcceptGZip; лимит проверяется и для распакованного результата. Тайм-аут использует таймер с IgnoreTimeScale, однако обработка зависит от работы дерева и его режима паузы. Не считайте его независимым процессом вне EntityTree.
MaxRedirects = 0 намеренно запрещает автоматический переход: /redirect заканчивается RedirectLimitReached. Если API перенаправляет запросы, отдельно проверьте допустимые адреса, метод и заголовки. Не переносите авторизацию на произвольный сервер.
UseThreads включает внутренний рабочий поток, если он доступен в сборке. Он не переносит ваш JSON-разбор и UI-обработчики в произвольный безопасный фон. Для обычного запроса ручной Poll не нужен; низкоуровневый HttpClient работает иначе.
DownloadFile пишет тело в файл, а байты сигнала остаются пустыми. Даже тело ответа 404 может оказаться в этом файле. Скачивайте в отдельный временный путь, проверяйте результат транспорта, HTTP-статус и ожидаемое содержимое/хеш, затем публикуйте файл. При отмене учитывайте KeepPartialDownload; AppendToDownloadFile — не готовая система корректного возобновления без проверки ответа сервера.
Проверены настоящие локальные TCP-запросы, UTF-8, GET/POST, двоичные байты, gzip/chunked/EOF, повреждённые ответы, лимиты, тайм-аут, отмена/замена и удаление владельца. Для HTTPS используйте проверку сертификата. Экспорты, прокси, браузерные ограничения и мобильные разрешения требуют отдельной проверки.