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.5",
version = "0.4.6",
)

bazel_dep(name = "platforms", version = "1.1.0")
Expand Down
35 changes: 34 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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<UserResponse> 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<std::string>{"id"},
Body<UpdateUserRequest>{},
QueryParam<bool>{"notify", false});
```

`PathParam<T>` and `QueryParam<T>` support strings, integral and floating-point values, and strict `true`/`false` booleans.
`QueryParam<T>{"name"}` is required, `QueryParam<std::optional<T>>{"name"}` is optional, and
`QueryParam<T>{"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:
Expand All @@ -304,13 +329,20 @@ router.map_exception<khttpd::framework::TypedRequestValidationError>(
boost::beast::http::status::bad_request,
{"INVALID_REQUEST", error.what()});
});
router.map_exception<khttpd::framework::TypedParameterValidationError>(
[](const auto& error) {
return khttpd::framework::HttpResult<ErrorResponse>(
boost::beast::http::status::bad_request,
{"INVALID_PARAMETER", error.what()});
});
```

### OpenAPI 3.1 documentation

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:
Expand Down Expand Up @@ -376,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<T>`
(`/stream/:size` and `/hello/hello`), and documented typed `POST /typed/greetings` and
`PUT /typed/greetings/{id}?excited=true`, along with `HttpResult<T>`
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:

Expand Down
57 changes: 56 additions & 1 deletion doc/api-reference.md
Original file line number Diff line number Diff line change
Expand Up @@ -201,6 +201,59 @@ Controller 可使用:
KHTTPD_TYPED_ROUTE(post, "/users", create_user);
```

### C++17 路径、查询和请求体参数绑定

注册路由时追加参数描述符,可将路径参数、查询参数和 JSON DTO 按顺序传给普通 handler 参数,不需要为字段名定义宏:

```cpp
HttpResult<UserResponse> update_user(
std::string id,
const UpdateUserRequest& body,
bool notify,
HttpContext& context);

router.put(
"/users/{id}",
update_user,
{"编辑用户", "编辑用户资料,并可选择发送通知。"},
PathParam<std::string>{"id"},
Body<UpdateUserRequest>{},
QueryParam<bool>{"notify", false});
```

描述符顺序必须与 handler 参数顺序一致,最后可以额外声明一个 `HttpContext&`。Controller 成员函数使用相同描述符:

```cpp
router.put(base_path() + "/users/{id}", shared_from_this(),
&UserController::update_user,
PathParam<std::string>{"id"}, Body<UpdateUserRequest>{});
```

| 描述符 | 语义 |
|------|------|
| `PathParam<T>{"id"}` | 必填路径参数;名称必须存在于 `:id` 或 `{id}` 路由模板 |
| `QueryParam<T>{"page"}` | 必填查询参数 |
| `QueryParam<std::optional<T>>{"keyword"}` | 可选查询参数,缺失时为 `std::nullopt` |
| `QueryParam<T>{"page", 1}` | 可选查询参数,缺失时使用默认值 |
| `Body<T>{}` | 将 JSON 请求体转换为 DTO;每条路由最多一个 |

路径和查询参数支持 `std::string`、整数、浮点数及布尔值;数值必须完整解析,布尔值只接受 `true` 或 `false`。
缺失或格式错误会抛出 `TypedParameterValidationError`,未映射时返回 HTTP 400 和
`INVALID_REQUEST_PARAMETER`。它与请求体异常一样经过统一异常映射管线:

```cpp
router.map_exception<khttpd::framework::TypedParameterValidationError>(
[](const auto& error) {
return khttpd::framework::HttpResult<ErrorResponse>(
boost::beast::http::status::bad_request,
{"INVALID_PARAMETER", error.what()});
});
```

描述符还会生成 OpenAPI 参数和请求体 schema:路径参数始终为 required;可选或有默认值的 query 参数为
optional,默认值写入 schema。`RouteDocumentation` 可放在 handler 后、描述符前,同时保留 summary、description
和请求头说明。

原有 `KHTTPD_ROUTE`、`void(HttpContext&)` 和所有路由分发行为保持不变。强类型路由仍执行相同的前置/后置拦截器,
不能替代鉴权拦截器。

Expand All @@ -222,9 +275,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` 参数定义。

### 路由优先级

当多个路由同时匹配时,按以下规则排序:
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.5")
bazel_dep(name = "khttpd", version = "0.4.6")
local_path_override(
module_name = "khttpd",
path = "..",
Expand Down
14 changes: 14 additions & 0 deletions example/TypedHelloController.hpp
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,12 @@ class TypedHelloController final : public khttpd::framework::BaseController<Type
KHTTPD_DOCUMENTED_TYPED_ROUTE(post, "/greetings", create_greeting,
{"Create a greeting",
"Validates a name and returns a created greeting with its Location header."});
router.put(base_path() + "/greetings/{id}", shared_from_this(),
&TypedHelloController::update_greeting,
{"Update a greeting", "Binds a path value, JSON DTO, query value, and HttpContext."},
khttpd::framework::PathParam<int>{"id"},
khttpd::framework::Body<CreateGreetingRequest>{},
khttpd::framework::QueryParam<bool>{"excited", false});
return shared_from_this();
}

Expand All @@ -77,6 +83,14 @@ class TypedHelloController final : public khttpd::framework::BaseController<Type
return result.header("Location", "/typed/greetings/latest")
.header("X-Example-Handler", "typed");
}

GreetingResponse update_greeting(int id, const CreateGreetingRequest& request, bool excited,
khttpd::framework::HttpContext& context) const
{
const auto request_id = context.get_header("X-Request-Id").value_or("none");
return {"Greeting " + std::to_string(id) + ": Hello, " + request.name +
(excited ? "!" : ".") + " request=" + request_id};
}
};

#endif // KHTTPD_EXAMPLE_TYPED_HELLO_CONTROLLER_HPP_
7 changes: 7 additions & 0 deletions example/main.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,13 @@ namespace
beast::http::status::bad_request,
{"INVALID_TYPED_REQUEST", error.what()});
});
http_router.map_exception<khttpd::framework::TypedParameterValidationError>(
[](const khttpd::framework::TypedParameterValidationError& error)
{
return khttpd::framework::HttpResult<GreetingErrorResponse>(
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);
Expand Down
3 changes: 2 additions & 1 deletion framework/controller/http_controller.hpp
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,8 @@ router.VERB(base_path() + PATH, bind_handler(&std::decay_t<decltype(*this)>::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<decltype(*this)>::METHOD_NAME)
router.VERB(base_path() + PATH, this->shared_from_this(), \
&std::decay_t<decltype(*this)>::METHOD_NAME)
#endif
#ifndef KHTTPD_DOCUMENTED_TYPED_ROUTE
#define KHTTPD_DOCUMENTED_TYPED_ROUTE(VERB, PATH, METHOD_NAME, ...) \
Expand Down
39 changes: 34 additions & 5 deletions framework/router/http_router.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -154,11 +154,12 @@ namespace khttpd::framework
std::optional<boost::json::value> request_schema,
std::optional<boost::json::value> response_schema,
const bool documented,
RouteDocumentation documentation)
RouteDocumentation documentation,
std::vector<RouteParameterDocumentation> 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)
Expand Down Expand Up @@ -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<boost::json::value> request_schema,
std::optional<boost::json::value> response_schema,
RouteDocumentation documentation)
RouteDocumentation documentation,
std::vector<RouteParameterDocumentation> parameters)
{
if (documentation.request_schema) request_schema = documentation.request_schema;
if (documentation.response_schema) response_schema = documentation.response_schema;
Expand All @@ -216,14 +238,16 @@ 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);
return;
}
}
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<RouteDescriptor> HttpRouter::route_descriptors() const
Expand Down Expand Up @@ -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);
Expand Down
Loading
Loading