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.1",
version = "0.4.2",
)

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

Expand All @@ -294,6 +303,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.
Expand Down
10 changes: 10 additions & 0 deletions doc/api-reference.md
Original file line number Diff line number Diff line change
Expand Up @@ -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<T>` 字段不进入 `required`;字符串、布尔、整数、浮点和 `std::vector<T>` 会生成对应 schema。

没有反射元数据的对象实际输出为 `{ "type": "object", "properties": {} }`,保证对象 schema 的结构完整,但不会虚构未知字段。整份 `openapi.json` 的根节点是 OpenAPI Document,不是一个可直接传入 OpenAI `response_format.json_schema` 的裸 JSON Schema;此类调用应选择 `paths` 下具体 request/response 的 `schema`。
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.1")
bazel_dep(name = "khttpd", version = "0.4.2")
local_path_override(
module_name = "khttpd",
path = "..",
Expand Down
10 changes: 10 additions & 0 deletions example/TypedHelloController.hpp
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
2 changes: 2 additions & 0 deletions example/export_openapi_test.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
4 changes: 3 additions & 1 deletion framework/router/http_router.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -179,14 +179,16 @@ namespace khttpd::framework
std::optional<boost::json::value> 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)
{
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;
}
Expand Down
2 changes: 2 additions & 0 deletions framework/router/http_router.hpp
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,8 @@ namespace khttpd::framework
std::string summary;
std::string description;
std::vector<RouteHeader> headers;
std::optional<boost::json::value> request_schema;
std::optional<boost::json::value> response_schema;
};

struct RouteDescriptor
Expand Down
32 changes: 31 additions & 1 deletion framework/router/openapi_schema.hpp
Original file line number Diff line number Diff line change
Expand Up @@ -7,9 +7,36 @@

#include <optional>
#include <string>
#include <string_view>
#include <type_traits>
#include <vector>

namespace khttpd::framework
{
// Specialize this trait for a described DTO to add OpenAPI descriptions to its fields.
template <class T>
struct OpenApiFieldDocumentation
{
static std::string_view description(std::string_view) { return {}; }
};
}

// 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<Type> \
{ \
static std::string_view description(const std::string_view field) \
{ \
__VA_ARGS__ \
return {}; \
} \
};

namespace khttpd::framework::detail
{
template <class T>
Expand Down Expand Up @@ -72,7 +99,10 @@ namespace khttpd::framework::detail
boost::mp11::mp_for_each<Members>([&](auto descriptor)
{
using Member = std::remove_cv_t<std::remove_reference_t<decltype(std::declval<Value>().*descriptor.pointer)>>;
properties.emplace(descriptor.name, openapi_schema<Member>());
auto property_schema = openapi_schema<Member>().as_object();
const auto description = OpenApiFieldDocumentation<Value>::description(descriptor.name);
if (!description.empty()) property_schema.emplace("description", description);
properties.emplace(descriptor.name, std::move(property_schema));
if constexpr (!is_optional_v<Member>) required.emplace_back(descriptor.name);
});
schema.emplace("type", "object");
Expand Down
52 changes: 52 additions & 0 deletions framework/tests/openapi_test.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,12 @@ struct OpenApiMessageResponse
std::string message;
};

struct OpenApiDescribedFieldsRequest
{
std::string token;
std::vector<std::string> permissions;
};

OpenApiOpaquePayload tag_invoke(boost::json::value_to_tag<OpenApiOpaquePayload>,
const boost::json::value& value)
{
Expand All @@ -74,6 +80,11 @@ 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))

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
Expand Down Expand Up @@ -161,6 +172,47 @@ 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;
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;
Expand Down
Loading