// 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("", 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, 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& 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(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 status = Dyn::ParseInt(result.response.status); const auto code = static_cast(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 error = Dyn::Utf8DecodeError(response.body)) throw Dyn::Error("UnicodeDecodeError", *error); if (const std::optional error = Dyn::JsonDecodeError(response.body)) throw Dyn::Error("JSONDecodeError", *error); return ParseLoose(response.body); } }