Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
45 changes: 45 additions & 0 deletions .github/scripts/write_failure_summary.sh
Original file line number Diff line number Diff line change
@@ -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
69 changes: 66 additions & 3 deletions .github/workflows/bazel.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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]
Expand Down
2 changes: 1 addition & 1 deletion MODULE.bazel
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
module(
name = "khttpd",
version = "0.4.3",
version = "0.4.4",
)

bazel_dep(name = "platforms", version = "1.1.0")
Expand Down
25 changes: 23 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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<T>` status/headers,
Expand Down Expand Up @@ -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

```
Expand Down
73 changes: 62 additions & 11 deletions framework/router/http_router.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -5,11 +5,39 @@
#include <algorithm>
#include <boost/beast/version.hpp>
#include <stdexcept>
#include <string_view>

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 += "&amp;"; break;
case '<': escaped += "&lt;"; break;
case '>': escaped += "&gt;"; break;
case '\"': escaped += "&quot;"; break;
case '\'': escaped += "&#39;"; 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"(<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"><title>{0} {1} · khttpd</title><style>:root{{color-scheme:light}}*{{box-sizing:border-box}}body{{min-height:100vh;margin:0;display:grid;place-items:center;padding:24px;background:radial-gradient(circle at top,#e0e7ff 0,transparent 32rem),#f8fafc;color:#172033;font:16px/1.5 system-ui,-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif}}.error-card{{width:min(100%,560px);padding:28px;border:1px solid rgba(203,213,225,.8);border-radius:20px;background:rgba(255,255,255,.94);box-shadow:0 24px 60px rgba(30,41,59,.12)}}.brand{{display:flex;align-items:center;gap:8px;color:#334155;font-size:.875rem;font-weight:700;letter-spacing:.02em}}.brand::before{{width:10px;height:10px;border-radius:999px;background:linear-gradient(135deg,#6366f1,#8b5cf6);box-shadow:0 0 0 4px #eef2ff;content:""}}.error-visual{{width:72px;height:72px;display:grid;place-items:center;margin:30px 0 18px;border-radius:50%;background:linear-gradient(135deg,#eef2ff,#f5f3ff);color:#4f46e5;font-size:1.125rem;font-weight:800;letter-spacing:.04em}}.error-code{{margin:0 0 8px;color:#64748b;font-size:.75rem;font-weight:800;letter-spacing:.1em;text-transform:uppercase}}h1{{margin:0;color:#0f172a;font-size:1.75rem;line-height:1.2;letter-spacing:-.025em}}.message{{margin:12px 0 0;color:#475569}}code{{display:block;overflow-wrap:anywhere;margin-top:22px;padding:11px 12px;border:1px solid #e2e8f0;border-radius:9px;background:#f8fafc;color:#334155;font:0.8125rem/1.4 ui-monospace,SFMono-Regular,Menlo,monospace}}.error-footer{{display:flex;align-items:center;justify-content:space-between;gap:16px;margin-top:24px;color:#94a3b8;font-size:.8125rem}}.home-link{{display:inline-flex;align-items:center;padding:8px 11px;border-radius:8px;background:#4f46e5;color:#fff;font-weight:700;text-decoration:none}}.home-link:hover{{background:#4338ca}}@media (max-width:480px){{body{{padding:16px}}.error-card{{padding:24px}}}}</style></head><body><main class="error-card"><div class="brand">khttpd</div><div class="error-visual" aria-hidden="true">{0}</div><p class="error-code">HTTP {0}</p><h1>{1}</h1><p class="message">{2}</p><code>{3}</code><footer class="error-footer"><a class="home-link" href="/">Return home</a><span>HTTP {0}</span></footer></main></body></html>)",
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);
Expand All @@ -36,7 +64,7 @@ namespace khttpd::framework
{
std::string regex_str = "^";
std::vector<std::string> 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;
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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("<h1>404 Not Found</h1><p>The resource '{}' was not found on this server.</p>",
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<boost::beast::http::verb, HttpHandler>& allowed_methods)
void HttpRouter::handle_method_not_allowed(
HttpContext& ctx, const std::map<boost::beast::http::verb, HttpHandler>& allowed_methods) const
{
ctx.set_status(boost::beast::http::status::method_not_allowed);
ctx.set_content_type("text/html");
ctx.set_body(fmt::format("<h1>405 Method Not Allowed</h1><p>Method {} not allowed for resource '{}'.</p>",
boost::beast::http::to_string(ctx.method()), ctx.path()));

std::string allowed_methods_str;
bool first = true;
Expand All @@ -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<ExceptionHandlerBase> handler)
Expand All @@ -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);
Expand Down
13 changes: 10 additions & 3 deletions framework/router/http_router.hpp
Original file line number Diff line number Diff line change
Expand Up @@ -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<bool()>& static_file_fun = nullptr) const;
bool dispatch_async(HttpContext& ctx, HttpAsyncComplete complete) const;

Expand All @@ -295,6 +300,8 @@ namespace khttpd::framework

std::vector<std::shared_ptr<ExceptionHandlerBase>> 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<boost::json::value> request_schema = std::nullopt,
Expand All @@ -311,9 +318,9 @@ namespace khttpd::framework
static std::tuple<std::regex, std::vector<std::string>, 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<boost::beast::http::verb, HttpHandler>& allowed_methods);
void handle_not_found(HttpContext& ctx) const;
void handle_method_not_allowed(HttpContext& ctx,
const std::map<boost::beast::http::verb, HttpHandler>& allowed_methods) const;
};
}
#endif // KHTTPD_FRAMEWORK_ROUTER_HTTP_ROUT
6 changes: 5 additions & 1 deletion framework/session/http_session.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
Loading
Loading