From e2f4495382a9302aed5d59ae99324d18e3209fa9 Mon Sep 17 00:00:00 2001 From: kekxv Date: Sat, 22 Aug 2026 14:59:44 +0000 Subject: [PATCH 1/3] fix: document brace-style OpenAPI path parameters --- README.md | 1 + doc/api-reference.md | 4 +++- framework/router/openapi.cpp | 8 +++++--- framework/tests/openapi_test.cpp | 21 +++++++++++++++++++++ 4 files changed, 30 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index fc1375c..ecae88e 100644 --- a/README.md +++ b/README.md @@ -311,6 +311,7 @@ router.map_exception( Route registration also records handler-free documentation metadata. Legacy routes contribute their method, path, and path 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. +Path parameters registered as `:name` or `{name}` are both emitted in the OpenAPI-standard `{name}` form. 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: diff --git a/doc/api-reference.md b/doc/api-reference.md index a64ed02..5c76ba3 100644 --- a/doc/api-reference.md +++ b/doc/api-reference.md @@ -222,9 +222,11 @@ KHTTPD_TYPED_ROUTE(post, "/users", create_user); | 语法 | 示例 | 匹配 | |------|------|------| | 静态路径 | `/api/users` | 精确匹配 | -| 动态参数 | `/users/:id` | 匹配单段路径,如 `/users/123` | +| 动态参数 | `/users/:id`、`/users/{id}` | 两种写法等价,匹配单段路径,如 `/users/123` | | 尾部通配 | `/files/:filepath` | 最后一个参数匹配剩余所有路径段 | +生成 OpenAPI 文档时,两种动态参数写法都会统一输出为标准的 `{id}`,并生成对应的 `in: path` 参数定义。 + ### 路由优先级 当多个路由同时匹配时,按以下规则排序: diff --git a/framework/router/openapi.cpp b/framework/router/openapi.cpp index 9d39435..e55b307 100644 --- a/framework/router/openapi.cpp +++ b/framework/router/openapi.cpp @@ -26,7 +26,8 @@ namespace khttpd::framework DocumentedPath document_path(const std::string& route_path) { - static const std::regex parameter_pattern(":([a-zA-Z_][a-zA-Z0-9_]*)"); + static const std::regex parameter_pattern( + R"((?::([a-zA-Z_][a-zA-Z0-9_]*)|\{([a-zA-Z_][a-zA-Z0-9_]*)\}))"); DocumentedPath result; auto current = route_path.cbegin(); const std::sregex_iterator end; @@ -34,8 +35,9 @@ namespace khttpd::framework it != end; ++it) { result.path.append(current, it->prefix().second); - result.path += "{" + (*it)[1].str() + "}"; - result.parameters.push_back((*it)[1].str()); + const auto name = (*it)[1].matched ? (*it)[1].str() : (*it)[2].str(); + result.path += "{" + name + "}"; + result.parameters.push_back(name); current = it->suffix().first; } result.path.append(current, route_path.cend()); diff --git a/framework/tests/openapi_test.cpp b/framework/tests/openapi_test.cpp index 2d68357..398027f 100644 --- a/framework/tests/openapi_test.cpp +++ b/framework/tests/openapi_test.cpp @@ -141,6 +141,27 @@ TEST(OpenApiTest, IncludesLegacyMethodsAndPathParameters) EXPECT_EQ(parameter.at("x-khttpd-greedy"), true); } +TEST(OpenApiTest, NormalizesColonAndBracePathParameters) +{ + fw::HttpRouter router; + router.get("/teams/{team_id}/users/:user_id", [](fw::HttpContext&) {}); + + const auto document = fw::generate_openapi(router); + const auto& operation = operation_at(document, "/teams/{team_id}/users/{user_id}", "get"); + const auto& parameters = operation.at("parameters").as_array(); + + ASSERT_EQ(parameters.size(), 2U); + for (std::size_t index = 0; index < parameters.size(); ++index) + { + const auto& parameter = parameters.at(index).as_object(); + EXPECT_EQ(parameter.at("in"), "path"); + EXPECT_EQ(parameter.at("required"), true); + EXPECT_EQ(parameter.at("schema").as_object().at("type"), "string"); + } + EXPECT_EQ(parameters.at(0).as_object().at("name"), "team_id"); + EXPECT_EQ(parameters.at(1).as_object().at("name"), "user_id"); +} + TEST(OpenApiTest, IncludesTypedDescribeSchemas) { fw::HttpRouter router; From 7fc15b39e0c373776643f73bef0f1cb0cff5e7eb Mon Sep 17 00:00:00 2001 From: kekxv Date: Sat, 22 Aug 2026 15:48:10 +0000 Subject: [PATCH 2/3] feat: bind typed route parameters --- README.md | 34 +++- doc/api-reference.md | 53 +++++ example/TypedHelloController.hpp | 14 ++ example/main.cpp | 7 + framework/controller/http_controller.hpp | 3 +- framework/router/http_router.cpp | 39 +++- framework/router/http_router.hpp | 248 ++++++++++++++++++++++- framework/router/openapi.cpp | 24 ++- framework/router/route_parameter.hpp | 86 ++++++++ framework/router/typed_route.hpp | 232 ++++++++++++++++++++- framework/tests/openapi_test.cpp | 45 ++++ framework/tests/typed_route_test.cpp | 234 +++++++++++++++++++++ 12 files changed, 1007 insertions(+), 12 deletions(-) create mode 100644 framework/router/route_parameter.hpp diff --git a/README.md b/README.md index ecae88e..c6006ad 100644 --- a/README.md +++ b/README.md @@ -293,6 +293,31 @@ Typed request bodies require `application/json` or an `application/*+json` media response without invoking the handler. Typed routes register as ordinary buffered routes, so interceptors and authorization checks run before them exactly as they do for legacy routes. +In C++17, route parameter descriptors bind path, query, and JSON body values directly to ordinary handler arguments. The +descriptor order must match the handler argument order; an optional `HttpContext&` may appear last: + +```cpp +HttpResult update_user( + std::string id, + const UpdateUserRequest& body, + bool notify, + HttpContext& context); + +router.put( + "/users/{id}", + update_user, + {"Update user", "Updates a user and optionally sends a notification."}, + PathParam{"id"}, + Body{}, + QueryParam{"notify", false}); +``` + +`PathParam` and `QueryParam` support strings, integral and floating-point values, and strict `true`/`false` booleans. +`QueryParam{"name"}` is required, `QueryParam>{"name"}` is optional, and +`QueryParam{"name", default_value}` supplies a default. A path descriptor name must exist in the registered `:name` or +`{name}` template. Missing or invalid values throw `TypedParameterValidationError`; the default response is HTTP 400 with +code `INVALID_REQUEST_PARAMETER`, and applications can map the exception to their own envelope. + Invalid media types, malformed JSON, and DTO conversion failures throw `TypedRequestValidationError` through the router's exception pipeline. Without a mapper they retain the default `400 INVALID_REQUEST_BODY` response; applications that use a shared error envelope can map them once for every typed route: @@ -304,6 +329,12 @@ router.map_exception( boost::beast::http::status::bad_request, {"INVALID_REQUEST", error.what()}); }); +router.map_exception( + [](const auto& error) { + return khttpd::framework::HttpResult( + boost::beast::http::status::bad_request, + {"INVALID_PARAMETER", error.what()}); + }); ``` ### OpenAPI 3.1 documentation @@ -377,7 +408,8 @@ schema for the target consumer. Unreflected C++ object types are emitted conserv `{"type":"object","properties":{}}`. The example includes documented legacy lambda routes (`/`, `/hello`, `/api/json`), documented controller routes -(`/stream/:size` and `/hello/hello`), and documented typed `POST /typed/greetings`, along with `HttpResult` +(`/stream/:size` and `/hello/hello`), and documented typed `POST /typed/greetings` and +`PUT /typed/greetings/{id}?excited=true`, along with `HttpResult` headers/status and a serialized validation exception. Run it normally on port 8080, or export the exact same registered routes and exit without constructing a listening server: diff --git a/doc/api-reference.md b/doc/api-reference.md index 5c76ba3..ac14d5a 100644 --- a/doc/api-reference.md +++ b/doc/api-reference.md @@ -201,6 +201,59 @@ Controller 可使用: KHTTPD_TYPED_ROUTE(post, "/users", create_user); ``` +### C++17 路径、查询和请求体参数绑定 + +注册路由时追加参数描述符,可将路径参数、查询参数和 JSON DTO 按顺序传给普通 handler 参数,不需要为字段名定义宏: + +```cpp +HttpResult update_user( + std::string id, + const UpdateUserRequest& body, + bool notify, + HttpContext& context); + +router.put( + "/users/{id}", + update_user, + {"编辑用户", "编辑用户资料,并可选择发送通知。"}, + PathParam{"id"}, + Body{}, + QueryParam{"notify", false}); +``` + +描述符顺序必须与 handler 参数顺序一致,最后可以额外声明一个 `HttpContext&`。Controller 成员函数使用相同描述符: + +```cpp +router.put(base_path() + "/users/{id}", shared_from_this(), + &UserController::update_user, + PathParam{"id"}, Body{}); +``` + +| 描述符 | 语义 | +|------|------| +| `PathParam{"id"}` | 必填路径参数;名称必须存在于 `:id` 或 `{id}` 路由模板 | +| `QueryParam{"page"}` | 必填查询参数 | +| `QueryParam>{"keyword"}` | 可选查询参数,缺失时为 `std::nullopt` | +| `QueryParam{"page", 1}` | 可选查询参数,缺失时使用默认值 | +| `Body{}` | 将 JSON 请求体转换为 DTO;每条路由最多一个 | + +路径和查询参数支持 `std::string`、整数、浮点数及布尔值;数值必须完整解析,布尔值只接受 `true` 或 `false`。 +缺失或格式错误会抛出 `TypedParameterValidationError`,未映射时返回 HTTP 400 和 +`INVALID_REQUEST_PARAMETER`。它与请求体异常一样经过统一异常映射管线: + +```cpp +router.map_exception( + [](const auto& error) { + return khttpd::framework::HttpResult( + boost::beast::http::status::bad_request, + {"INVALID_PARAMETER", error.what()}); + }); +``` + +描述符还会生成 OpenAPI 参数和请求体 schema:路径参数始终为 required;可选或有默认值的 query 参数为 +optional,默认值写入 schema。`RouteDocumentation` 可放在 handler 后、描述符前,同时保留 summary、description +和请求头说明。 + 原有 `KHTTPD_ROUTE`、`void(HttpContext&)` 和所有路由分发行为保持不变。强类型路由仍执行相同的前置/后置拦截器, 不能替代鉴权拦截器。 diff --git a/example/TypedHelloController.hpp b/example/TypedHelloController.hpp index af3bfa8..7f7691d 100644 --- a/example/TypedHelloController.hpp +++ b/example/TypedHelloController.hpp @@ -58,6 +58,12 @@ class TypedHelloController final : public khttpd::framework::BaseController{"id"}, + khttpd::framework::Body{}, + khttpd::framework::QueryParam{"excited", false}); return shared_from_this(); } @@ -77,6 +83,14 @@ class TypedHelloController final : public khttpd::framework::BaseController( + [](const khttpd::framework::TypedParameterValidationError& error) + { + return khttpd::framework::HttpResult( + beast::http::status::bad_request, + {"INVALID_ROUTE_PARAMETER", error.what()}); + }); HelloController::create()->register_routes(http_router)->register_routes(ws_router); HelloStreamController::create()->register_routes(http_router)->register_routes(ws_router); HelloWsController::create()->register_routes(http_router)->register_routes(ws_router); diff --git a/framework/controller/http_controller.hpp b/framework/controller/http_controller.hpp index cdb1e5a..2a6272b 100644 --- a/framework/controller/http_controller.hpp +++ b/framework/controller/http_controller.hpp @@ -18,7 +18,8 @@ router.VERB(base_path() + PATH, bind_handler(&std::decay_t::MET #endif #ifndef KHTTPD_TYPED_ROUTE #define KHTTPD_TYPED_ROUTE(VERB, PATH, METHOD_NAME) \ -router.VERB(base_path() + PATH, this->shared_from_this(), &std::decay_t::METHOD_NAME) +router.VERB(base_path() + PATH, this->shared_from_this(), \ + &std::decay_t::METHOD_NAME) #endif #ifndef KHTTPD_DOCUMENTED_TYPED_ROUTE #define KHTTPD_DOCUMENTED_TYPED_ROUTE(VERB, PATH, METHOD_NAME, ...) \ diff --git a/framework/router/http_router.cpp b/framework/router/http_router.cpp index 222f05d..8e2d1b0 100644 --- a/framework/router/http_router.cpp +++ b/framework/router/http_router.cpp @@ -154,11 +154,12 @@ namespace khttpd::framework std::optional request_schema, std::optional response_schema, const bool documented, - RouteDocumentation documentation) + RouteDocumentation documentation, + std::vector parameters) { if (documented) record_route_descriptor(path_pattern, method, std::move(request_schema), std::move(response_schema), - std::move(documentation)); + std::move(documentation), std::move(parameters)); else route_descriptors_.erase(std::remove_if(route_descriptors_.begin(), route_descriptors_.end(), [&](const RouteDescriptor& descriptor) @@ -197,16 +198,37 @@ namespace khttpd::framework detail::TypedRouteHandler handler, RouteDocumentation documentation) { + const auto parsed = parse_path_pattern(path_pattern); + const auto& path_parameter_names = std::get<1>(parsed); + for (std::size_t index = 0; index < handler.parameters.size(); ++index) + { + const auto& parameter = handler.parameters[index]; + if (parameter.name.empty()) + throw std::invalid_argument("Typed route parameter names cannot be empty"); + if (parameter.location == RouteParameterLocation::path && + std::find(path_parameter_names.begin(), path_parameter_names.end(), parameter.name) == + path_parameter_names.end()) + throw std::invalid_argument("Path parameter '" + parameter.name + + "' is not present in route " + path_pattern); + for (std::size_t previous = 0; previous < index; ++previous) + { + if (handler.parameters[previous].location == parameter.location && + handler.parameters[previous].name == parameter.name) + throw std::invalid_argument("Duplicate typed route parameter '" + parameter.name + "'"); + } + } + add_route(path_pattern, method, std::move(handler.handler), std::move(handler.request_schema), std::move(handler.response_schema), true, - std::move(documentation)); + std::move(documentation), std::move(handler.parameters)); } void HttpRouter::record_route_descriptor(const std::string& path, const boost::beast::http::verb method, std::optional request_schema, std::optional response_schema, - RouteDocumentation documentation) + RouteDocumentation documentation, + std::vector parameters) { if (documentation.request_schema) request_schema = documentation.request_schema; if (documentation.response_schema) response_schema = documentation.response_schema; @@ -216,6 +238,7 @@ namespace khttpd::framework { descriptor.request_schema = std::move(request_schema); descriptor.response_schema = std::move(response_schema); + descriptor.parameters = std::move(parameters); if (!documentation.summary.empty() || !documentation.description.empty() || !documentation.headers.empty() || documentation.request_schema || documentation.response_schema) descriptor.documentation = std::move(documentation); @@ -223,7 +246,8 @@ namespace khttpd::framework } } route_descriptors_.push_back( - {path, method, std::move(request_schema), std::move(response_schema), std::move(documentation)}); + {path, method, std::move(request_schema), std::move(response_schema), std::move(documentation), + std::move(parameters)}); } std::vector HttpRouter::route_descriptors() const @@ -642,6 +666,11 @@ namespace khttpd::framework { std::rethrow_exception(eptr); } + catch (const TypedParameterValidationError& error) + { + detail::write_invalid_request_parameter(ctx, error.what()); + return; + } catch (const TypedRequestValidationError&) { detail::write_invalid_request_body(ctx); diff --git a/framework/router/http_router.hpp b/framework/router/http_router.hpp index af83fa2..d1e8ffd 100644 --- a/framework/router/http_router.hpp +++ b/framework/router/http_router.hpp @@ -57,6 +57,7 @@ namespace khttpd::framework std::optional request_schema; std::optional response_schema; RouteDocumentation documentation; + std::vector parameters; }; // 路由条目结构 @@ -109,6 +110,30 @@ namespace khttpd::framework detail::make_typed_handler(std::forward(handler))); } + template && + (detail::is_route_parameter_descriptor_v && ...), int> = 0> + void get(const std::string& path, Handler&& handler, FirstDescriptor&& first, + Descriptors&&... descriptors) + { + add_typed_route(path, boost::beast::http::verb::get, + detail::make_parameterized_typed_handler( + std::forward(handler), std::forward(first), + std::forward(descriptors)...)); + } + + template && + (detail::is_route_parameter_descriptor_v && ...), int> = 0> + void get(const std::string& path, Handler&& handler, RouteDocumentation documentation, + FirstDescriptor&& first, Descriptors&&... descriptors) + { + add_typed_route(path, boost::beast::http::verb::get, + detail::make_parameterized_typed_handler( + std::forward(handler), std::forward(first), + std::forward(descriptors)...), std::move(documentation)); + } + template , int> = 0> void post(const std::string& path, Handler&& handler) { @@ -116,6 +141,30 @@ namespace khttpd::framework detail::make_typed_handler(std::forward(handler))); } + template && + (detail::is_route_parameter_descriptor_v && ...), int> = 0> + void post(const std::string& path, Handler&& handler, FirstDescriptor&& first, + Descriptors&&... descriptors) + { + add_typed_route(path, boost::beast::http::verb::post, + detail::make_parameterized_typed_handler( + std::forward(handler), std::forward(first), + std::forward(descriptors)...)); + } + + template && + (detail::is_route_parameter_descriptor_v && ...), int> = 0> + void post(const std::string& path, Handler&& handler, RouteDocumentation documentation, + FirstDescriptor&& first, Descriptors&&... descriptors) + { + add_typed_route(path, boost::beast::http::verb::post, + detail::make_parameterized_typed_handler( + std::forward(handler), std::forward(first), + std::forward(descriptors)...), std::move(documentation)); + } + template , int> = 0> void put(const std::string& path, Handler&& handler) { @@ -123,6 +172,30 @@ namespace khttpd::framework detail::make_typed_handler(std::forward(handler))); } + template && + (detail::is_route_parameter_descriptor_v && ...), int> = 0> + void put(const std::string& path, Handler&& handler, FirstDescriptor&& first, + Descriptors&&... descriptors) + { + add_typed_route(path, boost::beast::http::verb::put, + detail::make_parameterized_typed_handler( + std::forward(handler), std::forward(first), + std::forward(descriptors)...)); + } + + template && + (detail::is_route_parameter_descriptor_v && ...), int> = 0> + void put(const std::string& path, Handler&& handler, RouteDocumentation documentation, + FirstDescriptor&& first, Descriptors&&... descriptors) + { + add_typed_route(path, boost::beast::http::verb::put, + detail::make_parameterized_typed_handler( + std::forward(handler), std::forward(first), + std::forward(descriptors)...), std::move(documentation)); + } + template , int> = 0> void del(const std::string& path, Handler&& handler) { @@ -130,6 +203,30 @@ namespace khttpd::framework detail::make_typed_handler(std::forward(handler))); } + template && + (detail::is_route_parameter_descriptor_v && ...), int> = 0> + void del(const std::string& path, Handler&& handler, FirstDescriptor&& first, + Descriptors&&... descriptors) + { + add_typed_route(path, boost::beast::http::verb::delete_, + detail::make_parameterized_typed_handler( + std::forward(handler), std::forward(first), + std::forward(descriptors)...)); + } + + template && + (detail::is_route_parameter_descriptor_v && ...), int> = 0> + void del(const std::string& path, Handler&& handler, RouteDocumentation documentation, + FirstDescriptor&& first, Descriptors&&... descriptors) + { + add_typed_route(path, boost::beast::http::verb::delete_, + detail::make_parameterized_typed_handler( + std::forward(handler), std::forward(first), + std::forward(descriptors)...), std::move(documentation)); + } + template , int> = 0> void options(const std::string& path, Handler&& handler) { @@ -137,6 +234,30 @@ namespace khttpd::framework detail::make_typed_handler(std::forward(handler))); } + template && + (detail::is_route_parameter_descriptor_v && ...), int> = 0> + void options(const std::string& path, Handler&& handler, FirstDescriptor&& first, + Descriptors&&... descriptors) + { + add_typed_route(path, boost::beast::http::verb::options, + detail::make_parameterized_typed_handler( + std::forward(handler), std::forward(first), + std::forward(descriptors)...)); + } + + template && + (detail::is_route_parameter_descriptor_v && ...), int> = 0> + void options(const std::string& path, Handler&& handler, RouteDocumentation documentation, + FirstDescriptor&& first, Descriptors&&... descriptors) + { + add_typed_route(path, boost::beast::http::verb::options, + detail::make_parameterized_typed_handler( + std::forward(handler), std::forward(first), + std::forward(descriptors)...), std::move(documentation)); + } + template , int> = 0> void get(const std::string& path, Handler&& handler, RouteDocumentation documentation) { @@ -179,6 +300,30 @@ namespace khttpd::framework detail::make_typed_member_handler(std::move(controller), method)); } + template && + (detail::is_route_parameter_descriptor_v && ...), int> = 0> + void get(const std::string& path, std::shared_ptr controller, Method method, + FirstDescriptor&& first, Descriptors&&... descriptors) + { + add_typed_route(path, boost::beast::http::verb::get, + detail::make_parameterized_member_handler( + std::move(controller), method, std::forward(first), + std::forward(descriptors)...)); + } + + template && + (detail::is_route_parameter_descriptor_v && ...), int> = 0> + void get(const std::string& path, std::shared_ptr controller, Method method, + RouteDocumentation documentation, FirstDescriptor&& first, Descriptors&&... descriptors) + { + add_typed_route(path, boost::beast::http::verb::get, + detail::make_parameterized_member_handler( + std::move(controller), method, std::forward(first), + std::forward(descriptors)...), std::move(documentation)); + } + template void post(const std::string& path, std::shared_ptr controller, Method method) { @@ -186,6 +331,30 @@ namespace khttpd::framework detail::make_typed_member_handler(std::move(controller), method)); } + template && + (detail::is_route_parameter_descriptor_v && ...), int> = 0> + void post(const std::string& path, std::shared_ptr controller, Method method, + FirstDescriptor&& first, Descriptors&&... descriptors) + { + add_typed_route(path, boost::beast::http::verb::post, + detail::make_parameterized_member_handler( + std::move(controller), method, std::forward(first), + std::forward(descriptors)...)); + } + + template && + (detail::is_route_parameter_descriptor_v && ...), int> = 0> + void post(const std::string& path, std::shared_ptr controller, Method method, + RouteDocumentation documentation, FirstDescriptor&& first, Descriptors&&... descriptors) + { + add_typed_route(path, boost::beast::http::verb::post, + detail::make_parameterized_member_handler( + std::move(controller), method, std::forward(first), + std::forward(descriptors)...), std::move(documentation)); + } + template void put(const std::string& path, std::shared_ptr controller, Method method) { @@ -193,6 +362,30 @@ namespace khttpd::framework detail::make_typed_member_handler(std::move(controller), method)); } + template && + (detail::is_route_parameter_descriptor_v && ...), int> = 0> + void put(const std::string& path, std::shared_ptr controller, Method method, + FirstDescriptor&& first, Descriptors&&... descriptors) + { + add_typed_route(path, boost::beast::http::verb::put, + detail::make_parameterized_member_handler( + std::move(controller), method, std::forward(first), + std::forward(descriptors)...)); + } + + template && + (detail::is_route_parameter_descriptor_v && ...), int> = 0> + void put(const std::string& path, std::shared_ptr controller, Method method, + RouteDocumentation documentation, FirstDescriptor&& first, Descriptors&&... descriptors) + { + add_typed_route(path, boost::beast::http::verb::put, + detail::make_parameterized_member_handler( + std::move(controller), method, std::forward(first), + std::forward(descriptors)...), std::move(documentation)); + } + template void del(const std::string& path, std::shared_ptr controller, Method method) { @@ -200,6 +393,30 @@ namespace khttpd::framework detail::make_typed_member_handler(std::move(controller), method)); } + template && + (detail::is_route_parameter_descriptor_v && ...), int> = 0> + void del(const std::string& path, std::shared_ptr controller, Method method, + FirstDescriptor&& first, Descriptors&&... descriptors) + { + add_typed_route(path, boost::beast::http::verb::delete_, + detail::make_parameterized_member_handler( + std::move(controller), method, std::forward(first), + std::forward(descriptors)...)); + } + + template && + (detail::is_route_parameter_descriptor_v && ...), int> = 0> + void del(const std::string& path, std::shared_ptr controller, Method method, + RouteDocumentation documentation, FirstDescriptor&& first, Descriptors&&... descriptors) + { + add_typed_route(path, boost::beast::http::verb::delete_, + detail::make_parameterized_member_handler( + std::move(controller), method, std::forward(first), + std::forward(descriptors)...), std::move(documentation)); + } + template void options(const std::string& path, std::shared_ptr controller, Method method) { @@ -207,6 +424,31 @@ namespace khttpd::framework detail::make_typed_member_handler(std::move(controller), method)); } + template && + (detail::is_route_parameter_descriptor_v && ...), int> = 0> + void options(const std::string& path, std::shared_ptr controller, Method method, + FirstDescriptor&& first, Descriptors&&... descriptors) + { + add_typed_route(path, boost::beast::http::verb::options, + detail::make_parameterized_member_handler( + std::move(controller), method, std::forward(first), + std::forward(descriptors)...)); + } + + template && + (detail::is_route_parameter_descriptor_v && ...), int> = 0> + void options(const std::string& path, std::shared_ptr controller, Method method, + RouteDocumentation documentation, FirstDescriptor&& first, + Descriptors&&... descriptors) + { + add_typed_route(path, boost::beast::http::verb::options, + detail::make_parameterized_member_handler( + std::move(controller), method, std::forward(first), + std::forward(descriptors)...), std::move(documentation)); + } + template void get(const std::string& path, std::shared_ptr controller, Method method, RouteDocumentation documentation) @@ -311,13 +553,15 @@ namespace khttpd::framework std::optional request_schema = std::nullopt, std::optional response_schema = std::nullopt, bool documented = true, - RouteDocumentation documentation = {}); + RouteDocumentation documentation = {}, + std::vector parameters = {}); void add_typed_route(const std::string& path_pattern, boost::beast::http::verb method, detail::TypedRouteHandler handler, RouteDocumentation documentation = {}); void record_route_descriptor(const std::string& path, boost::beast::http::verb method, std::optional request_schema = std::nullopt, std::optional response_schema = std::nullopt, - RouteDocumentation documentation = {}); + RouteDocumentation documentation = {}, + std::vector parameters = {}); static std::tuple, int, int> parse_path_pattern( const std::string& path_pattern); diff --git a/framework/router/openapi.cpp b/framework/router/openapi.cpp index e55b307..32d4630 100644 --- a/framework/router/openapi.cpp +++ b/framework/router/openapi.cpp @@ -63,16 +63,38 @@ namespace khttpd::framework if (!descriptor.documentation.description.empty()) operation.emplace("description", descriptor.documentation.description); boost::json::array parameters; + const auto find_parameter = [&](const RouteParameterLocation location, + const std::string& name) -> const RouteParameterDocumentation* + { + const auto it = std::find_if(descriptor.parameters.begin(), descriptor.parameters.end(), + [&](const RouteParameterDocumentation& parameter) + { + return parameter.location == location && parameter.name == name; + }); + return it == descriptor.parameters.end() ? nullptr : &*it; + }; 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"}}); + const auto* documented = find_parameter(RouteParameterLocation::path, path_parameters[index]); + parameter.emplace("schema", documented != nullptr ? documented->schema : + boost::json::value(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& documented : descriptor.parameters) + { + if (documented.location != RouteParameterLocation::query) continue; + boost::json::object parameter; + parameter.emplace("name", documented.name); + parameter.emplace("in", "query"); + parameter.emplace("required", documented.required); + parameter.emplace("schema", documented.schema); + parameters.emplace_back(std::move(parameter)); + } for (const auto& header : descriptor.documentation.headers) { if (header.name.empty()) continue; diff --git a/framework/router/route_parameter.hpp b/framework/router/route_parameter.hpp new file mode 100644 index 0000000..1513e56 --- /dev/null +++ b/framework/router/route_parameter.hpp @@ -0,0 +1,86 @@ +#ifndef KHTTPD_FRAMEWORK_ROUTER_ROUTE_PARAMETER_HPP_ +#define KHTTPD_FRAMEWORK_ROUTER_ROUTE_PARAMETER_HPP_ + +#include + +#include +#include +#include +#include +#include + +namespace khttpd::framework +{ + enum class RouteParameterLocation + { + path, + query, + }; + + struct RouteParameterDocumentation + { + std::string name; + RouteParameterLocation location; + bool required; + boost::json::value schema; + }; + + template + struct PathParam + { + using value_type = T; + + explicit PathParam(std::string parameter_name) : name(std::move(parameter_name)) {} + + std::string name; + }; + + template + struct QueryParam + { + using value_type = T; + + explicit QueryParam(std::string parameter_name) : name(std::move(parameter_name)) {} + QueryParam(std::string parameter_name, T default_parameter_value) + : name(std::move(parameter_name)), default_value(std::move(default_parameter_value)) {} + + std::string name; + std::optional default_value; + }; + + template + struct Body + { + using value_type = T; + }; +} + +namespace khttpd::framework::detail +{ + template + struct is_route_parameter_descriptor : std::false_type {}; + + template + struct is_route_parameter_descriptor> : std::true_type {}; + + template + struct is_route_parameter_descriptor> : std::true_type {}; + + template + struct is_route_parameter_descriptor> : std::true_type {}; + + template + inline constexpr bool is_route_parameter_descriptor_v = + is_route_parameter_descriptor>::value; + + template + struct is_body_descriptor : std::false_type {}; + + template + struct is_body_descriptor> : std::true_type {}; + + template + inline constexpr bool is_body_descriptor_v = is_body_descriptor>::value; +} + +#endif diff --git a/framework/router/typed_route.hpp b/framework/router/typed_route.hpp index f4fc396..8fe238e 100644 --- a/framework/router/typed_route.hpp +++ b/framework/router/typed_route.hpp @@ -3,10 +3,12 @@ #include "router/http_result.hpp" #include "router/openapi_schema.hpp" +#include "router/route_parameter.hpp" #include #include +#include #include #include #include @@ -28,6 +30,12 @@ namespace khttpd::framework public: using std::runtime_error::runtime_error; }; + + class TypedParameterValidationError final : public std::runtime_error + { + public: + using std::runtime_error::runtime_error; + }; } namespace khttpd::framework::detail @@ -35,8 +43,9 @@ namespace khttpd::framework::detail struct TypedRouteHandler { std::function handler; - boost::json::value request_schema; + std::optional request_schema; std::optional response_schema; + std::vector parameters; }; template @@ -91,6 +100,15 @@ namespace khttpd::framework::detail context.set_body_json(error); } + inline void write_invalid_request_parameter(HttpContext& context, const std::string& message) + { + context.set_status(boost::beast::http::status::bad_request); + boost::json::object error; + error.emplace("code", "INVALID_REQUEST_PARAMETER"); + error.emplace("message", message); + context.set_body_json(error); + } + inline bool is_json_media_type(std::string content_type) { if (const auto semicolon = content_type.find(';'); semicolon != std::string::npos) @@ -117,6 +135,216 @@ namespace khttpd::framework::detail media_type.compare(media_type.size() - suffix.size(), suffix.size(), suffix) == 0); } + template + T parse_parameter_value(const std::string& value, const char* location, const std::string& name) + { + using Value = remove_cvref_t; + if constexpr (is_optional_v) + { + using Item = typename is_optional::value_type; + return Value{parse_parameter_value(value, location, name)}; + } + else if constexpr (std::is_same_v) + { + return value; + } + else if constexpr (std::is_same_v) + { + if (value == "true") return true; + if (value == "false") return false; + } + else if constexpr (std::is_integral_v) + { + Value converted{}; + const auto result = std::from_chars(value.data(), value.data() + value.size(), converted); + if (result.ec == std::errc{} && result.ptr == value.data() + value.size()) return converted; + } + else if constexpr (std::is_floating_point_v) + { + Value converted{}; + const auto result = std::from_chars(value.data(), value.data() + value.size(), converted); + if (result.ec == std::errc{} && result.ptr == value.data() + value.size()) return converted; + } + else + { + static_assert(std::is_same_v, + "route parameters support string, bool, integral, and floating-point values"); + } + + throw TypedParameterValidationError( + std::string("Invalid ") + location + " parameter '" + name + "'"); + } + + template + T read_route_parameter(const PathParam& descriptor, HttpContext& context) + { + const auto value = context.get_path_param(descriptor.name); + if (!value) + throw TypedParameterValidationError("Missing path parameter '" + descriptor.name + "'"); + return parse_parameter_value(*value, "path", descriptor.name); + } + + template + T read_route_parameter(const QueryParam& descriptor, HttpContext& context) + { + const auto value = context.get_query_param(descriptor.name); + if (value) return parse_parameter_value(*value, "query", descriptor.name); + if (descriptor.default_value) return *descriptor.default_value; + if constexpr (is_optional_v) return std::nullopt; + throw TypedParameterValidationError("Missing query parameter '" + descriptor.name + "'"); + } + + template + T read_route_parameter(const Body&, HttpContext& context) + { + const auto content_type = context.get_header(boost::beast::http::field::content_type); + if (!content_type || !is_json_media_type(*content_type)) + throw TypedRequestValidationError("Request body must be valid JSON matching the expected schema"); + + try + { + return boost::json::value_to(boost::json::parse(context.body())); + } + catch (const std::bad_alloc&) + { + throw; + } + catch (const std::exception&) + { + throw TypedRequestValidationError("Request body must be valid JSON matching the expected schema"); + } + } + + template + void set_request_schema(std::optional& schema) + { + using Value = std::decay_t; + if constexpr (std::is_same_v>) + schema.emplace(openapi_schema()); + } + + template + void append_parameter_documentation(std::vector& parameters, + const PathParam& descriptor) + { + parameters.push_back( + {descriptor.name, RouteParameterLocation::path, true, openapi_schema()}); + } + + template + void append_parameter_documentation(std::vector& parameters, + const QueryParam& descriptor) + { + auto schema = openapi_schema(); + if (descriptor.default_value) + { + if constexpr (is_optional_v) + { + if (*descriptor.default_value) + schema.as_object().emplace("default", boost::json::value_from(**descriptor.default_value)); + } + else + { + schema.as_object().emplace("default", boost::json::value_from(*descriptor.default_value)); + } + } + parameters.push_back({descriptor.name, RouteParameterLocation::query, + !descriptor.default_value && !is_optional_v, std::move(schema)}); + } + + template + void append_parameter_documentation(std::vector&, const Body&) + { + } + + template + TypedRouteHandler make_parameterized_typed_handler_with_response( + Handler&& input_handler, Descriptors&&... input_descriptors) + { + static_assert((is_route_parameter_descriptor_v && ...), + "all typed route bindings must be route parameter descriptors"); + static_assert((0U + ... + (is_body_descriptor_v ? 1U : 0U)) <= 1U, + "a typed route can register at most one Body descriptor"); + using StoredHandler = std::decay_t; + static_assert(!std::is_void_v, + "typed handlers must return a body or HttpResult"); + static_assert(!std::is_reference_v, + "typed handlers must return responses by value"); + + std::vector parameters; + (append_parameter_documentation(parameters, input_descriptors), ...); + auto descriptors = std::make_tuple(std::forward(input_descriptors)...); + auto adapted = [handler = StoredHandler(std::forward(input_handler)), + descriptors = std::move(descriptors)](HttpContext& context) mutable + { + auto values = std::apply([&context](const auto&... descriptor) + { + return std::make_tuple(read_route_parameter(descriptor, context)...); + }, descriptors); + + std::apply([&](auto&... value) + { + if constexpr (std::is_invocable_v) + { + auto response = std::invoke(handler, value..., context); + apply_typed_response(context, std::move(response)); + } + else + { + static_assert(std::is_invocable_v, + "typed handler arguments must match the registered route descriptors"); + auto response = std::invoke(handler, value...); + apply_typed_response(context, std::move(response)); + } + }, values); + }; + + std::optional request_schema; + (set_request_schema>(request_schema), ...); + using ResponseBody = typename typed_response_body::type; + std::optional response_schema; + if constexpr (!std::is_void_v) response_schema.emplace(openapi_schema()); + return {std::move(adapted), std::move(request_schema), std::move(response_schema), + std::move(parameters)}; + } + + template + TypedRouteHandler make_parameterized_typed_handler(Handler&& input_handler, + Descriptors&&... input_descriptors) + { + using Response = typename callable_traits>::return_type; + return make_parameterized_typed_handler_with_response( + std::forward(input_handler), std::forward(input_descriptors)...); + } + + template + TypedRouteHandler make_parameterized_member_handler( + std::shared_ptr controller, + Response (Controller::*method)(Arguments...), + Descriptors&&... descriptors) + { + auto bound = [controller = std::move(controller), method](auto&... value) -> Response + { + return std::invoke(method, *controller, value...); + }; + return make_parameterized_typed_handler_with_response( + std::move(bound), std::forward(descriptors)...); + } + + template + TypedRouteHandler make_parameterized_member_handler( + std::shared_ptr controller, + Response (Controller::*method)(Arguments...) const, + Descriptors&&... descriptors) + { + auto bound = [controller = std::move(controller), method](auto&... value) -> Response + { + return std::invoke(method, *controller, value...); + }; + return make_parameterized_typed_handler_with_response( + std::move(bound), std::forward(descriptors)...); + } + template TypedRouteHandler make_typed_handler(Handler&& input_handler) { @@ -184,7 +412,7 @@ namespace khttpd::framework::detail using ResponseBody = typename typed_response_body::type; std::optional response_schema; if constexpr (!std::is_void_v) response_schema.emplace(openapi_schema()); - return {std::move(adapted), openapi_schema(), std::move(response_schema)}; + return {std::move(adapted), openapi_schema(), std::move(response_schema), {}}; } template diff --git a/framework/tests/openapi_test.cpp b/framework/tests/openapi_test.cpp index 398027f..a5f8d3b 100644 --- a/framework/tests/openapi_test.cpp +++ b/framework/tests/openapi_test.cpp @@ -162,6 +162,51 @@ TEST(OpenApiTest, NormalizesColonAndBracePathParameters) EXPECT_EQ(parameters.at(1).as_object().at("name"), "user_id"); } +TEST(OpenApiTest, DocumentsTypedRouteParameterDescriptors) +{ + fw::HttpRouter router; + fw::RouteDocumentation documentation; + documentation.summary = "Update a pet"; + documentation.description = "Updates a pet using bound route parameters."; + router.put("/pets/{pet_id}", + [](const int pet_id, const OpenApiCreatePetRequest& request, const bool notify, + const std::optional& tag) + { + return OpenApiPetResponse{pet_id, notify ? request.name : tag.value_or(request.name), {}}; + }, + documentation, + fw::PathParam{"pet_id"}, + fw::Body{}, + fw::QueryParam{"notify", false}, + fw::QueryParam>{"tag"}); + + const auto document = fw::generate_openapi(router); + const auto& operation = operation_at(document, "/pets/{pet_id}", "put"); + EXPECT_EQ(operation.at("summary"), "Update a pet"); + EXPECT_EQ(operation.at("description"), "Updates a pet using bound route parameters."); + const auto& parameters = operation.at("parameters").as_array(); + + ASSERT_EQ(parameters.size(), 3U); + EXPECT_EQ(parameters.at(0).as_object().at("name"), "pet_id"); + EXPECT_EQ(parameters.at(0).as_object().at("in"), "path"); + EXPECT_EQ(parameters.at(0).as_object().at("required"), true); + EXPECT_EQ(parameters.at(0).as_object().at("schema").as_object().at("type"), "integer"); + EXPECT_EQ(parameters.at(1).as_object().at("name"), "notify"); + EXPECT_EQ(parameters.at(1).as_object().at("in"), "query"); + EXPECT_EQ(parameters.at(1).as_object().at("required"), false); + EXPECT_EQ(parameters.at(1).as_object().at("schema").as_object().at("type"), "boolean"); + EXPECT_EQ(parameters.at(1).as_object().at("schema").as_object().at("default"), false); + EXPECT_EQ(parameters.at(2).as_object().at("name"), "tag"); + EXPECT_EQ(parameters.at(2).as_object().at("in"), "query"); + EXPECT_EQ(parameters.at(2).as_object().at("required"), false); + EXPECT_EQ(parameters.at(2).as_object().at("schema").as_object().at("type"), "string"); + + const auto& request_schema = operation.at("requestBody").as_object().at("content").as_object() + .at("application/json").as_object().at("schema").as_object(); + EXPECT_EQ(request_schema.at("type"), "object"); + EXPECT_TRUE(request_schema.at("properties").as_object().contains("name")); +} + TEST(OpenApiTest, IncludesTypedDescribeSchemas) { fw::HttpRouter router; diff --git a/framework/tests/typed_route_test.cpp b/framework/tests/typed_route_test.cpp index c45754d..dfa2afc 100644 --- a/framework/tests/typed_route_test.cpp +++ b/framework/tests/typed_route_test.cpp @@ -111,6 +111,16 @@ namespace KHTTPD_TYPED_ROUTE(post, "/member", create); KHTTPD_TYPED_ROUTE(post, "/const-member", lookup); KHTTPD_TYPED_ROUTE(post, "/with-context", with_context); + router.put("/parameterized/{id}", shared_from_this(), &TypedController::parameterized, + fw::PathParam{"id"}, fw::QueryParam{"notify", false}); + router.get("/parameterized-get/{id}", shared_from_this(), &TypedController::parameterized, + fw::PathParam{"id"}, fw::QueryParam{"notify", false}); + router.post("/parameterized-post/{id}", shared_from_this(), &TypedController::parameterized, + fw::PathParam{"id"}, fw::QueryParam{"notify", false}); + router.del("/parameterized-delete/{id}", shared_from_this(), &TypedController::parameterized, + fw::PathParam{"id"}, fw::QueryParam{"notify", false}); + router.options("/parameterized-options/{id}", shared_from_this(), &TypedController::parameterized, + fw::PathParam{"id"}, fw::QueryParam{"notify", false}); return shared_from_this(); } @@ -129,6 +139,11 @@ namespace { return Reply{request.age, context.get_header("X-Display-Name").value_or(request.name)}; } + + Reply parameterized(const int id, const bool notify, fw::HttpContext& context) + { + return Reply{id, notify ? context.path() : "quiet"}; + } }; } @@ -236,6 +251,162 @@ TEST(TypedRouteTest, ConvertsJsonAndAppliesHttpResult) EXPECT_EQ(response.body(), R"({"id":42,"name":"Ada"})"); } +TEST(TypedRouteTest, BindsPathBodyAndQueryDescriptorsWithContext) +{ + fw::HttpRouter router; + router.put("/users/{id}", + [](const int id, const CreateRequest& request, const bool notify, fw::HttpContext& context) + { + const auto suffix = notify ? ":notify" : ":quiet"; + return Reply{id, request.name + suffix + context.get_header("X-Trace").value_or("")}; + }, + fw::PathParam{"id"}, + fw::Body{}, + fw::QueryParam{"notify"}); + + auto request = json_request("/users/42?notify=true", R"({"name":"Ada","age":20})"); + request.method(http::verb::put); + request.set("X-Trace", ":trace"); + http::response response; + auto context = make_context(request, response); + + EXPECT_TRUE(router.dispatch(context)); + EXPECT_EQ(response.result(), http::status::ok); + EXPECT_EQ(response.body(), R"({"id":42,"name":"Ada:notify:trace"})"); +} + +TEST(TypedRouteTest, SupportsDefaultedAndOptionalQueryDescriptorsWithoutBody) +{ + fw::HttpRouter router; + router.get("/users", + [](const int page, const std::optional& keyword) + { + return Reply{page, keyword.value_or("all")}; + }, + fw::QueryParam{"page", 1}, + fw::QueryParam>{"keyword"}); + + { + http::request request(http::verb::get, "/users", 11); + http::response response; + auto context = make_context(request, response); + EXPECT_TRUE(router.dispatch(context)); + EXPECT_EQ(response.body(), R"({"id":1,"name":"all"})"); + } + + { + http::request request(http::verb::get, "/users?page=3&keyword=Ada", 11); + http::response response; + auto context = make_context(request, response); + EXPECT_TRUE(router.dispatch(context)); + EXPECT_EQ(response.body(), R"({"id":3,"name":"Ada"})"); + } +} + +TEST(TypedRouteTest, StrictlyConvertsSupportedScalarDescriptors) +{ + fw::HttpRouter router; + router.get("/metrics/{scope}", + [](const std::string& scope, const double ratio, const bool enabled) + { + return Reply{static_cast(ratio * 10), scope + (enabled ? ":on" : ":off")}; + }, + fw::PathParam{"scope"}, + fw::QueryParam{"ratio"}, + fw::QueryParam{"enabled"}); + + http::request request( + http::verb::get, "/metrics/search?ratio=1.25&enabled=true", 11); + http::response response; + auto context = make_context(request, response); + + EXPECT_TRUE(router.dispatch(context)); + EXPECT_EQ(response.body(), R"({"id":12,"name":"search:on"})"); +} + +TEST(TypedRouteTest, InvalidDescriptorValuesReturnStableBadRequestWithoutCallingHandler) +{ + struct Case + { + const char* target; + const char* message; + }; + const Case cases[] = { + {"/users", "Missing query parameter 'page'"}, + {"/users?page=3x", "Invalid query parameter 'page'"}, + }; + + for (const auto& test_case : cases) + { + fw::HttpRouter router; + int calls = 0; + router.get("/users", [&calls](const int page) + { + ++calls; + return Reply{page, "called"}; + }, fw::QueryParam{"page"}); + + http::request request(http::verb::get, test_case.target, 11); + http::response response; + auto context = make_context(request, response); + EXPECT_TRUE(router.dispatch(context)); + EXPECT_EQ(calls, 0); + EXPECT_EQ(response.result(), http::status::bad_request); + EXPECT_EQ(response[http::field::content_type], "application/json"); + EXPECT_EQ(response.body(), std::string(R"({"code":"INVALID_REQUEST_PARAMETER","message":")") + + test_case.message + R"("})"); + } +} + +TEST(TypedRouteTest, MapsInvalidDescriptorValuesThroughTheExceptionPipeline) +{ + fw::HttpRouter router; + int calls = 0; + router.map_exception( + [](const fw::TypedParameterValidationError& error) + { + return fw::HttpResult( + http::status::unprocessable_entity, + ErrorReply{"AUTH_INVALID_PARAMETER", error.what()}); + }); + router.get("/users", [&calls](const int page) + { + ++calls; + return Reply{page, "called"}; + }, fw::QueryParam{"page"}); + + http::request request(http::verb::get, "/users?page=invalid", 11); + http::response response; + auto context = make_context(request, response); + + EXPECT_TRUE(router.dispatch(context)); + EXPECT_EQ(calls, 0); + EXPECT_EQ(response.result(), http::status::unprocessable_entity); + EXPECT_EQ(response.body(), + R"({"code":"AUTH_INVALID_PARAMETER","message":"Invalid query parameter 'page'"})"); +} + +TEST(TypedRouteTest, RejectsInvalidDescriptorDeclarationsAtRegistration) +{ + fw::HttpRouter router; + const auto one_argument = [](const int value) + { + return Reply{value, "one"}; + }; + const auto two_arguments = [](const int first, const int second) + { + return Reply{first + second, "two"}; + }; + + EXPECT_THROW(router.get("/users/{id}", one_argument, fw::PathParam{"other"}), + std::invalid_argument); + EXPECT_THROW(router.get("/users", one_argument, fw::QueryParam{""}), + std::invalid_argument); + EXPECT_THROW(router.get("/users", two_arguments, + fw::QueryParam{"page"}, fw::QueryParam{"page"}), + std::invalid_argument); +} + TEST(TypedRouteTest, InvalidBodiesReturnStableBadRequestWithoutCallingHandler) { const std::string expected = @@ -401,6 +572,42 @@ TEST(TypedRouteTest, SupportsStdFunctionAndEveryBufferedVerb) } } +TEST(TypedRouteTest, SupportsDescriptorsForEveryBufferedVerb) +{ + fw::HttpRouter router; + const auto handler = [](const int value) + { + return Reply{value, "bound"}; + }; + router.get("/descriptor-get", handler, fw::QueryParam{"value"}); + router.post("/descriptor-post", handler, fw::QueryParam{"value"}); + router.put("/descriptor-put", handler, fw::QueryParam{"value"}); + router.del("/descriptor-delete", handler, fw::QueryParam{"value"}); + router.options("/descriptor-options", handler, fw::QueryParam{"value"}); + + const struct + { + http::verb verb; + const char* path; + } cases[] = { + {http::verb::get, "/descriptor-get?value=1"}, + {http::verb::post, "/descriptor-post?value=2"}, + {http::verb::put, "/descriptor-put?value=3"}, + {http::verb::delete_, "/descriptor-delete?value=4"}, + {http::verb::options, "/descriptor-options?value=5"}, + }; + + for (std::size_t index = 0; index < std::size(cases); ++index) + { + http::request request(cases[index].verb, cases[index].path, 11); + http::response response; + auto context = make_context(request, response); + EXPECT_TRUE(router.dispatch(context)); + EXPECT_EQ(response.body(), std::string(R"({"id":)") + std::to_string(index + 1) + + R"(,"name":"bound"})"); + } +} + TEST(TypedRouteTest, SupportsControllerMembersConstMembersAndContextInjection) { fw::HttpRouter router; @@ -436,6 +643,33 @@ TEST(TypedRouteTest, SupportsControllerMembersConstMembersAndContextInjection) EXPECT_TRUE(router.dispatch(context)); EXPECT_EQ(response.body(), R"({"id":12,"name":"Header Name"})"); } + + { + http::request request(http::verb::put, "/parameterized/17", 11); + http::response response; + auto context = make_context(request, response); + EXPECT_TRUE(router.dispatch(context)); + EXPECT_EQ(response.body(), R"({"id":17,"name":"quiet"})"); + } + + const struct + { + http::verb verb; + const char* path; + } descriptor_cases[] = { + {http::verb::get, "/parameterized-get/18"}, + {http::verb::post, "/parameterized-post/18"}, + {http::verb::delete_, "/parameterized-delete/18"}, + {http::verb::options, "/parameterized-options/18"}, + }; + for (const auto& test_case : descriptor_cases) + { + http::request request(test_case.verb, test_case.path, 11); + http::response response; + auto context = make_context(request, response); + EXPECT_TRUE(router.dispatch(context)); + EXPECT_EQ(response.body(), R"({"id":18,"name":"quiet"})"); + } } TEST(TypedRouteCompatibilityTest, LegacyHttpContextHandlerRemainsUnchanged) From 4cc82f59453b7ac5c294932fc4cfa990f25deb1b Mon Sep 17 00:00:00 2001 From: kekxv Date: Sat, 22 Aug 2026 23:10:04 +0000 Subject: [PATCH 3/3] chore: release khttpd 0.4.6 --- MODULE.bazel | 2 +- example/MODULE.bazel | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/MODULE.bazel b/MODULE.bazel index 63a3ed2..3a459ee 100644 --- a/MODULE.bazel +++ b/MODULE.bazel @@ -1,6 +1,6 @@ module( name = "khttpd", - version = "0.4.5", + version = "0.4.6", ) bazel_dep(name = "platforms", version = "1.1.0") diff --git a/example/MODULE.bazel b/example/MODULE.bazel index 6b126f2..96f4c29 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.5") +bazel_dep(name = "khttpd", version = "0.4.6") local_path_override( module_name = "khttpd", path = "..",