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
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.0",
version = "0.4.1",
)

bazel_dep(name = "platforms", version = "1.1.0")
Expand Down
13 changes: 13 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
2 changes: 1 addition & 1 deletion example/MODULE.bazel
Original file line number Diff line number Diff line change
Expand Up @@ -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 = "..",
Expand Down
3 changes: 2 additions & 1 deletion framework/router/http_router.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Expand Down
8 changes: 8 additions & 0 deletions framework/router/http_router.hpp
Original file line number Diff line number Diff line change
Expand Up @@ -32,10 +32,18 @@ namespace khttpd::framework
std::shared_ptr<HttpResponseStream>, HttpStreamComplete)>;
using UnknownExceptionHandler = std::function<void(HttpContext&)>;

struct RouteHeader
{
std::string name;
std::string description;
bool required = false;
};

struct RouteDocumentation
{
std::string summary;
std::string description;
std::vector<RouteHeader> headers;
};

struct RouteDescriptor
Expand Down
84 changes: 62 additions & 22 deletions framework/router/openapi.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -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)
{
Expand Down Expand Up @@ -325,20 +334,23 @@ namespace khttpd::framework
return result + "<p class=\"empty\">No parameters.</p></section>";

result += "<div class=\"table-wrap\"><table><thead><tr><th>Name</th><th>Location</th>"
"<th>Required</th><th>Schema</th></tr></thead><tbody>";
"<th>Description</th><th>Required</th><th>Schema</th></tr></thead><tbody>";
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 += "<tr><td><code>" +
escape_html(name != nullptr && name->is_string() ? std::string(name->as_string()) : "") +
"</code></td><td>" +
escape_html(location != nullptr && location->is_string() ?
std::string(location->as_string()) : "") + "</td><td>" +
escape_html(description != nullptr && description->is_string() ?
std::string(description->as_string()) : "") + "</td><td>" +
(required != nullptr && required->is_bool() && required->as_bool() ? "Yes" : "No") +
"</td><td>" +
(schema != nullptr ? "<code>" + escape_html(boost::json::serialize(*schema)) + "</code>" : "—") +
Expand Down Expand Up @@ -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 += "<label class=\"try-field\"><span>" + escape_html(parameter_name) +
" <small>path</small></span><input type=\"text\" data-path-param=\"" +
escape_html(parameter_name) + "\" placeholder=\"Enter " + escape_html(parameter_name) +
"\" autocomplete=\"off\"></label>";
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 += "<label class=\"try-field\"><span>" + escape_html(parameter_name) +
" <small>path</small></span><input type=\"text\" data-path-param=\"" +
escape_html(parameter_name) + "\" placeholder=\"Enter " + escape_html(parameter_name) +
"\" autocomplete=\"off\"></label>";
}
else if (location->as_string() == "header")
{
result += "<label class=\"try-field\"><span>" + escape_html(parameter_name) +
" <small>header</small></span><input type=\"text\" data-header-name=\"" +
escape_html(parameter_name) + "\" data-header-required=\"" +
(is_required ? "true" : "false") + "\" placeholder=\"Enter " +
escape_html(parameter_name) + "\" autocomplete=\"off\"></label>";
}
}
}

Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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";
Expand Down
30 changes: 30 additions & 0 deletions framework/tests/openapi_test.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -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<http::string_body> request{http::verb::get, "/docs", 11};
http::response<http::string_body> 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;
Expand Down
Loading