From bff6884c0969325442d586cc4213b305706522cd Mon Sep 17 00:00:00 2001 From: khodowany Date: Sun, 4 Oct 2026 23:19:34 -0500 Subject: [PATCH] Tweak --- README.md | 6 ++ include/ligo/http.hpp | 12 +++ include/ligo/service.hpp | 6 ++ src/http.cpp | 165 +++++++++++++++++++++++++++++++++------ tests/test.cpp | 59 ++++++++++++++ 5 files changed, 222 insertions(+), 26 deletions(-) diff --git a/README.md b/README.md index 98df9e5..beb9606 100644 --- a/README.md +++ b/README.md @@ -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. diff --git a/include/ligo/http.hpp b/include/ligo/http.hpp index fe35618..9b69786 100755 --- a/include/ligo/http.hpp +++ b/include/ligo/http.hpp @@ -4,6 +4,8 @@ #include #include #include +#include +#include #include #include @@ -37,6 +39,14 @@ struct request { ligo::http::method method{ligo::http::method::POST}; std::string target{"/"}; boost::json::value payload{}; + std::map headers{}; + std::optional raw_body{}; +}; + +struct response { + std::uint16_t status{}; + std::map 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 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>; + auto exchange(ligo::http::request const &request) const -> boost::asio::awaitable>; private: ligo::http::endpoint endpoint_{}; ligo::http::client_options options_{}; diff --git a/include/ligo/service.hpp b/include/ligo/service.hpp index 75e5810..7d98637 100755 --- a/include/ligo/service.hpp +++ b/include/ligo/service.hpp @@ -7,6 +7,8 @@ #include #include #include +#include +#include #include #include @@ -20,11 +22,15 @@ struct request_context { std::string peer{}; std::string method{}; std::string route{}; + std::map headers{}; + std::map query{}; }; struct service_response { std::string content_type{"application/json"}; std::string body{}; + std::uint16_t status{200}; + std::vector> headers{}; }; class service_router { diff --git a/src/http.cpp b/src/http.cpp index 4a6b019..2818358 100755 --- a/src/http.cpp +++ b/src/http.cpp @@ -1,6 +1,9 @@ #include #include +#include +#include +#include #include #include #include @@ -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(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(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 .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 -auto read_http_response(STREAM &stream, std::chrono::milliseconds timeout, std::uint64_t max_body_size) -> boost::asio::awaitable> { +auto read_http_response(STREAM &stream, std::chrono::milliseconds timeout, std::uint64_t max_body_size) -> boost::asio::awaitable> { auto buffer = boost::beast::flat_buffer{}; auto parser = boost::beast::http::response_parser{}; 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(failure_status); + co_return ligo::detail::fail(failure_status); } auto failure_status = ligo::detail::operation_failure(read_error, read_deadline, "HTTP request timed out"); - co_return ligo::detail::fail(failure_status); + co_return ligo::detail::fail(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(ligo::detail::error_invalid_argument, parse_error.message()); + auto response = ligo::http::response{}; + response.status = static_cast(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 http_request, ligo::http::client_options options) -> boost::asio::awaitable> { +auto call_https_endpoint(ligo::http::endpoint endpoint, boost::beast::http::request http_request, ligo::http::client_options options) -> boost::asio::awaitable> { auto context = ligo::make_tls_client_context(options.security); if (!context) { - co_return ligo::detail::fail(context.error()); + co_return ligo::detail::fail(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(endpoints.error()); + co_return ligo::detail::fail(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(failure_status); + co_return ligo::detail::fail(failure_status); } auto stream = boost::asio::ssl::stream{ 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(verification.error()); + co_return ligo::detail::fail(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(handshake.error()); + co_return ligo::detail::fail(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(written); if (write_error) { auto failure_status = ligo::detail::operation_failure(write_error, write_deadline, "HTTP request timed out"); - co_return ligo::detail::fail(failure_status); + co_return ligo::detail::fail(failure_status); } auto response = co_await read_http_response(stream, options.timeout, options.max_body_size); static_cast(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 request, ligo::http::client_options options) -> boost::asio::awaitable> { +auto call_http_endpoint(ligo::http::endpoint endpoint, std::shared_ptr request, ligo::http::client_options options) -> boost::asio::awaitable> { 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(valid_timeout.error()); + co_return ligo::detail::fail(valid_timeout.error()); } if (endpoint.port == 0) { - co_return ligo::detail::fail(ligo::detail::error_invalid_argument, "HTTP port must be non-zero"); + co_return ligo::detail::fail(ligo::detail::error_invalid_argument, "HTTP port must be non-zero"); } if (request->target.empty()) { - co_return ligo::detail::fail(ligo::detail::error_invalid_argument, "HTTP target must not be empty"); + co_return ligo::detail::fail(ligo::detail::error_invalid_argument, "HTTP target must not be empty"); } if (request->target.front() != '/') { - co_return ligo::detail::fail(ligo::detail::error_invalid_argument, "HTTP target must start with /"); + co_return ligo::detail::fail(ligo::detail::error_invalid_argument, "HTTP target must start with /"); } auto verb = http_verb(request->method); if (!verb) { - co_return ligo::detail::fail(verb.error()); + co_return ligo::detail::fail(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(status); + co_return ligo::detail::fail(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(endpoints.error()); + co_return ligo::detail::fail(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(failure_status); + co_return ligo::detail::fail(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(written); if (write_error) { auto failure_status = ligo::detail::operation_failure(write_error, "HTTP request timed out"); - co_return ligo::detail::fail(failure_status); + co_return ligo::detail::fail(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> { + 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(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> { return call_http_endpoint(endpoint_, std::make_shared(request), options_); } diff --git a/tests/test.cpp b/tests/test.cpp index 91003b0..7985220 100644 --- a/tests/test.cpp +++ b/tests/test.cpp @@ -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> { + 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> { + 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 { + 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(); +}