Tweak
publish / publish (push) Failing after 10m17s

This commit is contained in:
khodowany
2026-10-04 23:19:34 -05:00
parent 0dafa5da22
commit bff6884c09
5 changed files with 222 additions and 26 deletions
+6
View File
@@ -43,3 +43,9 @@ python3 modulus.py package --all --replace
## License
Ligo is released under the MIT License.
### HTTP metadata and raw responses
`request_context` includes lowercase HTTP `headers` and a `query` map. Set `http::server_options::parse_query = true` to route on the path and decode query parameters (`+` for spaces, percent escapes); malformed escapes and duplicate keys return a bad-request response. The default remains false, preserving existing exact-target routing. Request cookies retain semicolon separation when repeated.
`service_response` accepts an HTTP `status` (default 200) and repeated response `headers`, for example `Set-Cookie`. Existing `json_response`, `text_response`, and aggregate initialization continue to work. `http::request` accepts additional headers and an optional raw body. `http::client::exchange()` returns status, lowercase headers, and an uninterpreted body, including non-2xx and 304 responses; `call()` retains the original JSON-envelope behavior. Repeated raw response headers are comma-joined in the map; callers needing multiple Set-Cookie values must not treat that combined value as a single cookie. TLS verification and body/time limits apply to both client paths.
+12
View File
@@ -4,6 +4,8 @@
#include <chrono>
#include <cstdint>
#include <functional>
#include <map>
#include <optional>
#include <string>
#include <boost/asio/any_io_executor.hpp>
@@ -37,6 +39,14 @@ struct request {
ligo::http::method method{ligo::http::method::POST};
std::string target{"/"};
boost::json::value payload{};
std::map<std::string, std::string> headers{};
std::optional<std::string> raw_body{};
};
struct response {
std::uint16_t status{};
std::map<std::string, std::string> headers{};
std::string body{};
};
struct client_options {
@@ -50,12 +60,14 @@ struct server_options {
std::uint64_t max_body_size{16U * 1024U * 1024U};
std::function<void(modulus::error)> on_error{};
ligo::security_config security{};
bool parse_query{false};
};
class client {
public:
explicit client(ligo::http::endpoint endpoint, ligo::http::client_options options = {});
auto call(ligo::http::request const &request) const -> boost::asio::awaitable<std::expected<boost::json::value, modulus::error>>;
auto exchange(ligo::http::request const &request) const -> boost::asio::awaitable<std::expected<ligo::http::response, modulus::error>>;
private:
ligo::http::endpoint endpoint_{};
ligo::http::client_options options_{};
+6
View File
@@ -7,6 +7,8 @@
#include <mutex>
#include <string>
#include <utility>
#include <vector>
#include <cstdint>
#include <boost/asio/awaitable.hpp>
#include <boost/json/value.hpp>
@@ -20,11 +22,15 @@ struct request_context {
std::string peer{};
std::string method{};
std::string route{};
std::map<std::string, std::string> headers{};
std::map<std::string, std::string> query{};
};
struct service_response {
std::string content_type{"application/json"};
std::string body{};
std::uint16_t status{200};
std::vector<std::pair<std::string, std::string>> headers{};
};
class service_router {
+139 -26
View File
@@ -1,6 +1,9 @@
#include <ligo/http.hpp>
#include <atomic>
#include <algorithm>
#include <cctype>
#include <stdexcept>
#include <cassert>
#include <chrono>
#include <cstdint>
@@ -118,6 +121,67 @@ auto host_header(std::string const &host, std::uint16_t port) -> std::string {
return value;
}
auto lower(std::string value) -> std::string {
std::transform(value.begin(), value.end(), value.begin(), [](unsigned char character) { return static_cast<char>(std::tolower(character)); });
return value;
}
auto decode_query(std::string_view value) -> std::string {
auto output = std::string{};
auto hex = [](char character) -> int {
if (character >= '0' && character <= '9') {
return character - '0';
}
if (character >= 'a' && character <= 'f') {
return character - 'a' + 10;
}
if (character >= 'A' && character <= 'F') {
return character - 'A' + 10;
}
return -1;
};
for (std::size_t index = 0; index < value.size(); ++index) {
if (value[index] == '%') {
if (index + 2 >= value.size() || hex(value[index + 1]) < 0 || hex(value[index + 2]) < 0) {
throw std::invalid_argument("Invalid query escape");
}
auto character = static_cast<char>(hex(value[index + 1]) * 16 + hex(value[index + 2]));
if (character == '\0') {
throw std::invalid_argument("Invalid query character");
}
output += character;
index += 2;
} else {
output += value[index] == '+' ? ' ' : value[index];
}
}
return output;
}
auto parse_query(ligo::request_context &context) -> void {
auto position = context.route.find('?');
if (position == std::string::npos) {
return;
}
auto query = context.route.substr(position + 1);
context.route.resize(position);
auto offset = std::size_t{};
while (offset < query.size()) {
auto end = query.find('&', offset);
auto part = std::string_view{query}.substr(offset, end == std::string::npos ? query.size() - offset : end - offset);
auto equal = part.find('=');
auto key = decode_query(part.substr(0, equal));
auto value = equal == std::string_view::npos ? std::string{} : decode_query(part.substr(equal + 1));
if (key.empty() || !context.query.emplace(std::move(key), std::move(value)).second) {
throw std::invalid_argument("Empty or duplicate query key");
}
if (end == std::string::npos) {
break;
}
offset = end + 1;
}
}
auto remote_peer(boost::beast::tcp_stream &stream) -> std::string {
auto endpoint_error = boost::system::error_code{};
auto remote_endpoint = stream.socket().remote_endpoint(endpoint_error);
@@ -196,9 +260,33 @@ auto handle_session(STREAM &stream, std::shared_ptr<ligo::service_router const>
.method = std::string{http_request.method_string()},
.route = std::string{http_request.target()},
};
for (auto const &field : http_request.base()) {
auto name = lower(std::string{field.name_string()});
auto value = std::string{field.value()};
if (context.headers.contains(name)) {
context.headers[name] += (name == "cookie" ? "; " : ", ") + value;
} else {
context.headers.emplace(std::move(name), std::move(value));
}
}
auto query_error = std::string{};
try {
if (options.parse_query) {
parse_query(context);
}
} catch (std::invalid_argument const &error) {
query_error = error.what();
}
if (!query_error.empty()) {
co_await write_http_failure_response(stream, ligo::detail::make_error(ligo::detail::error_invalid_argument, query_error), options.timeout, options.on_error);
co_return;
}
auto result = co_await ligo::detail::call_response_handler(*router, std::move(context), std::move(payload));
if (result) {
response.result(boost::beast::http::status::ok);
response.result(result->status >= 200 && result->status <= 599 ? result->status : 500);
for (auto const &[name, value] : result->headers) {
response.insert(name, value);
}
response.set(boost::beast::http::field::content_type, result->content_type);
response.body() = std::move(result->body);
} else {
@@ -326,12 +414,18 @@ auto make_http_request_message(ligo::http::endpoint const &endpoint, ligo::http:
http_request.set(boost::beast::http::field::content_type, "application/json");
http_request.body() = boost::json::serialize(request.payload);
}
if (request.raw_body) {
http_request.body() = *request.raw_body;
}
for (auto const &[name, value] : request.headers) {
http_request.set(name, value);
}
http_request.prepare_payload();
return http_request;
}
template <typename STREAM>
auto read_http_response(STREAM &stream, std::chrono::milliseconds timeout, std::uint64_t max_body_size) -> boost::asio::awaitable<std::expected<boost::json::value, modulus::error>> {
auto read_http_response(STREAM &stream, std::chrono::milliseconds timeout, std::uint64_t max_body_size) -> boost::asio::awaitable<std::expected<ligo::http::response, modulus::error>> {
auto buffer = boost::beast::flat_buffer{};
auto parser = boost::beast::http::response_parser<boost::beast::http::string_body>{};
parser.body_limit(max_body_size);
@@ -342,36 +436,42 @@ auto read_http_response(STREAM &stream, std::chrono::milliseconds timeout, std::
if (read_error) {
if (http_body_limit_exceeded(read_error)) {
auto failure_status = http_body_limit_status();
co_return ligo::detail::fail<boost::json::value>(failure_status);
co_return ligo::detail::fail<ligo::http::response>(failure_status);
}
auto failure_status = ligo::detail::operation_failure(read_error, read_deadline, "HTTP request timed out");
co_return ligo::detail::fail<boost::json::value>(failure_status);
co_return ligo::detail::fail<ligo::http::response>(failure_status);
}
auto http_response = parser.release();
auto parse_error = boost::system::error_code{};
auto body = boost::json::parse(http_response.body(), parse_error);
if (parse_error) {
co_return ligo::detail::fail<boost::json::value>(ligo::detail::error_invalid_argument, parse_error.message());
auto response = ligo::http::response{};
response.status = static_cast<std::uint16_t>(http_response.result_int());
response.body = std::move(http_response.body());
for (auto const &field : http_response.base()) {
auto name = lower(std::string{field.name_string()});
if (response.headers.contains(name)) {
response.headers[name] += ", " + std::string{field.value()};
} else {
response.headers.emplace(std::move(name), std::string{field.value()});
}
}
co_return ligo::detail::unwrap_response_envelope(std::move(body), "HTTP");
co_return response;
}
auto call_https_endpoint(ligo::http::endpoint endpoint, boost::beast::http::request<boost::beast::http::string_body> http_request, ligo::http::client_options options) -> boost::asio::awaitable<std::expected<boost::json::value, modulus::error>> {
auto call_https_endpoint(ligo::http::endpoint endpoint, boost::beast::http::request<boost::beast::http::string_body> http_request, ligo::http::client_options options) -> boost::asio::awaitable<std::expected<ligo::http::response, modulus::error>> {
auto context = ligo::make_tls_client_context(options.security);
if (!context) {
co_return ligo::detail::fail<boost::json::value>(context.error());
co_return ligo::detail::fail<ligo::http::response>(context.error());
}
auto executor = co_await boost::asio::this_coro::executor;
auto endpoints = co_await ligo::detail::resolve_tcp_endpoints(executor, endpoint.host, endpoint.port, options.timeout, "HTTP request timed out");
if (!endpoints) {
co_return ligo::detail::fail<boost::json::value>(endpoints.error());
co_return ligo::detail::fail<ligo::http::response>(endpoints.error());
}
auto tcp_stream = boost::beast::tcp_stream{executor};
tcp_stream.expires_after(options.timeout);
auto [connect_error, _] = co_await tcp_stream.async_connect(*endpoints, boost::asio::as_tuple(boost::asio::use_awaitable));
if (connect_error) {
auto failure_status = ligo::detail::operation_failure(connect_error, "HTTP request timed out");
co_return ligo::detail::fail<boost::json::value>(failure_status);
co_return ligo::detail::fail<ligo::http::response>(failure_status);
}
auto stream = boost::asio::ssl::stream<boost::asio::ip::tcp::socket>{
tcp_stream.release_socket(),
@@ -379,11 +479,11 @@ auto call_https_endpoint(ligo::http::endpoint endpoint, boost::beast::http::requ
};
auto verification = ligo::configure_tls_client_stream(stream, endpoint.host, options.security);
if (!verification) {
co_return ligo::detail::fail<boost::json::value>(verification.error());
co_return ligo::detail::fail<ligo::http::response>(verification.error());
}
auto handshake = co_await ligo::detail::async_tls_handshake(stream, boost::asio::ssl::stream_base::client, options.timeout, "HTTP TLS handshake timed out");
if (!handshake) {
co_return ligo::detail::fail<boost::json::value>(handshake.error());
co_return ligo::detail::fail<ligo::http::response>(handshake.error());
}
auto write_deadline = ligo::detail::start_stream_deadline(stream, options.timeout);
auto [write_error, written] = co_await boost::beast::http::async_write(stream, http_request, ligo::detail::deadline_token(write_deadline));
@@ -391,36 +491,36 @@ auto call_https_endpoint(ligo::http::endpoint endpoint, boost::beast::http::requ
static_cast<void>(written);
if (write_error) {
auto failure_status = ligo::detail::operation_failure(write_error, write_deadline, "HTTP request timed out");
co_return ligo::detail::fail<boost::json::value>(failure_status);
co_return ligo::detail::fail<ligo::http::response>(failure_status);
}
auto response = co_await read_http_response(stream, options.timeout, options.max_body_size);
static_cast<void>(co_await ligo::detail::async_tls_shutdown(stream, options.timeout, "HTTP TLS shutdown timed out"));
co_return response;
}
auto call_http_endpoint(ligo::http::endpoint endpoint, std::shared_ptr<ligo::http::request const> request, ligo::http::client_options options) -> boost::asio::awaitable<std::expected<boost::json::value, modulus::error>> {
auto call_http_endpoint(ligo::http::endpoint endpoint, std::shared_ptr<ligo::http::request const> request, ligo::http::client_options options) -> boost::asio::awaitable<std::expected<ligo::http::response, modulus::error>> {
assert(request != nullptr);
modulus::log_debug("ligo HTTP client call to " + endpoint.host + ":" + std::to_string(endpoint.port));
auto valid_timeout = ligo::detail::validate_positive_timeout(options.timeout, "HTTP timeout");
if (!valid_timeout) {
co_return ligo::detail::fail<boost::json::value>(valid_timeout.error());
co_return ligo::detail::fail<ligo::http::response>(valid_timeout.error());
}
if (endpoint.port == 0) {
co_return ligo::detail::fail<boost::json::value>(ligo::detail::error_invalid_argument, "HTTP port must be non-zero");
co_return ligo::detail::fail<ligo::http::response>(ligo::detail::error_invalid_argument, "HTTP port must be non-zero");
}
if (request->target.empty()) {
co_return ligo::detail::fail<boost::json::value>(ligo::detail::error_invalid_argument, "HTTP target must not be empty");
co_return ligo::detail::fail<ligo::http::response>(ligo::detail::error_invalid_argument, "HTTP target must not be empty");
}
if (request->target.front() != '/') {
co_return ligo::detail::fail<boost::json::value>(ligo::detail::error_invalid_argument, "HTTP target must start with /");
co_return ligo::detail::fail<ligo::http::response>(ligo::detail::error_invalid_argument, "HTTP target must start with /");
}
auto verb = http_verb(request->method);
if (!verb) {
co_return ligo::detail::fail<boost::json::value>(verb.error());
co_return ligo::detail::fail<ligo::http::response>(verb.error());
}
if (options.security.mode == ligo::security_mode::DTLS) {
auto status = ligo::unsupported_security("HTTP client", options.security.mode);
co_return ligo::detail::fail<boost::json::value>(status);
co_return ligo::detail::fail<ligo::http::response>(status);
}
auto http_request = make_http_request_message(endpoint, *request, *verb);
if (options.security.mode == ligo::security_mode::TLS) {
@@ -430,20 +530,20 @@ auto call_http_endpoint(ligo::http::endpoint endpoint, std::shared_ptr<ligo::htt
auto stream = boost::beast::tcp_stream{executor};
auto endpoints = co_await ligo::detail::resolve_tcp_endpoints(executor, endpoint.host, endpoint.port, options.timeout, "HTTP request timed out");
if (!endpoints) {
co_return ligo::detail::fail<boost::json::value>(endpoints.error());
co_return ligo::detail::fail<ligo::http::response>(endpoints.error());
}
stream.expires_after(options.timeout);
auto [connect_error, _] = co_await stream.async_connect(*endpoints, boost::asio::as_tuple(boost::asio::use_awaitable));
if (connect_error) {
auto failure_status = ligo::detail::operation_failure(connect_error, "HTTP request timed out");
co_return ligo::detail::fail<boost::json::value>(failure_status);
co_return ligo::detail::fail<ligo::http::response>(failure_status);
}
stream.expires_after(options.timeout);
auto [write_error, written] = co_await boost::beast::http::async_write(stream, http_request, boost::asio::as_tuple(boost::asio::use_awaitable));
static_cast<void>(written);
if (write_error) {
auto failure_status = ligo::detail::operation_failure(write_error, "HTTP request timed out");
co_return ligo::detail::fail<boost::json::value>(failure_status);
co_return ligo::detail::fail<ligo::http::response>(failure_status);
}
stream.expires_after(options.timeout);
co_return co_await read_http_response(stream, options.timeout, options.max_body_size);
@@ -458,6 +558,19 @@ ligo::http::client::client(ligo::http::endpoint endpoint, ligo::http::client_opt
: endpoint_{std::move(endpoint)}, options_{options} {}
auto ligo::http::client::call(ligo::http::request const &request) const -> boost::asio::awaitable<std::expected<boost::json::value, modulus::error>> {
auto response = co_await exchange(request);
if (!response) {
co_return std::unexpected(response.error());
}
auto error = boost::system::error_code{};
auto body = boost::json::parse(response->body, error);
if (error) {
co_return ligo::detail::fail<boost::json::value>(ligo::detail::error_invalid_argument, error.message());
}
co_return ligo::detail::unwrap_response_envelope(std::move(body), "HTTP");
}
auto ligo::http::client::exchange(ligo::http::request const &request) const -> boost::asio::awaitable<std::expected<ligo::http::response, modulus::error>> {
return call_http_endpoint(endpoint_, std::make_shared<ligo::http::request const>(request), options_);
}
+59
View File
@@ -2256,3 +2256,62 @@ BOOST_AUTO_TEST_CASE(ligo_udp_multithreaded_io_context_round_trip_succeeds) {
BOOST_REQUIRE(message);
BOOST_CHECK(message->payload.as_object().at("ok").as_bool());
}
BOOST_AUTO_TEST_CASE(ligo_response_metadata_is_preserved_by_router) {
auto io = boost::asio::io_context{};
auto router = ligo::service_router{};
router.add_response_route("GET", "/metadata", [](ligo::request_context context, boost::json::value) -> boost::asio::awaitable<std::expected<ligo::service_response, modulus::error>> {
BOOST_CHECK_EQUAL(context.headers.at("cookie"), "session=example");
BOOST_CHECK_EQUAL(context.query.at("search"), "two words");
auto response = ligo::text_response("text/plain", "accepted");
response.status = 201;
response.headers.emplace_back("Set-Cookie", "session=replacement; HttpOnly");
response.headers.emplace_back("Cache-Control", "no-store");
co_return response;
});
auto future = boost::asio::co_spawn(io, router.call_response({.method = "GET", .route = "/metadata", .headers = {{"cookie", "session=example"}}, .query = {{"search", "two words"}}}, {}), boost::asio::use_future);
io.run();
auto result = future.get();
BOOST_REQUIRE(result);
BOOST_CHECK_EQUAL(result->status, 201);
BOOST_CHECK_EQUAL(result->body, "accepted");
BOOST_CHECK_EQUAL(result->headers.size(), 2);
// The old aggregate initialization remains valid.
auto options = ligo::http::server_options{std::chrono::milliseconds{1000}, 1024, {}, ligo::security_config{}};
BOOST_CHECK(!options.parse_query);
}
BOOST_AUTO_TEST_CASE(ligo_http_raw_exchange_headers_query_and_status) {
auto io = boost::asio::io_context{};
auto router = ligo::service_router{};
router.add_response_route("GET", "/raw", [](ligo::request_context context, boost::json::value) -> boost::asio::awaitable<std::expected<ligo::service_response, modulus::error>> {
BOOST_CHECK_EQUAL(context.headers.at("if-none-match"), "revision-one");
BOOST_CHECK_EQUAL(context.query.at("search"), "two words+");
auto response = ligo::text_response("text/plain", "not a JSON envelope");
response.status = 202;
response.headers = {{"ETag", "revision-two"}, {"Set-Cookie", "sample=value; HttpOnly"}};
co_return response;
});
auto options = ligo::http::server_options{};
options.parse_query = true;
auto server = ligo::http::server{io, {"127.0.0.1", 0}, std::move(router), options};
BOOST_REQUIRE(server.start());
auto exercise = [&]() -> boost::asio::awaitable<void> {
auto client = ligo::http::client{{"127.0.0.1", server.selected_port()}};
auto result = co_await client.exchange({.method = ligo::http::method::GET, .target = "/raw?search=two+words%2B", .headers = {{"If-None-Match", "revision-one"}}});
BOOST_REQUIRE(result);
BOOST_CHECK_EQUAL(result->status, 202);
BOOST_CHECK_EQUAL(result->body, "not a JSON envelope");
BOOST_CHECK_EQUAL(result->headers.at("etag"), "revision-two");
auto invalid = co_await client.exchange({.method = ligo::http::method::GET, .target = "/raw?search=%ZZ"});
BOOST_REQUIRE(invalid);
BOOST_CHECK_EQUAL(invalid->status, 400);
auto duplicate = co_await client.exchange({.method = ligo::http::method::GET, .target = "/raw?x=1&x=2"});
BOOST_REQUIRE(duplicate);
BOOST_CHECK_EQUAL(duplicate->status, 400);
server.stop();
};
auto future = boost::asio::co_spawn(io, exercise(), boost::asio::use_future);
io.run();
future.get();
}