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] 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") 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 0d2d482..15f8cdf 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} · khttpd
khttpd

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) @@ -481,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(fmt::format("

404 Not Found

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

", - 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(fmt::format("

405 Method Not Allowed

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

", - boost::beast::http::to_string(ctx.method()), ctx.path())); std::string allowed_methods_str; bool first = true; @@ -509,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) @@ -521,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/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/router_test.cpp b/framework/tests/router_test.cpp index 0276a45..35b7798 100644 --- a/framework/tests/router_test.cpp +++ b/framework/tests/router_test.cpp @@ -122,6 +122,84 @@ 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); + 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) { khttpd_fw::HttpRouter router; @@ -230,6 +308,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]); 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