From 415528dbac963165cfcac10e26b3264fcecbbd71 Mon Sep 17 00:00:00 2001 From: kekxv Date: Wed, 19 Aug 2026 11:44:48 +0000 Subject: [PATCH 1/5] ci: publish failure details --- .github/scripts/write_failure_summary.sh | 45 ++++++++++++++++ .github/workflows/bazel.yml | 69 ++++++++++++++++++++++-- 2 files changed, 111 insertions(+), 3 deletions(-) create mode 100755 .github/scripts/write_failure_summary.sh diff --git a/.github/scripts/write_failure_summary.sh b/.github/scripts/write_failure_summary.sh new file mode 100755 index 0000000..4266595 --- /dev/null +++ b/.github/scripts/write_failure_summary.sh @@ -0,0 +1,45 @@ +#!/usr/bin/env bash +set -euo pipefail + +summary_file="${GITHUB_STEP_SUMMARY:?GITHUB_STEP_SUMMARY must be set}" +test_result="${TEST_RESULT:-skipped}" +build_result="${BUILD_RESULT:-skipped}" +artifact_name="${FAILED_LOG_ARTIFACT:-khttpd-test-diagnostics}" + +if [[ "${test_result}" == "failure" ]]; then + { + echo + echo "### Failed test details" + echo + if [[ -s ci-test.log ]]; then + echo "#### Matching failure lines" + echo + echo '```text' + grep -Ei 'FAIL:|FAILED|Failure|error:' ci-test.log | head -n 40 || true + echo '```' + echo + echo "#### Test log tail (last 80 lines)" + echo + echo '```text' + tail -n 80 ci-test.log + echo '```' + else + echo "- No captured test output was found. Check the failed test step log." + fi + echo "- Complete logs are available in the \`${artifact_name}\` artifact." + } >> "${summary_file}" +elif [[ "${build_result}" == "failure" ]]; then + { + echo + echo "### Build failure details" + echo + if [[ -s ci-build.log ]]; then + echo '```text' + tail -n 80 ci-build.log + echo '```' + else + echo "- No captured build output was found. Check the failed build step log." + fi + echo "- Complete logs are available in the \`${artifact_name}\` artifact." + } >> "${summary_file}" +fi diff --git a/.github/workflows/bazel.yml b/.github/workflows/bazel.yml index 4b39e25..a3664ab 100644 --- a/.github/workflows/bazel.yml +++ b/.github/workflows/bazel.yml @@ -60,8 +60,41 @@ jobs: build --@boost.asio//:ssl=boringssl - name: Bazel Test + id: test + shell: bash run: | - bazel test //framework/... --test_output=errors --test_verbose_timeout_warnings --verbose_failures + set -o pipefail + bazel test //framework/... --test_output=errors --test_verbose_timeout_warnings --verbose_failures 2>&1 | tee ci-test.log + - name: Upload failed test logs + if: failure() + uses: actions/upload-artifact@v4 + with: + name: khttpd-${{ matrix.os }}-test-diagnostics + path: | + bazel-testlogs/** + ci-test.log + if-no-files-found: ignore + retention-days: 3 + - name: Publish test summary + if: always() + shell: bash + env: + TEST_RESULT: ${{ steps.test.outcome }} + JOB_RESULT: ${{ job.status }} + run: | + { + echo "## khttpd framework tests (${{ matrix.os }})" + echo + echo "| Stage | Result |" + echo "| --- | --- |" + echo "| Test | ${TEST_RESULT} |" + echo "| Job | ${JOB_RESULT} |" + echo + echo "- Ref: \`${GITHUB_REF}\`" + echo "- Commit: \`${GITHUB_SHA}\`" + } >> "${GITHUB_STEP_SUMMARY}" + FAILED_LOG_ARTIFACT=khttpd-${{ matrix.os }}-test-diagnostics \ + .github/scripts/write_failure_summary.sh example: needs: build strategy: @@ -99,11 +132,41 @@ jobs: #build --@boost.mysql//:ssl=boringssl build --@boost.asio//:ssl=boringssl - name: Bazel Build example + id: build_example + shell: bash run: | + set -o pipefail cp .bazelrc ./example cd example - bazel build app - cd .. + bazel build app 2>&1 | tee ../ci-build.log + - name: Upload failed example build logs + if: failure() + uses: actions/upload-artifact@v4 + with: + name: khttpd-${{ matrix.os }}-example-build-diagnostics + path: ci-build.log + if-no-files-found: ignore + retention-days: 3 + - name: Publish example summary + if: always() + shell: bash + env: + BUILD_RESULT: ${{ steps.build_example.outcome }} + JOB_RESULT: ${{ job.status }} + run: | + { + echo "## khttpd example build (${{ matrix.os }})" + echo + echo "| Stage | Result |" + echo "| --- | --- |" + echo "| Build | ${BUILD_RESULT} |" + echo "| Job | ${JOB_RESULT} |" + echo + echo "- Ref: \`${GITHUB_REF}\`" + echo "- Commit: \`${GITHUB_SHA}\`" + } >> "${GITHUB_STEP_SUMMARY}" + FAILED_LOG_ARTIFACT=khttpd-${{ matrix.os }}-example-build-diagnostics \ + .github/scripts/write_failure_summary.sh release: needs: [build, example] From 5a379b4b98f29afc00a29965828814fea47233b4 Mon Sep 17 00:00:00 2001 From: kekxv Date: Thu, 20 Aug 2026 03:57:44 +0000 Subject: [PATCH 2/5] fix: frame empty HTTP responses --- framework/session/http_session.cpp | 6 +++++- framework/tests/session_test.cpp | 17 +++++++++++++++++ 2 files changed, 22 insertions(+), 1 deletion(-) diff --git a/framework/session/http_session.cpp b/framework/session/http_session.cpp index b8ea40c..411127c 100644 --- a/framework/session/http_session.cpp +++ b/framework/session/http_session.cpp @@ -455,7 +455,11 @@ void HttpSession::send_context_response() return send_response(std::move(head)); } if (res_.chunked()) send_chunked_response(); - else send_response(std::move(res_)); + else + { + if (!res_.has_content_length()) res_.prepare_payload(); + send_response(std::move(res_)); + } } // Extract path from request target (query-stripped) diff --git a/framework/tests/session_test.cpp b/framework/tests/session_test.cpp index 567bf26..6e00765 100644 --- a/framework/tests/session_test.cpp +++ b/framework/tests/session_test.cpp @@ -212,6 +212,23 @@ TEST(HttpSessionTest, DynamicHeadUsesGetMetadataWithoutSendingBody) EXPECT_EQ(res[http::field::content_length], "14"); } +TEST(HttpSessionTest, EmptyRouteWritesZeroContentLength) +{ + TempStaticTree tree; + khttpd_fw::HttpRouter router; + khttpd_fw::WebsocketRouter websocket_router; + router.get("/empty", [](khttpd_fw::HttpContext&) {}); + + http::request req{http::verb::get, "/empty", 11}; + req.keep_alive(false); + auto res = round_trip(router, websocket_router, tree.web, std::move(req)); + + EXPECT_EQ(res.result(), http::status::ok); + ASSERT_TRUE(res.has_content_length()); + EXPECT_EQ(res[http::field::content_length], "0"); + EXPECT_TRUE(res.body().empty()); +} + TEST(HttpSessionTest, AsyncInterceptorSeesTransportPeerAndCanDenyRequest) { struct RemoteAuth final : khttpd_fw::Interceptor From 4aaa3f478b94dada35a534bb2530526f782ed7a0 Mon Sep 17 00:00:00 2001 From: kekxv Date: Thu, 20 Aug 2026 06:36:22 +0000 Subject: [PATCH 3/5] feat: support brace route parameters and modern error pages --- framework/router/http_router.cpp | 40 +++++++++++++++++++++++++++----- framework/tests/router_test.cpp | 34 +++++++++++++++++++++++++++ 2 files changed, 68 insertions(+), 6 deletions(-) diff --git a/framework/router/http_router.cpp b/framework/router/http_router.cpp index 0d2d482..ecd4626 100644 --- a/framework/router/http_router.cpp +++ b/framework/router/http_router.cpp @@ -5,11 +5,39 @@ #include #include #include +#include namespace khttpd::framework { namespace { + std::string escape_html(const std::string_view value) + { + std::string escaped; + escaped.reserve(value.size()); + for (const char character : value) + { + switch (character) + { + case '&': escaped += "&"; break; + case '<': escaped += "<"; break; + case '>': escaped += ">"; break; + case '\"': escaped += """; break; + case '\'': escaped += "'"; break; + default: escaped += character; break; + } + } + return escaped; + } + + std::string make_html_error_page(const int status, const std::string_view title, + const std::string_view message, const std::string_view detail) + { + return fmt::format( + R"({0} {1}

HTTP {0}

{1}

{2}

{3}
)", + status, escape_html(title), escape_html(message), escape_html(detail)); + } + void write_internal_server_error(HttpContext& ctx) { ctx.set_status(boost::beast::http::status::internal_server_error); @@ -36,7 +64,7 @@ namespace khttpd::framework { std::string regex_str = "^"; std::vector param_names; - std::regex param_regex(":([a-zA-Z_][a-zA-Z0-9_]*)"); // search :paramName + std::regex param_regex(R"((?::([a-zA-Z_][a-zA-Z0-9_]*)|\{([a-zA-Z_][a-zA-Z0-9_]*)\}))"); int literal_segments = 0; int dynamic_segments = 0; @@ -79,7 +107,7 @@ namespace khttpd::framework } regex_str += std::regex_replace(literal_part, escape_regex, "\\$&"); - param_names.push_back(it->str().substr(1)); + param_names.push_back((*it)[1].matched ? (*it)[1].str() : (*it)[2].str()); dynamic_segments++; if (current_param_index == param_count - 1) @@ -485,8 +513,7 @@ namespace khttpd::framework { ctx.set_status(boost::beast::http::status::not_found); ctx.set_content_type("text/html"); - ctx.set_body(fmt::format("

404 Not Found

The resource '{}' was not found on this server.

", - ctx.path())); + ctx.set_body(make_html_error_page(404, "Not Found", "The requested resource could not be found.", ctx.path())); spdlog::warn("404 Not Found: {}", ctx.path()); } @@ -495,8 +522,9 @@ namespace khttpd::framework { ctx.set_status(boost::beast::http::status::method_not_allowed); ctx.set_content_type("text/html"); - ctx.set_body(fmt::format("

405 Method Not Allowed

Method {} not allowed for resource '{}'.

", - boost::beast::http::to_string(ctx.method()), ctx.path())); + ctx.set_body(make_html_error_page(405, "Method Not Allowed", + "The request method is not allowed for this resource.", + fmt::format("{} {}", boost::beast::http::to_string(ctx.method()), ctx.path()))); std::string allowed_methods_str; bool first = true; diff --git a/framework/tests/router_test.cpp b/framework/tests/router_test.cpp index 0276a45..67b503f 100644 --- a/framework/tests/router_test.cpp +++ b/framework/tests/router_test.cpp @@ -122,6 +122,39 @@ TEST(HttpRouterTest, DynamicRouteMatchingAndParamExtraction) ASSERT_EQ(ctx2.get_query_param("name").value(), "test"); // Query param still works } +TEST(HttpRouterTest, BraceStyleDynamicRouteExtractsPathParameter) +{ + khttpd_fw::HttpRouter router; + std::string role; + router.get("/roles/{role}/permissions", [&](khttpd_fw::HttpContext& ctx) + { + role = ctx.get_path_param("role").value_or(""); + ctx.set_status(http::status::ok); + }); + + auto req = make_request(http::verb::get, "/roles/admin/permissions"); + http::response res; + auto ctx = create_http_context(req, res); + router.dispatch(ctx); + + EXPECT_EQ(res.result(), http::status::ok); + EXPECT_EQ(role, "admin"); +} + +TEST(HttpRouterTest, NotFoundUsesModernHtmlErrorPage) +{ + khttpd_fw::HttpRouter router; + auto req = make_request(http::verb::get, "/missing"); + http::response res; + auto ctx = create_http_context(req, res); + router.dispatch(ctx); + + EXPECT_EQ(res.result(), http::status::not_found); + EXPECT_EQ(res[http::field::content_type], "text/html"); + EXPECT_NE(res.body().find("error-card"), std::string::npos); + EXPECT_NE(res.body().find("404"), std::string::npos); +} + TEST(HttpRouterTest, RouteSpecificity) { khttpd_fw::HttpRouter router; @@ -230,6 +263,7 @@ TEST(HttpRouterTest, MethodNotAllowed) router.dispatch(ctx); ASSERT_EQ(ctx.get_response().result(), http::status::method_not_allowed); + ASSERT_NE(ctx.get_response().body().find("error-card"), std::string::npos); ASSERT_TRUE(ctx.get_response().find(http::field::allow) != ctx.get_response().end()); // Order might vary, but should contain GET and POST std::string allow_header = std::string(ctx.get_response()[http::field::allow]); From e93381011cff168e25619155051672b25d6c50a7 Mon Sep 17 00:00:00 2001 From: kekxv Date: Thu, 20 Aug 2026 07:22:44 +0000 Subject: [PATCH 4/5] feat: allow custom router error pages --- README.md | 25 ++++++++++++++++-- framework/router/http_router.cpp | 43 +++++++++++++++++++++++------- framework/router/http_router.hpp | 13 ++++++--- framework/tests/router_test.cpp | 45 ++++++++++++++++++++++++++++++++ 4 files changed, 111 insertions(+), 15 deletions(-) diff --git a/README.md b/README.md index da7f877..284a622 100644 --- a/README.md +++ b/README.md @@ -10,8 +10,8 @@ and [Boost.Asio](https://www.boost.org/doc/libs/release/libs/asio/), managed wit - **HTTP Server** — Multi-threaded, async I/O server powered by Boost.Asio strand-based concurrency - **WebSocket Support** — Dynamic routes, handshake metadata, typed text/binary/control frames, and full lifecycle management -- **Routing** — Express-style route registration with path parameters (`/users/:id`), query params, and method - specificity sorting +- **Routing** — Express-style route registration with path parameters (`/users/:id` or `/users/{id}`), query params, + and method specificity sorting - **Controller Pattern** — CRTP-based `BaseController` with `KHTTPD_ROUTE` / `KHTTPD_WSROUTE` macros for clean route definitions - **Typed JSON Routes** — Request/response inference for lambdas and controller members, `HttpResult` status/headers, @@ -128,6 +128,27 @@ bazel build //:your_target bazel run //:your_target ``` +### Custom error pages + +The router renders responsive HTML pages for 404 and 405 responses by default. A service can replace either response +without reimplementing route matching. The framework sets the status before calling the handler, and it sets `Allow` +before calling a 405 handler: + +```cpp +router.set_not_found_handler([](khttpd::framework::HttpContext& ctx) { + ctx.set_content_type("application/json"); + ctx.set_body(R"({"error":"not_found"})"); // Status is already 404. +}); + +router.set_method_not_allowed_handler([](khttpd::framework::HttpContext& ctx) { + ctx.set_content_type("application/json"); + ctx.set_body(R"({"error":"method_not_allowed"})"); // Status is 405; Allow is preserved. +}); +``` + +Pass an empty `khttpd::framework::HttpHandler{}` to either setter to restore the framework default. Custom handlers can inspect the request +path and headers, so an auth service can return branded HTML to browsers and a JSON error envelope to API clients. + ## Architecture ``` diff --git a/framework/router/http_router.cpp b/framework/router/http_router.cpp index ecd4626..15f8cdf 100644 --- a/framework/router/http_router.cpp +++ b/framework/router/http_router.cpp @@ -34,7 +34,7 @@ namespace khttpd::framework const std::string_view message, const std::string_view detail) { return fmt::format( - R"({0} {1}

HTTP {0}

{1}

{2}

{3}
)", + R"({0} {1} · khttpd
khttpd

HTTP {0}

{1}

{2}

{3}
)", status, escape_html(title), escape_html(message), escape_html(detail)); } @@ -509,22 +509,24 @@ namespace khttpd::framework return true; } - void HttpRouter::handle_not_found(HttpContext& ctx) + void HttpRouter::handle_not_found(HttpContext& ctx) const { ctx.set_status(boost::beast::http::status::not_found); - ctx.set_content_type("text/html"); - ctx.set_body(make_html_error_page(404, "Not Found", "The requested resource could not be found.", ctx.path())); spdlog::warn("404 Not Found: {}", ctx.path()); + if (not_found_handler_) + { + not_found_handler_(ctx); + return; + } + ctx.set_content_type("text/html"); + ctx.set_body(make_html_error_page(404, "Page not found", "The requested resource could not be found.", + ctx.path())); } - void HttpRouter::handle_method_not_allowed(HttpContext& ctx, - const std::map& allowed_methods) + void HttpRouter::handle_method_not_allowed( + HttpContext& ctx, const std::map& allowed_methods) const { ctx.set_status(boost::beast::http::status::method_not_allowed); - ctx.set_content_type("text/html"); - ctx.set_body(make_html_error_page(405, "Method Not Allowed", - "The request method is not allowed for this resource.", - fmt::format("{} {}", boost::beast::http::to_string(ctx.method()), ctx.path()))); std::string allowed_methods_str; bool first = true; @@ -537,6 +539,17 @@ namespace khttpd::framework ctx.set_header(boost::beast::http::field::allow, allowed_methods_str); spdlog::warn("405 Method Not Allowed: {} {}", std::string(boost::beast::http::to_string(ctx.method())), ctx.path()); + + if (method_not_allowed_handler_) + { + method_not_allowed_handler_(ctx); + return; + } + + ctx.set_content_type("text/html"); + ctx.set_body(make_html_error_page(405, "Method Not Allowed", + "The request method is not allowed for this resource.", + fmt::format("{} {}", boost::beast::http::to_string(ctx.method()), ctx.path()))); } void HttpRouter::add_exception_handler(std::shared_ptr handler) @@ -549,6 +562,16 @@ namespace khttpd::framework unknown_exception_handler_ = std::move(handler); } + void HttpRouter::set_not_found_handler(HttpHandler handler) + { + not_found_handler_ = std::move(handler); + } + + void HttpRouter::set_method_not_allowed_handler(HttpHandler handler) + { + method_not_allowed_handler_ = std::move(handler); + } + void HttpRouter::handle_exception(std::exception_ptr eptr, HttpContext& ctx) const { reset_exception_response(ctx); diff --git a/framework/router/http_router.hpp b/framework/router/http_router.hpp index f42de1a..b25b090 100644 --- a/framework/router/http_router.hpp +++ b/framework/router/http_router.hpp @@ -275,6 +275,11 @@ namespace khttpd::framework void handle_exception(std::exception_ptr eptr, HttpContext& ctx) const; void handle_unknown_exception(HttpContext& ctx) const; + // Optional service-level replacements for the framework's default HTML error pages. Pass an empty handler to reset. + // The context is pre-populated with the corresponding HTTP status. The 405 handler also receives Allow. + void set_not_found_handler(HttpHandler handler); + void set_method_not_allowed_handler(HttpHandler handler); + bool dispatch(HttpContext& ctx, const std::function& static_file_fun = nullptr) const; bool dispatch_async(HttpContext& ctx, HttpAsyncComplete complete) const; @@ -295,6 +300,8 @@ namespace khttpd::framework std::vector> exception_handlers_; UnknownExceptionHandler unknown_exception_handler_; + HttpHandler not_found_handler_; + HttpHandler method_not_allowed_handler_; void add_route(const std::string& path_pattern, boost::beast::http::verb method, HttpHandler handler, std::optional request_schema = std::nullopt, @@ -311,9 +318,9 @@ namespace khttpd::framework static std::tuple, int, int> parse_path_pattern( const std::string& path_pattern); - static void handle_not_found(HttpContext& ctx); - static void handle_method_not_allowed(HttpContext& ctx, - const std::map& allowed_methods); + void handle_not_found(HttpContext& ctx) const; + void handle_method_not_allowed(HttpContext& ctx, + const std::map& allowed_methods) const; }; } #endif // KHTTPD_FRAMEWORK_ROUTER_HTTP_ROUT diff --git a/framework/tests/router_test.cpp b/framework/tests/router_test.cpp index 67b503f..35b7798 100644 --- a/framework/tests/router_test.cpp +++ b/framework/tests/router_test.cpp @@ -153,6 +153,51 @@ TEST(HttpRouterTest, NotFoundUsesModernHtmlErrorPage) EXPECT_EQ(res[http::field::content_type], "text/html"); EXPECT_NE(res.body().find("error-card"), std::string::npos); EXPECT_NE(res.body().find("404"), std::string::npos); + EXPECT_NE(res.body().find("khttpd"), std::string::npos); + EXPECT_NE(res.body().find("Page not found"), std::string::npos); + EXPECT_NE(res.body().find("Return home"), std::string::npos); + EXPECT_NE(res.body().find("/missing"), std::string::npos); +} + +TEST(HttpRouterTest, CustomNotFoundHandlerReplacesDefaultResponse) +{ + khttpd_fw::HttpRouter router; + router.set_not_found_handler([](khttpd_fw::HttpContext& ctx) + { + ctx.set_content_type("application/json"); + ctx.set_body(R"({"error":"custom"})"); + }); + + auto req = make_request(http::verb::get, "/missing"); + http::response res; + auto ctx = create_http_context(req, res); + router.dispatch(ctx); + + EXPECT_EQ(res.result(), http::status::not_found); + EXPECT_EQ(res[http::field::content_type], "application/json"); + EXPECT_EQ(res.body(), R"({"error":"custom"})"); +} + +TEST(HttpRouterTest, CustomMethodNotAllowedHandlerKeepsAllowHeader) +{ + khttpd_fw::HttpRouter router; + std::string allowed_during_handler; + router.get("/resource", []([[maybe_unused]] khttpd_fw::HttpContext& ctx) {}); + router.set_method_not_allowed_handler([&allowed_during_handler](khttpd_fw::HttpContext& ctx) + { + allowed_during_handler = std::string(ctx.get_response()[http::field::allow]); + ctx.set_body("custom method response"); + }); + + auto req = make_request(http::verb::post, "/resource"); + http::response res; + auto ctx = create_http_context(req, res); + router.dispatch(ctx); + + EXPECT_EQ(res.result(), http::status::method_not_allowed); + EXPECT_EQ(res.body(), "custom method response"); + EXPECT_EQ(allowed_during_handler, "GET"); + EXPECT_EQ(res[http::field::allow], "GET"); } TEST(HttpRouterTest, RouteSpecificity) From c9f4076b17cb2da839c5d2bca5abb3a0bf6d4dca Mon Sep 17 00:00:00 2001 From: kekxv Date: Thu, 20 Aug 2026 07:25:55 +0000 Subject: [PATCH 5/5] 0.4.4 --- MODULE.bazel | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/MODULE.bazel b/MODULE.bazel index 59c36f5..498a022 100644 --- a/MODULE.bazel +++ b/MODULE.bazel @@ -1,6 +1,6 @@ module( name = "khttpd", - version = "0.4.3", + version = "0.4.4", ) bazel_dep(name = "platforms", version = "1.1.0")