From 910d00f892196a3e270ff3c9bb63cb29e60d1391 Mon Sep 17 00:00:00 2001 From: kekxv Date: Tue, 18 Aug 2026 22:42:48 +0000 Subject: [PATCH 1/3] feat: document legacy route schemas --- MODULE.bazel | 2 +- README.md | 11 +++++++++++ example/MODULE.bazel | 2 +- framework/router/http_router.cpp | 4 +++- framework/router/http_router.hpp | 2 ++ framework/tests/openapi_test.cpp | 27 +++++++++++++++++++++++++++ 6 files changed, 45 insertions(+), 3 deletions(-) diff --git a/MODULE.bazel b/MODULE.bazel index 5a4ef2c..fb9e526 100644 --- a/MODULE.bazel +++ b/MODULE.bazel @@ -1,6 +1,6 @@ module( name = "khttpd", - version = "0.4.1", + version = "0.4.2", ) bazel_dep(name = "platforms", version = "1.1.0") diff --git a/README.md b/README.md index ec970f3..6ce05a5 100644 --- a/README.md +++ b/README.md @@ -294,6 +294,17 @@ router.post("/tokens", create_token, 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 legacy `HttpContext` handlers, provide explicit JSON Schema values as the fourth and fifth +`RouteDocumentation` fields. They describe the request body and successful response without changing runtime parsing: + +```cpp +router.post("/authorize", authorize, + {"Authorize", "Checks an API token.", {}, + {{"type", "object"}, {"properties", {{"token", {{"type", "string"}}}}}, + {"required", {"token"}}}, + {{"type", "object"}, {"properties", {{"authorized", {{"type", "boolean"}}}}}}}); +``` + 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 82d9550..95643af 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.1") +bazel_dep(name = "khttpd", version = "0.4.2") local_path_override( module_name = "khttpd", path = "..", diff --git a/framework/router/http_router.cpp b/framework/router/http_router.cpp index 8feeb14..8da820d 100644 --- a/framework/router/http_router.cpp +++ b/framework/router/http_router.cpp @@ -179,6 +179,8 @@ namespace khttpd::framework std::optional response_schema, RouteDocumentation documentation) { + if (documentation.request_schema) request_schema = documentation.request_schema; + if (documentation.response_schema) response_schema = documentation.response_schema; for (auto& descriptor : route_descriptors_) { if (descriptor.path == path && descriptor.method == method) @@ -186,7 +188,7 @@ namespace khttpd::framework descriptor.request_schema = std::move(request_schema); descriptor.response_schema = std::move(response_schema); if (!documentation.summary.empty() || !documentation.description.empty() || - !documentation.headers.empty()) + !documentation.headers.empty() || documentation.request_schema || documentation.response_schema) descriptor.documentation = std::move(documentation); return; } diff --git a/framework/router/http_router.hpp b/framework/router/http_router.hpp index 1781bbc..f42de1a 100644 --- a/framework/router/http_router.hpp +++ b/framework/router/http_router.hpp @@ -44,6 +44,8 @@ namespace khttpd::framework std::string summary; std::string description; std::vector headers; + std::optional request_schema; + std::optional response_schema; }; struct RouteDescriptor diff --git a/framework/tests/openapi_test.cpp b/framework/tests/openapi_test.cpp index fd73e05..40d5d25 100644 --- a/framework/tests/openapi_test.cpp +++ b/framework/tests/openapi_test.cpp @@ -161,6 +161,33 @@ TEST(OpenApiTest, IncludesTypedDescribeSchemas) EXPECT_EQ(tags.at("items").as_object().at("type"), "string"); } +TEST(OpenApiTest, IncludesExplicitSchemasForDocumentedLegacyRoutes) +{ + fw::HttpRouter router; + router.post("/authorize", [](fw::HttpContext&) {}, + {"Authorize", "Checks an API token.", {}, + boost::json::object{{"type", "object"}, + {"properties", boost::json::object{ + {"token", boost::json::object{{"type", "string"}}}, + {"permissions", boost::json::object{{"type", "array"}, + {"items", boost::json::object{{"type", "string"}}}}}}}, + {"required", boost::json::array{"token", "permissions"}}}, + boost::json::object{{"type", "object"}, + {"properties", boost::json::object{ + {"active", boost::json::object{{"type", "boolean"}}}, + {"authorized", boost::json::object{{"type", "boolean"}}}}}}}); + + const auto document = fw::generate_openapi(router); + const auto& operation = operation_at(document, "/authorize", "post"); + const auto& request = operation.at("requestBody").as_object().at("content").as_object() + .at("application/json").as_object().at("schema").as_object(); + EXPECT_EQ(request.at("properties").as_object().at("token").as_object().at("type"), "string"); + EXPECT_EQ(request.at("required").as_array()[1], "permissions"); + const auto& response = operation.at("responses").as_object().at("200").as_object() + .at("content").as_object().at("application/json").as_object().at("schema").as_object(); + EXPECT_EQ(response.at("properties").as_object().at("authorized").as_object().at("type"), "boolean"); +} + TEST(OpenApiTest, EmitsPropertiesForUnreflectedObjectSchemas) { fw::HttpRouter router; From 42aa5161865441d8ef30c8d6b1893e149d0253ac Mon Sep 17 00:00:00 2001 From: kekxv Date: Wed, 19 Aug 2026 02:39:06 +0000 Subject: [PATCH 2/3] feat: describe OpenAPI DTO fields --- framework/router/openapi_schema.hpp | 16 ++++++++++++++- framework/tests/openapi_test.cpp | 32 +++++++++++++++++++++++++++++ 2 files changed, 47 insertions(+), 1 deletion(-) diff --git a/framework/router/openapi_schema.hpp b/framework/router/openapi_schema.hpp index 17ba0ab..b7aae2f 100644 --- a/framework/router/openapi_schema.hpp +++ b/framework/router/openapi_schema.hpp @@ -7,9 +7,20 @@ #include #include +#include #include #include +namespace khttpd::framework +{ + // Specialize this trait for a described DTO to add OpenAPI descriptions to its fields. + template + struct OpenApiFieldDocumentation + { + static std::string_view description(std::string_view) { return {}; } + }; +} + namespace khttpd::framework::detail { template @@ -72,7 +83,10 @@ namespace khttpd::framework::detail boost::mp11::mp_for_each([&](auto descriptor) { using Member = std::remove_cv_t().*descriptor.pointer)>>; - properties.emplace(descriptor.name, openapi_schema()); + auto property_schema = openapi_schema().as_object(); + const auto description = OpenApiFieldDocumentation::description(descriptor.name); + if (!description.empty()) property_schema.emplace("description", description); + properties.emplace(descriptor.name, std::move(property_schema)); if constexpr (!is_optional_v) required.emplace_back(descriptor.name); }); schema.emplace("type", "object"); diff --git a/framework/tests/openapi_test.cpp b/framework/tests/openapi_test.cpp index 40d5d25..2702898 100644 --- a/framework/tests/openapi_test.cpp +++ b/framework/tests/openapi_test.cpp @@ -58,6 +58,12 @@ struct OpenApiMessageResponse std::string message; }; +struct OpenApiDescribedFieldsRequest +{ + std::string token; + std::vector permissions; +}; + OpenApiOpaquePayload tag_invoke(boost::json::value_to_tag, const boost::json::value& value) { @@ -74,6 +80,18 @@ BOOST_DESCRIBE_STRUCT(OpenApiCreatePetRequest, (), (name, age, nickname)) BOOST_DESCRIBE_STRUCT(OpenApiPetResponse, (), (id, name, tags)) BOOST_DESCRIBE_STRUCT(OpenApiMessageRequest, (), (message)) BOOST_DESCRIBE_STRUCT(OpenApiMessageResponse, (), (message)) +BOOST_DESCRIBE_STRUCT(OpenApiDescribedFieldsRequest, (), (token, permissions)) + +template <> +struct khttpd::framework::OpenApiFieldDocumentation +{ + static std::string_view description(const std::string_view name) + { + if (name == "token") return "API token to authorize."; + if (name == "permissions") return "Permissions required by the caller."; + return {}; + } +}; #if defined(__clang__) #pragma clang diagnostic pop @@ -161,6 +179,20 @@ TEST(OpenApiTest, IncludesTypedDescribeSchemas) EXPECT_EQ(tags.at("items").as_object().at("type"), "string"); } +TEST(OpenApiTest, IncludesFieldDescriptionsForTypedDtos) +{ + fw::HttpRouter router; + router.post("/field-descriptions", [](const OpenApiDescribedFieldsRequest&) { return true; }); + + const auto document = fw::generate_openapi(router); + const auto& request_schema = operation_at(document, "/field-descriptions", "post") + .at("requestBody").as_object().at("content").as_object().at("application/json").as_object() + .at("schema").as_object(); + const auto& properties = request_schema.at("properties").as_object(); + EXPECT_EQ(properties.at("token").as_object().at("description"), "API token to authorize."); + EXPECT_EQ(properties.at("permissions").as_object().at("description"), "Permissions required by the caller."); +} + TEST(OpenApiTest, IncludesExplicitSchemasForDocumentedLegacyRoutes) { fw::HttpRouter router; From 58610e011df16b515d1e16d4b435e4514b4722fa Mon Sep 17 00:00:00 2001 From: kekxv Date: Wed, 19 Aug 2026 03:12:43 +0000 Subject: [PATCH 3/3] feat: simplify OpenAPI DTO field documentation --- README.md | 9 +++++++++ doc/api-reference.md | 10 ++++++++++ example/TypedHelloController.hpp | 10 ++++++++++ example/export_openapi_test.sh | 2 ++ framework/router/openapi_schema.hpp | 16 ++++++++++++++++ framework/tests/openapi_test.cpp | 13 +++---------- 6 files changed, 50 insertions(+), 10 deletions(-) diff --git a/README.md b/README.md index 6ce05a5..7c9ca51 100644 --- a/README.md +++ b/README.md @@ -273,6 +273,15 @@ Route registration also records handler-free documentation metadata. Legacy rout parameters; typed routes additionally contribute request and response schemas. DTOs declared with `BOOST_DESCRIBE_STRUCT` produce field-level schemas, while DTOs that only provide custom Boost.JSON converters use a conservative `object` schema. +Add field descriptions after `BOOST_DESCRIBE_STRUCT` with the OpenAPI field documentation macros. They only enrich +`openapi.json` and `/docs`; Boost.JSON conversion and the DTO remain unchanged: + +```cpp +KHTTPD_OPENAPI_FIELD_DOCUMENTATION(CreateUserRequest, + KHTTPD_OPENAPI_FIELD(name, "Name of the new user.") + KHTTPD_OPENAPI_FIELD(age, "Age of the new user.")) +``` + Add a summary and a longer description when registering a route; both become standard OpenAPI operation fields and appear in `/docs`: diff --git a/doc/api-reference.md b/doc/api-reference.md index 73274e9..2db9d23 100644 --- a/doc/api-reference.md +++ b/doc/api-reference.md @@ -471,6 +471,16 @@ struct CreateRequest { std::string name; int age; }; BOOST_DESCRIBE_STRUCT(CreateRequest, (), (name, age)) ``` +为字段增加 OpenAPI `description` 时,可在 `BOOST_DESCRIBE_STRUCT` 后的命名空间作用域紧跟 DTO 写文档宏,无需手写 trait 特化: + +```cpp +KHTTPD_OPENAPI_FIELD_DOCUMENTATION(CreateRequest, + KHTTPD_OPENAPI_FIELD(name, "要创建的名称。") + KHTTPD_OPENAPI_FIELD(age, "创建对象时使用的年龄。")) +``` + +字段说明仅写入 OpenAPI schema 与 `/docs`,不会改变 Boost.JSON 的序列化、反序列化或 DTO 对象。 + 仅通过自定义 `tag_invoke` 序列化且没有 Boost.Describe 元数据的类型会退化为 `{ "type": "object", "properties": {} }`,不会猜测字段。`std::optional` 字段不进入 `required`;字符串、布尔、整数、浮点和 `std::vector` 会生成对应 schema。 没有反射元数据的对象实际输出为 `{ "type": "object", "properties": {} }`,保证对象 schema 的结构完整,但不会虚构未知字段。整份 `openapi.json` 的根节点是 OpenAPI Document,不是一个可直接传入 OpenAI `response_format.json_schema` 的裸 JSON Schema;此类调用应选择 `paths` 下具体 request/response 的 `schema`。 diff --git a/example/TypedHelloController.hpp b/example/TypedHelloController.hpp index 88a7e9d..af3bfa8 100644 --- a/example/TypedHelloController.hpp +++ b/example/TypedHelloController.hpp @@ -29,6 +29,16 @@ BOOST_DESCRIBE_STRUCT(CreateGreetingRequest, (), (name)) BOOST_DESCRIBE_STRUCT(GreetingResponse, (), (message)) BOOST_DESCRIBE_STRUCT(GreetingErrorResponse, (), (code, message)) +KHTTPD_OPENAPI_FIELD_DOCUMENTATION(CreateGreetingRequest, + KHTTPD_OPENAPI_FIELD(name, "Name to include in the greeting.")) + +KHTTPD_OPENAPI_FIELD_DOCUMENTATION(GreetingResponse, + KHTTPD_OPENAPI_FIELD(message, "Greeting text returned to the caller.")) + +KHTTPD_OPENAPI_FIELD_DOCUMENTATION(GreetingErrorResponse, + KHTTPD_OPENAPI_FIELD(code, "Stable error code.") + KHTTPD_OPENAPI_FIELD(message, "Error message for the caller.")) + class GreetingValidationError : public std::runtime_error { public: diff --git a/example/export_openapi_test.sh b/example/export_openapi_test.sh index 9834c6e..232311b 100755 --- a/example/export_openapi_test.sh +++ b/example/export_openapi_test.sh @@ -50,7 +50,9 @@ stream = document["paths"]["/stream/{size}"]["get"] request = operation["requestBody"]["content"]["application/json"]["schema"] response = operation["responses"]["200"]["content"]["application/json"]["schema"] assert request["properties"]["name"]["type"] == "string" +assert request["properties"]["name"]["description"] == "Name to include in the greeting." assert response["properties"]["message"]["type"] == "string" +assert response["properties"]["message"]["description"] == "Greeting text returned to the caller." assert home["summary"] == "Example service home" assert hello["summary"] == "Greet a visitor" assert stream["summary"] == "Stream response chunks" diff --git a/framework/router/openapi_schema.hpp b/framework/router/openapi_schema.hpp index b7aae2f..0ef33b8 100644 --- a/framework/router/openapi_schema.hpp +++ b/framework/router/openapi_schema.hpp @@ -21,6 +21,22 @@ namespace khttpd::framework }; } +// Declares OpenAPI documentation for fields reflected with BOOST_DESCRIBE_STRUCT. +// Place one KHTTPD_OPENAPI_FIELD entry per documented DTO member. +#define KHTTPD_OPENAPI_FIELD(member, text) \ + if (field == #member) return text; + +#define KHTTPD_OPENAPI_FIELD_DOCUMENTATION(Type, ...) \ + template <> \ + struct khttpd::framework::OpenApiFieldDocumentation \ + { \ + static std::string_view description(const std::string_view field) \ + { \ + __VA_ARGS__ \ + return {}; \ + } \ + }; + namespace khttpd::framework::detail { template diff --git a/framework/tests/openapi_test.cpp b/framework/tests/openapi_test.cpp index 2702898..2d68357 100644 --- a/framework/tests/openapi_test.cpp +++ b/framework/tests/openapi_test.cpp @@ -82,16 +82,9 @@ BOOST_DESCRIBE_STRUCT(OpenApiMessageRequest, (), (message)) BOOST_DESCRIBE_STRUCT(OpenApiMessageResponse, (), (message)) BOOST_DESCRIBE_STRUCT(OpenApiDescribedFieldsRequest, (), (token, permissions)) -template <> -struct khttpd::framework::OpenApiFieldDocumentation -{ - static std::string_view description(const std::string_view name) - { - if (name == "token") return "API token to authorize."; - if (name == "permissions") return "Permissions required by the caller."; - return {}; - } -}; +KHTTPD_OPENAPI_FIELD_DOCUMENTATION(OpenApiDescribedFieldsRequest, + KHTTPD_OPENAPI_FIELD(token, "API token to authorize.") + KHTTPD_OPENAPI_FIELD(permissions, "Permissions required by the caller.")) #if defined(__clang__) #pragma clang diagnostic pop