Crafter.Review/implementations/Crafter.Review-Http.cpp

106 lines
6.1 KiB
C++
Raw Permalink Normal View History

// 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);
}
}