Crafter.Review/implementations/Crafter.Review-Http.cpp
Jorijn van der Graaf 51bdd6298a
feat(http): the HTTP layer on ClientHTTP1::Fetch and the typed socket error
Crafter.Network master now carries, in their final shape, the pieces the
HTTP layer and the SQL broker were written against on a side branch:
ClientHTTP1::Fetch follows redirects by RFC 9110 §15.4 on a URL type of
its own, and SocketErrorException is a std::system_error with the phase
that failed. The broker and the HTTP layer read the errno or the resolver
code from code() and the phase from the field, instead of matching
message prefixes, and the pin moves to that commit, so the build no
longer needs --local against a checkout of the side branch.

The messages the run log, the preflight detail and the SQL helper show
are unchanged, but for the wording of a refused URL.
2026-10-31 20:46:13 +01:00

106 lines
6.1 KiB
C++

// SPDX-License-Identifier: AGPL-3.0-only
// SPDX-FileCopyrightText: Copyright (C) 2026 Catcrafts®
module Crafter.Review:Http_impl;
import :Http;
import :Json;
import :Text;
import Crafter.Network;
import std;
namespace Crafter::Review::Http {
namespace {
std::string Wrapped(std::string_view reason) {
return std::format("<urlopen error {}>", reason);
}
// ClientTCP's resolve, socket() and connect failures, which happen
// while the request goes out, apart from a send's or a receive's.
bool WhileSending(const SocketErrorException& failure) {
return failure.phase == SocketPhase::Resolve || failure.phase == SocketPhase::Open || failure.phase == SocketPhase::Connect;
}
// "[Errno N] text", with the resolver's own text for a resolver
// code and the C library's for an errno.
std::string SocketText(const SocketErrorException& failure) {
if (failure.code().category() == ResolverCategory()) return std::format("[Errno {}] {}", failure.code().value(), failure.code().message());
return Dyn::ErrnoText(failure.code().value());
}
constexpr std::array<std::pair<std::int32_t, std::string_view>, 62> Phrases = {{
{100, "Continue"}, {101, "Switching Protocols"}, {102, "Processing"}, {103, "Early Hints"}, {200, "OK"}, {201, "Created"}, {202, "Accepted"},
{203, "Non-Authoritative Information"}, {204, "No Content"}, {205, "Reset Content"}, {206, "Partial Content"}, {207, "Multi-Status"},
{208, "Already Reported"}, {226, "IM Used"}, {300, "Multiple Choices"}, {301, "Moved Permanently"}, {302, "Found"}, {303, "See Other"},
{304, "Not Modified"}, {305, "Use Proxy"}, {307, "Temporary Redirect"}, {308, "Permanent Redirect"}, {400, "Bad Request"}, {401, "Unauthorized"},
{402, "Payment Required"}, {403, "Forbidden"}, {404, "Not Found"}, {405, "Method Not Allowed"}, {406, "Not Acceptable"},
{407, "Proxy Authentication Required"}, {408, "Request Timeout"}, {409, "Conflict"}, {410, "Gone"}, {411, "Length Required"},
{412, "Precondition Failed"}, {413, "Content Too Large"}, {414, "URI Too Long"}, {415, "Unsupported Media Type"}, {416, "Range Not Satisfiable"},
{417, "Expectation Failed"}, {418, "I'm a Teapot"}, {421, "Misdirected Request"}, {422, "Unprocessable Content"}, {423, "Locked"},
{424, "Failed Dependency"}, {425, "Too Early"}, {426, "Upgrade Required"}, {428, "Precondition Required"}, {429, "Too Many Requests"},
{431, "Request Header Fields Too Large"}, {451, "Unavailable For Legal Reasons"}, {500, "Internal Server Error"}, {501, "Not Implemented"},
{502, "Bad Gateway"}, {503, "Service Unavailable"}, {504, "Gateway Timeout"}, {505, "HTTP Version Not Supported"}, {506, "Variant Also Negotiates"},
{507, "Insufficient Storage"}, {508, "Loop Detected"}, {510, "Not Extended"}, {511, "Network Authentication Required"},
}};
}
HttpError::HttpError(std::int32_t code, const std::string& message) : std::runtime_error(std::format("HTTP Error {}: {}", code, message)), code(code) {}
HttpError::HttpError(std::int32_t code) : HttpError(code, std::string(Reason(code))) {}
std::int32_t HttpError::Code() const {
return code;
}
std::string_view Reason(std::int32_t code) {
for (const auto& [value, phrase] : Phrases) {
if (value == code) return phrase;
}
return "";
}
Response UrlOpen(std::string_view url, const std::map<std::string, std::string>& headers, double timeout) {
FetchOptions options;
options.headers = {{"accept-encoding", "identity"}, {"connection", "close"}, {"user-agent", "crafter-review"}};
for (const auto& [name, value] : headers) options.headers[Dyn::Lower(name)] = value;
// The timeout bounds each blocking step: the connect, the handshake,
// every read.
const std::chrono::milliseconds budget(static_cast<std::int64_t>(timeout * 1000));
options.connectTimeout = budget;
options.handshakeTimeout = budget;
options.timeout = budget;
FetchResult result;
try {
result = ClientHTTP1::Fetch(url, options);
} catch (const SocketTimeoutException&) {
throw Error(Wrapped("timed out"));
} catch (const SocketErrorException& failure) {
throw Error(WhileSending(failure) ? Wrapped(SocketText(failure)) : SocketText(failure));
} catch (const SocketClosedException&) {
throw Error("Remote end closed connection without response");
} catch (const TLSException& failure) {
throw Error(Wrapped(failure.what()));
} catch (const std::invalid_argument& failure) {
throw Error(failure.what());
} catch (const std::runtime_error& failure) {
const std::string_view text = failure.what();
if (text.starts_with("timed out")) throw Error("timed out");
if (text.starts_with("connection closed")) throw Error("Remote end closed connection without response");
throw Error(failure.what());
} catch (const std::exception& failure) {
throw Error(failure.what());
}
const std::optional<std::int64_t> status = Dyn::ParseInt(result.response.status);
const auto code = static_cast<std::int32_t>(status.value_or(0));
if (result.stop == FetchStop::Loop || result.stop == FetchStop::Limit) {
throw HttpError(code, std::format("The HTTP server returned a redirect error that would lead to an infinite loop.\nThe last 30x error message was:\n{}", Reason(code)));
}
if (code < 200 || code >= 300) throw HttpError(code);
return {code, std::move(result.response.body)};
}
Json JsonBody(const Response& response) {
if (const std::optional<std::string> error = Dyn::Utf8DecodeError(response.body)) throw Dyn::Error("UnicodeDecodeError", *error);
if (const std::optional<std::string> error = Dyn::JsonDecodeError(response.body)) throw Dyn::Error("JSONDecodeError", *error);
return ParseLoose(response.body);
}
}