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
7 changes: 7 additions & 0 deletions .bcr/presubmit.yml
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,12 @@ matrix:
- 7.x
- 8.x

module_bazel_deps:
- name: boost.asio
version: 1.90.0.bcr.1
- name: boost.mysql
version: 1.90.0.bcr.1

tasks:
verify_targets:
name: Verify build targets
Expand All @@ -24,6 +30,7 @@ tasks:
bazel: ${{ bazel }}
build_flags:
- '--cxxopt=/std:c++17'
- '--cxxopt=/utf-8'
- '--@boost.asio//:ssl=boringssl'
- '--@boost.mysql//:ssl=boringssl'
build_targets:
Expand Down
14 changes: 7 additions & 7 deletions MODULE.bazel
Original file line number Diff line number Diff line change
@@ -1,16 +1,16 @@
module(
name = "khttpd",
version = "0.3.0",
version = "0.4.0",
)

bazel_dep(name = "platforms", version = "1.1.0")
bazel_dep(name = "bazel_skylib", version = "1.9.0")
bazel_dep(name = "rules_cc", version = "0.2.20")
bazel_dep(name = "bazel_skylib", version = "1.9.2")
bazel_dep(name = "rules_cc", version = "0.2.22")
bazel_dep(name = "rules_shell", version = "0.8.0")
bazel_dep(name = "rules_perl", version = "1.1.1")
bazel_dep(name = "fmt", version = "12.1.0")
bazel_dep(name = "googletest", version = "1.17.0.bcr.2")
bazel_dep(name = "sqlite3", version = "3.53.2")
bazel_dep(name = "rules_perl", version = "1.1.2")
bazel_dep(name = "fmt", version = "12.2.0")
bazel_dep(name = "googletest", version = "1.18.0")
bazel_dep(name = "sqlite3", version = "3.53.3")
bazel_dep(name = "openssl", version = "4.0.1.bcr.0")
bazel_dep(name = "boringssl", version = "0.20260616.0")
bazel_dep(name = "boost", version = "1.90.0.bcr.1")
Expand Down
104 changes: 98 additions & 6 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,8 @@ and [Boost.Asio](https://www.boost.org/doc/libs/release/libs/asio/), managed wit
specificity sorting
- **Controller Pattern** — CRTP-based `BaseController` with `KHTTPD_ROUTE` / `KHTTPD_WSROUTE` macros for clean route
definitions
- **Typed JSON Routes** — Request/response inference for lambdas and controller members, `HttpResult<T>` status/headers,
and serializable exception mapping while retaining the original `HttpContext&` API
- **HTTP Client** — Sync & async HTTP client with SSL, bearer token, base URL, and JSON body serialization
- **Oat++-style API Client** — Declarative API definition with `KHTTPD_API_CLIENT`, multi-host support with weight-based routing
- **WebSocket Client** — Async WebSocket client counterpart
Expand Down Expand Up @@ -230,6 +232,94 @@ class MyController : public khttpd::framework::BaseController<MyController> {
MyController::create()->register_routes(server->get_http_router());
```

### Typed JSON routes

Typed routes infer the request and response from a callable or controller member. DTOs described with Boost.Describe work
with the framework's Boost.JSON conversion support:

```cpp
struct CreateUserRequest { std::string name; int age; };
struct UserResponse { int id; std::string name; };

BOOST_DESCRIBE_STRUCT(CreateUserRequest, (), (name, age))
BOOST_DESCRIBE_STRUCT(UserResponse, (), (id, name))

class UserController final : public khttpd::framework::BaseController<UserController> {
public:
std::shared_ptr<BaseController> register_routes(HttpRouter& router) override {
KHTTPD_TYPED_ROUTE(post, "/users", create_user);
return shared_from_this();
}

private:
khttpd::framework::HttpResult<UserResponse> create_user(const CreateUserRequest& request) {
auto result = khttpd::framework::HttpResult<UserResponse>::created({1001, request.name});
return result.header("Location", "/users/1001");
}
};
```

A bare JSON-serializable response is automatically returned as JSON with status 200. Return `HttpResult<T>` when status or
headers are required, and `HttpResult<void>` for an empty response. The optional second handler argument may be
`HttpContext&` for headers, cookies, path parameters, and interceptor attributes.

Typed request bodies require `application/json` or an `application/*+json` media type. Invalid bodies receive a stable 400
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.

### 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.

Add a summary and a longer description when registering a route; both become standard OpenAPI operation fields and appear in
`/docs`:

```cpp
router.post("/messages", handle_message,
{"Send a message", "Accepts a message and returns its delivery result."});
```

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.

```cpp
#include "framework/router/openapi.hpp"

// Register application routes first, then add hidden runtime documentation routes.
khttpd::framework::install_openapi_routes(
server->get_http_router(), {"Example API", "1.0.0"});
// GET /openapi.json and GET /docs
```

Both `/docs` and `/openapi.json` are implemented by the framework; the example only demonstrates calling
`install_openapi_routes`. `/docs` is a responsive, dependency-free HTML view with endpoint navigation, parsed schema
fields, generated request examples, cURL copy, and an interactive request runner. Interactive requests omit browser
credentials by default. The exported
`openapi.json` is an OpenAPI document, not a single JSON Schema: do not pass the whole file to an OpenAI
`response_format.json_schema` field. Select the relevant request/response `schema` below `paths`, or convert that operation
schema for the target consumer. Unreflected C++ object types are emitted conservatively as
`{"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>`
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:

```bash
bazel run //example:app
bazel run //example:app -- --export-openapi openapi.json
bazel run //example:app -- --disable-openapi-docs
```

`install_openapi_routes` rejects dynamic, control-character, duplicate, or conflicting documentation paths. Documentation
routes are omitted from their own document and use the ordinary router/interceptor pipeline. `export_openapi` writes only to
the caller-supplied path; applications should apply their normal filesystem authorization policy before accepting such a path
from an untrusted user. Runtime routes can be switched explicitly with the final `enabled` argument; the example accepts
`--enable-openapi-docs` and `--disable-openapi-docs` (the last flag wins).

### Streaming HTTP routes and proxying

Large request and response bodies can bypass `string_body` buffering by using a
Expand Down Expand Up @@ -361,16 +451,18 @@ auto repo = di.resolve<UserRepository>();
### Exception Handling

```cpp
#include "framework/exception/exception_handler.hpp"
#include "framework/exception/http_exception.hpp"

auto dispatcher = std::make_shared<khttpd::framework::ExceptionDispatcher>();
dispatcher->on<std::runtime_error>([](const std::runtime_error& e, HttpContext& ctx) {
ctx.set_status(boost::beast::http::status::internal_server_error);
ctx.set_body(fmt::format("Error: {}", e.what()));
router.map_exception<ValidationError>([](const ValidationError& e) {
return khttpd::framework::HttpResult<ErrorResponse>(
boost::beast::http::status::unprocessable_entity,
{"VALIDATION_FAILED", e.what()});
});
server->get_http_router().add_exception_handler(dispatcher);
```

`HttpException` is available when an exception should carry an HTTP status, JSON body, and validated headers directly.
Unmapped exceptions return a generic JSON 500 response; exception details are logged server-side but are not sent to clients.

## License

MIT License — see [LICENSE](LICENSE) for details.
61 changes: 58 additions & 3 deletions doc/advanced.md
Original file line number Diff line number Diff line change
Expand Up @@ -78,14 +78,37 @@ auto uid = ctx.get_attribute_as<std::string>("user_id");

## 异常处理

### ExceptionDispatcher(推荐)
### 强类型异常映射(推荐)

```cpp
router.map_exception<ValidationError>([](const ValidationError& error) {
boost::json::object body;
body.emplace("code", "VALIDATION_FAILED");
body.emplace("message", error.what());
return HttpResult<boost::json::object>(http::status::unprocessable_entity, std::move(body));
});
```

异常也可以直接携带公开 JSON 和只供日志使用的内部信息:

```cpp
throw HttpException(
http::status::conflict,
boost::json::object{{"code", "VERSION_CONFLICT"}},
"optimistic lock failed for internal row 99");
```

未注册异常默认返回固定 JSON 500,不会把 `what()`、数据库信息或内部地址暴露给客户端。仅在映射明确认为
异常文本可以公开时,才应把 `error.what()` 放进响应 DTO。

### ExceptionDispatcher(兼容接口)

```cpp
auto dispatcher = std::make_shared<khttpd::framework::ExceptionDispatcher>();

dispatcher->on<std::runtime_error>([](const std::runtime_error& e, HttpContext& ctx) {
ctx.set_status(boost::beast::http::status::internal_server_error);
ctx.set_body(fmt::format("Server Error: {}", e.what()));
ctx.set_body("Internal server error"); // 不要向客户端返回 e.what()
});

dispatcher->on<int>([](const int code, HttpContext& ctx) {
Expand Down Expand Up @@ -114,7 +137,7 @@ public:
class MyExceptionHandler : public khttpd::framework::ExceptionHandler<MyException> {
void handle(const MyException& e, HttpContext& ctx) override {
ctx.set_status(boost::beast::http::status::unprocessable_entity);
ctx.set_body(e.what());
ctx.set_body(e.what()); // 仅当 what() 明确只包含可公开的校验信息
}
};

Expand Down Expand Up @@ -288,6 +311,38 @@ router.stream("/gateway/:target", http::verb::post,

---

## OpenAPI 服务与离线导出

建议把业务路由注册提取为一个同时用于服务模式和导出模式的函数:

```cpp
void register_routes(HttpRouter& http, WebsocketRouter& websocket);

if (export_path) {
HttpRouter http;
WebsocketRouter websocket;
register_routes(http, websocket);
export_openapi(http, *export_path, {"Service API", "1.0.0"});
return 0; // 没有构造 Server,因此不会 bind/listen
}

auto server = std::make_shared<Server>(endpoint, web_root, threads);
register_routes(server->get_http_router(), server->get_websocket_router());
install_openapi_routes(server->get_http_router(), {"Service API", "1.0.0"},
"/openapi.json", "/docs", enable_runtime_docs);
server->run();
```

运行时文档是普通 GET 路由,会经过与业务接口相同的 interceptor。若文档不应公开,应在现有鉴权 interceptor 中按路径或权限策略控制;不要另建绕过 session 的响应通道。安装函数拒绝控制字符、动态文档路径、两个入口重名以及已有 GET 路由冲突,避免 header/HTML 注入和静默路由覆盖。

`/docs` 由 framework 服务端渲染为带 endpoint 导航、接口 summary/description、字段化 schema、cURL 复制和在线请求工具的响应式页面,不依赖第三方 JSON Schema 预览器。交互请求默认省略浏览器凭据。example 分别演示了 lambda 注册时传入说明、Controller 文档宏和对已注册路由调用 `document_route` 三种写法。这样不会把 OpenAPI 根文档误当成 OpenAI `response_format` 所要求的单一 object schema。

最后一个 `enabled` 参数可手动开关运行时入口:为 `false` 时不注册 `/openapi.json` 与 `/docs`;离线导出仍可单独执行。example 同时提供 `--enable-openapi-docs` 和 `--disable-openapi-docs`。

离线导出具有调用进程对目标路径的全部文件权限,并会截断已存在文件。CLI 或管理接口必须先完成目录白名单、租户边界和操作权限校验;框架只保证确定性 JSON 以及打开/写入失败可见,不负责替业务决定允许写入哪些目录。

---

## Cron 定时任务

### Lambda 任务
Expand Down
Loading
Loading