From ce88076911c427d30685609ffa6f35fff6e61bd3 Mon Sep 17 00:00:00 2001 From: kekxv Date: Tue, 18 Aug 2026 14:58:21 +0000 Subject: [PATCH] feat: document OpenAPI request headers --- MODULE.bazel | 2 +- README.md | 13 +++++ example/MODULE.bazel | 2 +- framework/router/http_router.cpp | 3 +- framework/router/http_router.hpp | 8 +++ framework/router/openapi.cpp | 84 +++++++++++++++++++++++--------- framework/tests/openapi_test.cpp | 30 ++++++++++++ 7 files changed, 117 insertions(+), 25 deletions(-) diff --git a/MODULE.bazel b/MODULE.bazel index c541663..5a4ef2c 100644 --- a/MODULE.bazel +++ b/MODULE.bazel @@ -1,6 +1,6 @@ module( name = "khttpd", - version = "0.4.0", + version = "0.4.1", ) bazel_dep(name = "platforms", version = "1.1.0") diff --git a/README.md b/README.md index a6f0531..ec970f3 100644 --- a/README.md +++ b/README.md @@ -281,6 +281,19 @@ router.post("/messages", handle_message, {"Send a message", "Accepts a message and returns its delivery result."}); ``` +Document request headers with a name, description, and required flag. They are emitted as OpenAPI `in: header` +parameters and appear in the interactive `/docs` request form: + +```cpp +router.post("/tokens", create_token, + {"Create token", "Creates an access token.", + {{"Authorization", "Bearer access token.", true}, + {"X-Request-Id", "Optional caller correlation identifier.", false}}}); +``` + +Header metadata documents the API only; it does not authenticate or validate incoming requests. Read and validate the +header in the handler (for example, with `HttpContext::get_header`) as part of the service's normal authorization flow. + For an already registered async or stream route, call `router.document_route("/messages", boost::beast::http::verb::post, {"Send a message", "..."})` afterwards. Controllers can use `KHTTPD_DOCUMENTED_ROUTE` or `KHTTPD_DOCUMENTED_TYPED_ROUTE` with the same `RouteDocumentation` value. diff --git a/example/MODULE.bazel b/example/MODULE.bazel index eba5680..82d9550 100644 --- a/example/MODULE.bazel +++ b/example/MODULE.bazel @@ -8,7 +8,7 @@ bazel_dep(name = "boost", version = "1.90.0.bcr.1") bazel_dep(name = "boost.asio", version = "1.90.0.bcr.1") bazel_dep(name = "boost.mysql", version = "1.90.0.bcr.1") bazel_dep(name = "spdlog", version = "1.17.0") -bazel_dep(name = "khttpd", version = "0.4.0") +bazel_dep(name = "khttpd", version = "0.4.1") local_path_override( module_name = "khttpd", path = "..", diff --git a/framework/router/http_router.cpp b/framework/router/http_router.cpp index a88f5f9..8feeb14 100644 --- a/framework/router/http_router.cpp +++ b/framework/router/http_router.cpp @@ -185,7 +185,8 @@ namespace khttpd::framework { descriptor.request_schema = std::move(request_schema); descriptor.response_schema = std::move(response_schema); - if (!documentation.summary.empty() || !documentation.description.empty()) + if (!documentation.summary.empty() || !documentation.description.empty() || + !documentation.headers.empty()) descriptor.documentation = std::move(documentation); return; } diff --git a/framework/router/http_router.hpp b/framework/router/http_router.hpp index db1c3a4..1781bbc 100644 --- a/framework/router/http_router.hpp +++ b/framework/router/http_router.hpp @@ -32,10 +32,18 @@ namespace khttpd::framework std::shared_ptr, HttpStreamComplete)>; using UnknownExceptionHandler = std::function; + struct RouteHeader + { + std::string name; + std::string description; + bool required = false; + }; + struct RouteDocumentation { std::string summary; std::string description; + std::vector headers; }; struct RouteDescriptor diff --git a/framework/router/openapi.cpp b/framework/router/openapi.cpp index 262c78c..9d39435 100644 --- a/framework/router/openapi.cpp +++ b/framework/router/openapi.cpp @@ -60,21 +60,30 @@ namespace khttpd::framework operation.emplace("summary", descriptor.documentation.summary); if (!descriptor.documentation.description.empty()) operation.emplace("description", descriptor.documentation.description); - if (!path_parameters.empty()) + boost::json::array parameters; + for (std::size_t index = 0; index < path_parameters.size(); ++index) { - boost::json::array parameters; - for (std::size_t index = 0; index < path_parameters.size(); ++index) - { - boost::json::object parameter; - parameter.emplace("name", path_parameters[index]); - parameter.emplace("in", "path"); - parameter.emplace("required", true); - parameter.emplace("schema", boost::json::object{{"type", "string"}}); - if (index + 1 == path_parameters.size()) parameter.emplace("x-khttpd-greedy", true); - parameters.emplace_back(std::move(parameter)); - } - operation.emplace("parameters", std::move(parameters)); + boost::json::object parameter; + parameter.emplace("name", path_parameters[index]); + parameter.emplace("in", "path"); + parameter.emplace("required", true); + parameter.emplace("schema", boost::json::object{{"type", "string"}}); + if (index + 1 == path_parameters.size()) parameter.emplace("x-khttpd-greedy", true); + parameters.emplace_back(std::move(parameter)); } + for (const auto& header : descriptor.documentation.headers) + { + if (header.name.empty()) continue; + boost::json::object parameter; + parameter.emplace("name", header.name); + parameter.emplace("in", "header"); + if (!header.description.empty()) parameter.emplace("description", header.description); + parameter.emplace("required", header.required); + parameter.emplace("schema", boost::json::object{{"type", "string"}}); + parameters.emplace_back(std::move(parameter)); + } + if (!parameters.empty()) + operation.emplace("parameters", std::move(parameters)); if (descriptor.request_schema) { @@ -325,13 +334,14 @@ namespace khttpd::framework return result + "

No parameters.

"; result += "
" - ""; + ""; for (const auto& parameter_value : parameters->as_array()) { if (!parameter_value.is_object()) continue; const auto& parameter = parameter_value.as_object(); const auto* name = parameter.if_contains("name"); const auto* location = parameter.if_contains("in"); + const auto* description = parameter.if_contains("description"); const auto* required = parameter.if_contains("required"); const auto* schema = parameter.if_contains("schema"); result += "
NameLocationRequiredSchema
DescriptionRequiredSchema
" + @@ -339,6 +349,8 @@ namespace khttpd::framework "" + escape_html(location != nullptr && location->is_string() ? std::string(location->as_string()) : "") + "" + + escape_html(description != nullptr && description->is_string() ? + std::string(description->as_string()) : "") + "" + (required != nullptr && required->is_bool() && required->as_bool() ? "Yes" : "No") + "" + (schema != nullptr ? "" + escape_html(boost::json::serialize(*schema)) + "" : "—") + @@ -460,13 +472,26 @@ namespace khttpd::framework const auto& parameter = parameter_value.as_object(); const auto* location = parameter.if_contains("in"); const auto* name = parameter.if_contains("name"); - if (location == nullptr || !location->is_string() || location->as_string() != "path" || - name == nullptr || !name->is_string()) continue; + if (location == nullptr || !location->is_string() || name == nullptr || !name->is_string()) + continue; const std::string parameter_name(name->as_string()); - result += ""; + const auto* required = parameter.if_contains("required"); + const bool is_required = required != nullptr && required->is_bool() && required->as_bool(); + if (location->as_string() == "path") + { + result += ""; + } + else if (location->as_string() == "header") + { + result += ""; + } } } @@ -502,17 +527,32 @@ namespace khttpd::framework }); if (missingParameter) throw new Error("Enter path parameter: " + missingParameter); + const headers = {Accept: "application/json"}; + let missingHeader = ""; + panel.querySelectorAll("[data-header-name]").forEach(input => { + const value = input.value.trim(); + if (value) { + headers[input.dataset.headerName] = value; + } else if (requireParameters && input.dataset.headerRequired === "true") { + missingHeader = input.dataset.headerName; + } + }); + if (missingHeader) throw new Error("Enter request header: " + missingHeader); + const method = panel.dataset.method; const url = window.location.origin + (path.startsWith("/") ? path : "/" + path); const bodyInput = panel.querySelector("[data-request-body]"); const body = bodyInput ? bodyInput.value.trim() : ""; let command = "curl -i -X " + method + " " + shellQuote(url); + Object.entries(headers).forEach(([name, value]) => { + if (name !== "Accept") command += " \\\n -H " + shellQuote(name + ": " + value); + }); if (body && method !== "GET" && method !== "HEAD") { command += " \\\n -H " + shellQuote("Content-Type: application/json") + " \\\n --data-binary " + shellQuote(body); } panel.querySelector("[data-curl-output]").textContent = command; - return {method, url, body, command}; + return {method, url, body, headers, command}; } function feedback(panel, message, error = false) { @@ -570,7 +610,7 @@ namespace khttpd::framework credentials: "omit", cache: "no-store", redirect: "manual", - headers: {Accept: "application/json"} + headers: request.headers }; if (request.body && request.method !== "GET" && request.method !== "HEAD") { options.headers["Content-Type"] = "application/json"; diff --git a/framework/tests/openapi_test.cpp b/framework/tests/openapi_test.cpp index 9b5ccc5..fd73e05 100644 --- a/framework/tests/openapi_test.cpp +++ b/framework/tests/openapi_test.cpp @@ -235,6 +235,36 @@ TEST(OpenApiTest, IncludesRouteSummaryAndDescription) EXPECT_NE(response.body().find("Accepts a message and returns its delivery result."), std::string::npos); } +TEST(OpenApiTest, IncludesDocumentedRequestHeaders) +{ + fw::HttpRouter router; + router.post("/secure", [](fw::HttpContext&) {}, + {"Secure operation", "Requires an API access token.", + {{"Authorization", "Bearer access token.", true}, + {"X-Request-Id", "Optional caller correlation identifier.", false}}}); + + const auto document = fw::generate_openapi(router); + const auto& operation = operation_at(document, "/secure", "post"); + const auto& parameters = operation.at("parameters").as_array(); + ASSERT_EQ(parameters.size(), 2U); + EXPECT_EQ(parameters[0].as_object().at("name"), "Authorization"); + EXPECT_EQ(parameters[0].as_object().at("in"), "header"); + EXPECT_EQ(parameters[0].as_object().at("description"), "Bearer access token."); + EXPECT_EQ(parameters[0].as_object().at("required"), true); + EXPECT_EQ(parameters[0].as_object().at("schema").as_object().at("type"), "string"); + EXPECT_EQ(parameters[1].as_object().at("name"), "X-Request-Id"); + EXPECT_EQ(parameters[1].as_object().at("required"), false); + + fw::install_openapi_routes(router); + http::request request{http::verb::get, "/docs", 11}; + http::response response; + fw::HttpContext context(request, response); + router.dispatch(context); + EXPECT_NE(response.body().find("Authorization"), std::string::npos); + EXPECT_NE(response.body().find("Bearer access token."), std::string::npos); + EXPECT_NE(response.body().find("data-header-name"), std::string::npos); +} + TEST(OpenApiTest, DocumentsRouteAtRegistration) { fw::HttpRouter router;