From 13471a38d1f559fef5c79b410553eb00b67229a1 Mon Sep 17 00:00:00 2001 From: caesar Date: Wed, 12 Aug 2026 15:33:38 +0800 Subject: [PATCH 1/9] ci: add boost.asio and boost.mysql to presubmit --- .bcr/presubmit.yml | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/.bcr/presubmit.yml b/.bcr/presubmit.yml index e69f56d..3162e07 100644 --- a/.bcr/presubmit.yml +++ b/.bcr/presubmit.yml @@ -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 From 15d92eed7b4d1423fd98c1c738f702d2a3b28521 Mon Sep 17 00:00:00 2001 From: caesar Date: Wed, 12 Aug 2026 16:20:45 +0800 Subject: [PATCH 2/9] fix: windows ci/cd --- .bcr/presubmit.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.bcr/presubmit.yml b/.bcr/presubmit.yml index 3162e07..6006f9d 100644 --- a/.bcr/presubmit.yml +++ b/.bcr/presubmit.yml @@ -30,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: From 09539c7fc62c204d576d7186c8dbf9f9da7a83ae Mon Sep 17 00:00:00 2001 From: kekxv Date: Mon, 17 Aug 2026 11:54:49 +0000 Subject: [PATCH 3/9] fix: skip static root checks when disabled --- framework/server.cpp | 33 ++++++++++++++++++--------------- 1 file changed, 18 insertions(+), 15 deletions(-) diff --git a/framework/server.cpp b/framework/server.cpp index 62db1b3..1a14616 100644 --- a/framework/server.cpp +++ b/framework/server.cpp @@ -45,23 +45,26 @@ namespace khttpd::framework throw std::runtime_error(fmt::format("Failed to listen: {}", ec.message())); } - // Pre-compute canonical web root path once (not per-connection) - boost::system::error_code path_ec; - canonical_web_root_ = boost::filesystem::canonical(web_root_, path_ec); - if (path_ec) + if (!web_root_.empty()) { - spdlog::warn("Cannot canonicalize web_root '{}': {}", web_root_, path_ec.message()); - } + // Pre-compute canonical web root path once (not per-connection). + boost::system::error_code path_ec; + canonical_web_root_ = boost::filesystem::canonical(web_root_, path_ec); + if (path_ec) + { + spdlog::warn("Cannot canonicalize web_root '{}': {}", web_root_, path_ec.message()); + } - if (!boost::filesystem::exists(web_root_, ec)) - { - spdlog::warn("Web root directory '{}' does not exist. Static file serving may fail. Error: {}", - web_root_, ec.message()); - } - else if (!boost::filesystem::is_directory(web_root_, ec)) - { - spdlog::warn("Web root path '{}' is not a directory. Static file serving may fail. Error: {}", - web_root_, ec.message()); + if (!boost::filesystem::exists(web_root_, ec)) + { + spdlog::warn("Web root directory '{}' does not exist. Static file serving may fail. Error: {}", + web_root_, ec.message()); + } + else if (!boost::filesystem::is_directory(web_root_, ec)) + { + spdlog::warn("Web root path '{}' is not a directory. Static file serving may fail. Error: {}", + web_root_, ec.message()); + } } } From b4452bfcf8bc33844b54d04dcc2b0c07db420ba3 Mon Sep 17 00:00:00 2001 From: kekxv Date: Mon, 17 Aug 2026 12:25:30 +0000 Subject: [PATCH 4/9] 0.3.1 --- MODULE.bazel | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/MODULE.bazel b/MODULE.bazel index cad0c01..de61ac6 100644 --- a/MODULE.bazel +++ b/MODULE.bazel @@ -1,6 +1,6 @@ module( name = "khttpd", - version = "0.3.0", + version = "0.3.1", ) bazel_dep(name = "platforms", version = "1.1.0") From 9917ea8a77455de96d1dc5247bee4440826c7490 Mon Sep 17 00:00:00 2001 From: kekxv Date: Mon, 17 Aug 2026 13:59:36 +0000 Subject: [PATCH 5/9] 0.3.1 --- example/BUILD.bazel | 2 +- framework/BUILD.bazel | 2 +- framework/tests/BUILD.bazel | 84 ++++++++++++++++++++++++++++++------- 3 files changed, 71 insertions(+), 17 deletions(-) diff --git a/example/BUILD.bazel b/example/BUILD.bazel index d6a9621..4755a28 100644 --- a/example/BUILD.bazel +++ b/example/BUILD.bazel @@ -10,6 +10,6 @@ cc_binary( ], deps = [ "@khttpd", - "@spdlog//:spdlog", + "@spdlog", ], ) diff --git a/framework/BUILD.bazel b/framework/BUILD.bazel index baf17b4..a9b60f1 100644 --- a/framework/BUILD.bazel +++ b/framework/BUILD.bazel @@ -41,6 +41,6 @@ cc_library( "@boost.url", "@boost.uuid", "@fmt", # 用于日志输出 - "@spdlog//:spdlog", + "@spdlog", ], ) diff --git a/framework/tests/BUILD.bazel b/framework/tests/BUILD.bazel index b0a4a1f..ac2d444 100644 --- a/framework/tests/BUILD.bazel +++ b/framework/tests/BUILD.bazel @@ -25,9 +25,9 @@ cc_test( ], deps = [ "//framework", - "@spdlog//:spdlog", "@googletest//:gtest", "@googletest//:gtest_main", + "@spdlog", ], ) @@ -138,44 +138,98 @@ cc_test( cc_test( name = "buffered_body_limit_test", - srcs = ["buffered_body_limit_test.cpp", "http_session_test_harness.hpp"], - copts = ["-std=c++17", "-Wall", "-pedantic"], - deps = ["//framework", "@googletest//:gtest", "@googletest//:gtest_main"], + srcs = [ + "buffered_body_limit_test.cpp", + "http_session_test_harness.hpp", + ], + copts = [ + "-std=c++17", + "-Wall", + "-pedantic", + ], + deps = [ + "//framework", + "@googletest//:gtest", + "@googletest//:gtest_main", + ], ) cc_test( name = "http_stream_edge_test", - srcs = ["http_stream_edge_test.cpp", "http_session_test_harness.hpp"], - copts = ["-std=c++17", "-Wall", "-pedantic"], - deps = ["//framework", "@googletest//:gtest", "@googletest//:gtest_main"], + srcs = [ + "http_session_test_harness.hpp", + "http_stream_edge_test.cpp", + ], + copts = [ + "-std=c++17", + "-Wall", + "-pedantic", + ], + deps = [ + "//framework", + "@googletest//:gtest", + "@googletest//:gtest_main", + ], ) cc_test( name = "http_client_stream_edge_test", srcs = ["http_client_stream_edge_test.cpp"], - copts = ["-std=c++17", "-Wall", "-pedantic"], - deps = ["//framework", "@googletest//:gtest", "@googletest//:gtest_main"], + copts = [ + "-std=c++17", + "-Wall", + "-pedantic", + ], + deps = [ + "//framework", + "@googletest//:gtest", + "@googletest//:gtest_main", + ], ) cc_test( name = "http_proxy_session_edge_test", srcs = ["http_proxy_session_edge_test.cpp"], - copts = ["-std=c++17", "-Wall", "-pedantic"], - deps = ["//framework", "@googletest//:gtest", "@googletest//:gtest_main"], + copts = [ + "-std=c++17", + "-Wall", + "-pedantic", + ], + deps = [ + "//framework", + "@googletest//:gtest", + "@googletest//:gtest_main", + ], ) cc_test( name = "websocket_router_dynamic_test", srcs = ["websocket_router_dynamic_test.cpp"], - copts = ["-std=c++17", "-Wall", "-pedantic"], - deps = ["//framework", "@googletest//:gtest", "@googletest//:gtest_main"], + copts = [ + "-std=c++17", + "-Wall", + "-pedantic", + ], + deps = [ + "//framework", + "@googletest//:gtest", + "@googletest//:gtest_main", + ], ) cc_test( name = "websocket_client_handshake_test", srcs = ["websocket_client_handshake_test.cpp"], - copts = ["-std=c++17", "-Wall", "-pedantic"], - deps = ["//framework", "@googletest//:gtest", "@googletest//:gtest_main"], + copts = [ + "-std=c++17", + "-Wall", + "-pedantic", + ], + deps = [ + "//framework", + "@googletest//:gtest", + "@googletest//:gtest_main", + ], ) cc_test( From 97e52d61c3bbd4b6ef1cf784457d24732ee922a6 Mon Sep 17 00:00:00 2001 From: kekxv Date: Tue, 18 Aug 2026 03:17:57 +0000 Subject: [PATCH 6/9] feat: add typed routes and OpenAPI documentation --- README.md | 81 +++- doc/advanced.md | 59 ++- doc/api-reference.md | 134 ++++++ example/BUILD.bazel | 15 + example/TypedHelloController.hpp | 70 +++ example/export_openapi_test.sh | 53 +++ example/main.cpp | 122 ++++- example/runtime_docs_toggle_test.sh | 32 ++ framework/controller/http_controller.hpp | 4 + framework/exception/exception_handler.hpp | 3 + framework/exception/http_exception.hpp | 88 ++++ framework/router/http_result.hpp | 175 +++++++ framework/router/http_router.cpp | 109 ++++- framework/router/http_router.hpp | 112 ++++- framework/router/openapi.cpp | 218 +++++++++ framework/router/openapi.hpp | 29 ++ framework/router/openapi_schema.hpp | 93 ++++ framework/router/typed_route.hpp | 232 +++++++++ framework/session/http_session.cpp | 25 +- framework/tests/BUILD.bazel | 30 ++ framework/tests/openapi_test.cpp | 273 +++++++++++ framework/tests/session_test.cpp | 115 +++++ framework/tests/typed_route_test.cpp | 555 ++++++++++++++++++++++ 23 files changed, 2583 insertions(+), 44 deletions(-) create mode 100644 example/TypedHelloController.hpp create mode 100755 example/export_openapi_test.sh create mode 100755 example/runtime_docs_toggle_test.sh create mode 100644 framework/exception/http_exception.hpp create mode 100644 framework/router/http_result.hpp create mode 100644 framework/router/openapi.cpp create mode 100644 framework/router/openapi.hpp create mode 100644 framework/router/openapi_schema.hpp create mode 100644 framework/router/typed_route.hpp create mode 100644 framework/tests/openapi_test.cpp create mode 100644 framework/tests/typed_route_test.cpp diff --git a/README.md b/README.md index 5b741fb..d4fefdd 100644 --- a/README.md +++ b/README.md @@ -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` 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 @@ -230,6 +232,71 @@ class MyController : public khttpd::framework::BaseController { 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 { +public: + std::shared_ptr register_routes(HttpRouter& router) override { + KHTTPD_TYPED_ROUTE(post, "/users", create_user); + return shared_from_this(); + } + +private: + khttpd::framework::HttpResult create_user(const CreateUserRequest& request) { + auto result = khttpd::framework::HttpResult::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` when status or +headers are required, and `HttpResult` 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. + +```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 +``` + +The example includes `POST /typed/greetings`, `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: + +```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 @@ -361,16 +428,18 @@ auto repo = di.resolve(); ### Exception Handling ```cpp -#include "framework/exception/exception_handler.hpp" +#include "framework/exception/http_exception.hpp" -auto dispatcher = std::make_shared(); -dispatcher->on([](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([](const ValidationError& e) { + return khttpd::framework::HttpResult( + 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. diff --git a/doc/advanced.md b/doc/advanced.md index ec6fd5f..0ff7c2d 100644 --- a/doc/advanced.md +++ b/doc/advanced.md @@ -78,14 +78,37 @@ auto uid = ctx.get_attribute_as("user_id"); ## 异常处理 -### ExceptionDispatcher(推荐) +### 强类型异常映射(推荐) + +```cpp +router.map_exception([](const ValidationError& error) { + boost::json::object body; + body.emplace("code", "VALIDATION_FAILED"); + body.emplace("message", error.what()); + return HttpResult(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(); dispatcher->on([](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([](const int code, HttpContext& ctx) { @@ -114,7 +137,7 @@ public: class MyExceptionHandler : public khttpd::framework::ExceptionHandler { 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() 明确只包含可公开的校验信息 } }; @@ -288,6 +311,36 @@ 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(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 注入和静默路由覆盖。 + +最后一个 `enabled` 参数可手动开关运行时入口:为 `false` 时不注册 `/openapi.json` 与 `/docs`;离线导出仍可单独执行。example 同时提供 `--enable-openapi-docs` 和 `--disable-openapi-docs`。 + +离线导出具有调用进程对目标路径的全部文件权限,并会截断已存在文件。CLI 或管理接口必须先完成目录白名单、租户边界和操作权限校验;框架只保证确定性 JSON 以及打开/写入失败可见,不负责替业务决定允许写入哪些目录。 + +--- + ## Cron 定时任务 ### Lambda 任务 diff --git a/doc/api-reference.md b/doc/api-reference.md index 67db174..820a712 100644 --- a/doc/api-reference.md +++ b/doc/api-reference.md @@ -162,6 +162,50 @@ void(HttpContext&, 普通 handler 仍使用 `string_body`,超过 Server 配置上限会返回 413。流式路由使用固定缓冲区读取,不受该缓冲上限约束。 +### 强类型 JSON 路由 + +`get/post/put/del/options` 还接受返回值非 `void` 的强类型 callable: + +```cpp +router.post("/users", [](const CreateUserRequest& request) -> HttpResult { + return HttpResult::created({1001, request.name}) + .header("Location", "/users/1001"); +}); +``` + +支持的 Controller 成员签名(也支持 `const` 成员函数): + +```cpp +Response method(const Request&); +Response method(const Request&, HttpContext&); +``` + +`Response` 可以是 JSON 可序列化裸类型、`HttpResult` 或 `HttpResult`。裸类型自动返回 HTTP 200。 +第二个 `HttpContext&` 参数用于读取 path/query/header/cookie 和拦截器属性。请求体必须是 `application/json` +或 `application/*+json`;媒体类型或 JSON/DTO 转换失败时返回 HTTP 400,并且不会调用业务 handler。 + +Controller 可使用: + +```cpp +KHTTPD_TYPED_ROUTE(post, "/users", create_user); +``` + +原有 `KHTTPD_ROUTE`、`void(HttpContext&)` 和所有路由分发行为保持不变。强类型路由仍执行相同的前置/后置拦截器, +不能替代鉴权拦截器。 + +### HttpResult + +| API | 说明 | +|------|------| +| `HttpResult(status, body)` | 指定状态和 JSON 响应体 | +| `HttpResult::ok(body)` | 创建 HTTP 200 响应 | +| `HttpResult::created(body)` | 创建 HTTP 201 响应 | +| `HttpResult::no_content()` | 创建无响应体的 HTTP 204 响应 | +| `result.header(name, value)` | 增加经过校验的响应头 | + +响应头拒绝 CR/LF/NUL、其他控制字符、非法字段名,以及 `Content-Length`、`Transfer-Encoding`、`Connection` +等由服务器管理的 framing/hop-by-hop 字段,避免响应拆分和消息边界冲突。 + ### 路由语法 | 语法 | 示例 | 匹配 | @@ -184,6 +228,7 @@ void(HttpContext&, |------|------| | `add_interceptor(interceptor)` | 添加拦截器 | | `add_exception_handler(handler)` | 添加异常处理器 | +| `map_exception(mapper)` | 将异常 `E` 映射为裸 JSON 响应或 `HttpResult` | | `set_unknown_exception_handler(handler)` | 设置未知异常兜底处理器 | | `run_pre_interceptors(ctx)` | 执行前置拦截器 | | `run_post_interceptors(ctx)` | 执行后置拦截器(逆序) | @@ -324,6 +369,95 @@ class ExceptionHandler : public ExceptionHandlerBase 针对单一异常类型的处理器(需继承实现)。 +### 强类型异常映射 + +```cpp +router.map_exception([](const ValidationError& error) { + return HttpResult( + http::status::unprocessable_entity, + {"VALIDATION_FAILED", error.what()}); +}); +``` + +也可以抛出 `HttpException(status, json_body, internal_message)`,并通过 `.header(name, value)` 添加经过相同安全校验的响应头。 +`internal_message` 只写服务端日志,不进入 JSON 响应。未匹配的 `std::exception` 默认返回: + +```json +{"code":"INTERNAL_SERVER_ERROR","message":"Internal server error"} +``` + +异常响应会先清除 handler 已经写入的部分响应,避免错误路径泄露残留的 header 或 body。旧的 +`ExceptionDispatcher`、`ExceptionHandler` 和自定义 unknown handler 保持兼容。 + +--- + +## OpenAPI 3.1 文档 + +头文件: + +```cpp +#include "framework/router/openapi.hpp" +``` + +### 数据类型 + +```cpp +struct OpenApiInfo { + std::string title = "khttpd API"; + std::string version = "1.0.0"; +}; + +struct RouteDescriptor { + std::string path; + boost::beast::http::verb method; + std::optional request_schema; + std::optional response_schema; +}; +``` + +`HttpRouter::route_descriptors()` 返回不含 handler、正则表达式、拦截器和异常映射器的副本,调用方无法借此修改路由器内部状态。 + +### 生成和导出 + +```cpp +boost::json::object generate_openapi( + const HttpRouter& router, + const OpenApiInfo& info = {}); + +void export_openapi( + const HttpRouter& router, + const std::string& output_path, + const OpenApiInfo& info = {}); +``` + +输出固定为 OpenAPI 3.1.0。路径中的 `:id` 转换为 `{id}`;由于当前路由匹配规则允许最后一个动态参数跨 `/`,该参数带有 `x-khttpd-greedy: true`。旧式、异步和流式路由生成 method/path/parameter/response 骨架;强类型路由还生成 JSON request body 和 response schema。 + +Boost.Describe DTO 可自动展开字段: + +```cpp +struct CreateRequest { std::string name; int age; }; +BOOST_DESCRIBE_STRUCT(CreateRequest, (), (name, age)) +``` + +仅通过自定义 `tag_invoke` 序列化且没有 Boost.Describe 元数据的类型会退化为 `{ "type": "object" }`,不会猜测字段。`std::optional` 字段不进入 `required`;字符串、布尔、整数、浮点和 `std::vector` 会生成对应 schema。 + +文件输出采用确定性路径/方法顺序并以换行结尾。空路径、无法打开或无法完整写入会抛出异常。API 不替调用方限制目标目录,因此不要把未经授权的网络输入直接作为 `output_path`。 + +### 运行时文档路由 + +```cpp +void install_openapi_routes( + HttpRouter& router, + const OpenApiInfo& info = {}, + const std::string& spec_path = "/openapi.json", + const std::string& docs_path = "/docs", + bool enabled = true); +``` + +先注册业务路由,再调用该函数。它会安装只读 JSON 与 HTML 入口,并从生成文档中隐藏自身。两个路径必须是互不相同的绝对字面路径,且不能与已有 GET 路由冲突;冲突会抛出 `std::invalid_argument`,不会覆盖业务 handler。路由仍走标准 session、前置/后置 interceptor 和授权流程,不存在单独的越权 dispatch 通道。 + +传入 `enabled = false` 时函数不注册 `/openapi.json` 或 `/docs`,可用于按环境、租户或权限策略手动关闭运行时文档;离线 `export_openapi` 不受此开关影响。 + --- ## DI Container diff --git a/example/BUILD.bazel b/example/BUILD.bazel index 4755a28..53380be 100644 --- a/example/BUILD.bazel +++ b/example/BUILD.bazel @@ -1,4 +1,5 @@ load("@rules_cc//cc:defs.bzl", "cc_binary") +load("@rules_shell//shell:sh_test.bzl", "sh_test") cc_binary( name = "app", @@ -6,6 +7,7 @@ cc_binary( "HelloController.hpp", "HelloStreamController.hpp", "HelloWsController.hpp", + "TypedHelloController.hpp", "main.cpp", ], deps = [ @@ -13,3 +15,16 @@ cc_binary( "@spdlog", ], ) + +sh_test( + name = "export_openapi_test", + srcs = ["export_openapi_test.sh"], + data = [":app"], +) + +sh_test( + name = "runtime_docs_toggle_test", + srcs = ["runtime_docs_toggle_test.sh"], + data = [":app"], + tags = ["exclusive"], +) diff --git a/example/TypedHelloController.hpp b/example/TypedHelloController.hpp new file mode 100644 index 0000000..3cd238a --- /dev/null +++ b/example/TypedHelloController.hpp @@ -0,0 +1,70 @@ +#ifndef KHTTPD_EXAMPLE_TYPED_HELLO_CONTROLLER_HPP_ +#define KHTTPD_EXAMPLE_TYPED_HELLO_CONTROLLER_HPP_ + +#include "framework/controller/http_controller.hpp" +#include "framework/router/http_result.hpp" + +#include + +#include +#include + +struct CreateGreetingRequest +{ + std::string name; +}; + +struct GreetingResponse +{ + std::string message; +}; + +struct GreetingErrorResponse +{ + std::string code; + std::string message; +}; + +BOOST_DESCRIBE_STRUCT(CreateGreetingRequest, (), (name)) +BOOST_DESCRIBE_STRUCT(GreetingResponse, (), (message)) +BOOST_DESCRIBE_STRUCT(GreetingErrorResponse, (), (code, message)) + +class GreetingValidationError : public std::runtime_error +{ +public: + using std::runtime_error::runtime_error; +}; + +class TypedHelloController final : public khttpd::framework::BaseController +{ +public: + static std::shared_ptr create() + { + return std::make_shared(); + } + + std::shared_ptr register_routes(khttpd::framework::HttpRouter& router) override + { + KHTTPD_TYPED_ROUTE(post, "/greetings", create_greeting); + return shared_from_this(); + } + +protected: + std::string base_path() override + { + return "/typed"; + } + +private: + khttpd::framework::HttpResult create_greeting(const CreateGreetingRequest& request) const + { + if (request.name.empty()) throw GreetingValidationError("name must not be empty"); + + auto result = khttpd::framework::HttpResult::created( + {"Hello, " + request.name + "!"}); + return result.header("Location", "/typed/greetings/latest") + .header("X-Example-Handler", "typed"); + } +}; + +#endif // KHTTPD_EXAMPLE_TYPED_HELLO_CONTROLLER_HPP_ diff --git a/example/export_openapi_test.sh b/example/export_openapi_test.sh new file mode 100755 index 0000000..8f2ad90 --- /dev/null +++ b/example/export_openapi_test.sh @@ -0,0 +1,53 @@ +#!/usr/bin/env bash +set -euo pipefail + +app="${TEST_SRCDIR}/${TEST_WORKSPACE}/example/app" +ready="${TEST_TMPDIR}/port-ready" +output="${TEST_TMPDIR}/example-openapi.json" + +python3 - "${ready}" <<'PY' & +import errno +import pathlib +import signal +import socket +import sys + +sock = socket.socket() +sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) +try: + sock.bind(("127.0.0.1", 8080)) + sock.listen(1) +except OSError as error: + if error.errno != errno.EADDRINUSE: + raise + pathlib.Path(sys.argv[1]).write_text("already occupied") + sys.exit(0) + +pathlib.Path(sys.argv[1]).write_text("occupied by test") +signal.pause() +PY +holder_pid=$! +trap 'kill "${holder_pid}" 2>/dev/null || true' EXIT + +for _ in $(seq 1 100); do + [[ -f "${ready}" ]] && break + sleep 0.02 +done +[[ -f "${ready}" ]] + +timeout 10 "${app}" --export-openapi="${output}" + +python3 - "${output}" <<'PY' +import json +import pathlib +import sys + +document = json.loads(pathlib.Path(sys.argv[1]).read_text()) +operation = document["paths"]["/typed/greetings"]["post"] +request = operation["requestBody"]["content"]["application/json"]["schema"] +response = operation["responses"]["200"]["content"]["application/json"]["schema"] +assert request["properties"]["name"]["type"] == "string" +assert response["properties"]["message"]["type"] == "string" +assert "/openapi.json" not in document["paths"] +assert "/docs" not in document["paths"] +PY diff --git a/example/main.cpp b/example/main.cpp index ae1fe56..fd06062 100644 --- a/example/main.cpp +++ b/example/main.cpp @@ -4,46 +4,39 @@ #include "framework/server.hpp" #include "framework/context/http_context.hpp" #include "framework/context/websocket_context.hpp" +#include "framework/router/openapi.hpp" #include #include #include +#include +#include #include #include #include "HelloController.hpp" #include "HelloStreamController.hpp" #include "HelloWsController.hpp" +#include "TypedHelloController.hpp" namespace net = boost::asio; using tcp = boost::asio::ip::tcp; namespace beast = boost::beast; -int main(int argc, char* argv[]) +namespace { - auto const address = net::ip::make_address("0.0.0.0"); - auto const port = static_cast(8080); - auto const num_threads = std::max(1, static_cast(std::thread::hardware_concurrency())); - - spdlog::info("Starting khttpd server with {} worker threads...", num_threads); - - // 定义 Web 根目录 - std::string web_root_path = "web_root"; - // 创建一个简单的 web_root 目录和文件用于测试 - boost::filesystem::create_directories(web_root_path); - std::ofstream("web_root/index.html") << - "

Hello from Static Index!

Visit hello_static.html

"; - std::ofstream("web_root/hello_static.html") << - R"(

This is a static HTML file.

Static Image)"; - - auto server = std::make_shared( - tcp::endpoint{address, port}, web_root_path, num_threads); - - auto& http_router = server->get_http_router(); - auto& ws_router = server->get_websocket_router(); - + void register_application_routes(khttpd::framework::HttpRouter& http_router, + khttpd::framework::WebsocketRouter& ws_router) + { + http_router.map_exception([](const GreetingValidationError&) + { + return khttpd::framework::HttpResult( + beast::http::status::bad_request, + {"INVALID_GREETING", "The greeting name must not be empty"}); + }); 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); + TypedHelloController::create()->register_routes(http_router)->register_routes(ws_router); http_router.get("/", [](khttpd::framework::HttpContext& ctx) { @@ -272,9 +265,88 @@ int main(int argc, char* argv[]) } ); - server->run(); + } + + std::optional export_path_from_arguments(const int argc, char* argv[]) + { + constexpr std::string_view prefix = "--export-openapi="; + for (int index = 1; index < argc; ++index) + { + const std::string_view argument(argv[index]); + if (argument == "--export-openapi") + { + if (index + 1 >= argc) throw std::invalid_argument("--export-openapi requires an output path"); + return std::string(argv[index + 1]); + } + if (argument.compare(0, prefix.size(), prefix) == 0) + { + const auto path = argument.substr(prefix.size()); + if (path.empty()) throw std::invalid_argument("--export-openapi requires an output path"); + return std::string(path); + } + } + return std::nullopt; + } - spdlog::info("Application exited."); + bool openapi_docs_enabled_from_arguments(const int argc, char* argv[]) + { + bool enabled = true; + for (int index = 1; index < argc; ++index) + { + const std::string_view argument(argv[index]); + if (argument == "--enable-openapi-docs") enabled = true; + if (argument == "--disable-openapi-docs") enabled = false; + } + return enabled; + } +} + +int main(int argc, char* argv[]) +{ + try + { + const auto export_path = export_path_from_arguments(argc, argv); + if (export_path) + { + khttpd::framework::HttpRouter http_router; + khttpd::framework::WebsocketRouter ws_router; + register_application_routes(http_router, ws_router); + khttpd::framework::export_openapi(http_router, *export_path, + {"khttpd example API", "1.0.0"}); + spdlog::info("OpenAPI document exported to {}", *export_path); + return 0; + } + + auto const address = net::ip::make_address("0.0.0.0"); + auto const port = static_cast(8080); + auto const num_threads = std::max(1, static_cast(std::thread::hardware_concurrency())); + spdlog::info("Starting khttpd server with {} worker threads...", num_threads); + + std::string web_root_path = "web_root"; + boost::filesystem::create_directories(web_root_path); + std::ofstream("web_root/index.html") << + "

Hello from Static Index!

Visit hello_static.html

"; + std::ofstream("web_root/hello_static.html") << + R"(

This is a static HTML file.

Static Image)"; + + auto server = std::make_shared( + tcp::endpoint{address, port}, web_root_path, num_threads); + auto& http_router = server->get_http_router(); + auto& ws_router = server->get_websocket_router(); + register_application_routes(http_router, ws_router); + khttpd::framework::install_openapi_routes( + http_router, {"khttpd example API", "1.0.0"}, "/openapi.json", "/docs", + openapi_docs_enabled_from_arguments(argc, argv)); + + server->run(); + + spdlog::info("Application exited."); + return 0; + } + catch (const std::exception& error) + { + spdlog::error("Application failed: {}", error.what()); + return 1; + } - return 0; } diff --git a/example/runtime_docs_toggle_test.sh b/example/runtime_docs_toggle_test.sh new file mode 100755 index 0000000..d2743b3 --- /dev/null +++ b/example/runtime_docs_toggle_test.sh @@ -0,0 +1,32 @@ +#!/usr/bin/env bash +set -euo pipefail + +app="${TEST_SRCDIR}/${TEST_WORKSPACE}/example/app" +log="${TEST_TMPDIR}/app.log" + +"${app}" --disable-openapi-docs >"${log}" 2>&1 & +app_pid=$! +trap 'kill "${app_pid}" 2>/dev/null || true; wait "${app_pid}" 2>/dev/null || true' EXIT + +python3 - <<'PY' +import socket +import time + +for _ in range(100): + try: + with socket.create_connection(("127.0.0.1", 8080), timeout=0.1): + break + except OSError: + time.sleep(0.02) +else: + raise SystemExit("example server did not listen on port 8080") + +def request(path): + with socket.create_connection(("127.0.0.1", 8080), timeout=1) as sock: + sock.sendall(f"GET {path} HTTP/1.1\r\nHost: localhost\r\nConnection: close\r\n\r\n".encode()) + return sock.recv(4096).decode("iso-8859-1") + +assert request("/").startswith("HTTP/1.1 200"), "business route must remain available" +assert request("/docs").startswith("HTTP/1.1 404"), "docs endpoint must be disabled" +assert request("/openapi.json").startswith("HTTP/1.1 404"), "spec endpoint must be disabled" +PY diff --git a/framework/controller/http_controller.hpp b/framework/controller/http_controller.hpp index b725da5..7339d5b 100644 --- a/framework/controller/http_controller.hpp +++ b/framework/controller/http_controller.hpp @@ -12,6 +12,10 @@ namespace khttpd::framework #define KHTTPD_ROUTE(VERB, PATH, METHOD_NAME) \ router.VERB(base_path() + PATH, bind_handler(&std::decay_t::METHOD_NAME)) #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) +#endif #ifndef KHTTPD_WSROUTE #define KHTTPD_WSROUTE_NULL_HANDLER nullptr diff --git a/framework/exception/exception_handler.hpp b/framework/exception/exception_handler.hpp index a5ce021..b679be5 100644 --- a/framework/exception/exception_handler.hpp +++ b/framework/exception/exception_handler.hpp @@ -1,6 +1,9 @@ #ifndef KHTTPD_FRAMEWORK_EXCEPTION_EXCEPTION_HANDLER_HPP_ #define KHTTPD_FRAMEWORK_EXCEPTION_EXCEPTION_HANDLER_HPP_ +#include "context/http_context.hpp" + +#include #include #include #include diff --git a/framework/exception/http_exception.hpp b/framework/exception/http_exception.hpp new file mode 100644 index 0000000..d9be705 --- /dev/null +++ b/framework/exception/http_exception.hpp @@ -0,0 +1,88 @@ +#ifndef KHTTPD_FRAMEWORK_EXCEPTION_HTTP_EXCEPTION_HPP_ +#define KHTTPD_FRAMEWORK_EXCEPTION_HTTP_EXCEPTION_HPP_ + +#include "exception/exception_handler.hpp" +#include "router/http_result.hpp" + +#include + +#include +#include +#include +#include +#include +#include +#include + +namespace khttpd::framework +{ + class HttpException : public std::runtime_error + { + public: + template + HttpException(const boost::beast::http::status status, + Body&& body, + std::string internal_message = "HTTP request failed") + : std::runtime_error(std::move(internal_message)), + status_(status), + body_(boost::json::value_from(std::forward(body))) + { + } + + HttpException& header(std::string name, std::string value) + { + detail::validate_response_header(name, value); + headers_.push_back({std::move(name), std::move(value)}); + return *this; + } + + boost::beast::http::status status() const noexcept { return status_; } + const boost::json::value& body() const noexcept { return body_; } + const std::vector& headers() const noexcept { return headers_; } + + void apply(HttpContext& context) const + { + context.set_status(status_); + context.set_body_json(body_); + detail::apply_headers(context, headers_); + } + + private: + boost::beast::http::status status_; + boost::json::value body_; + std::vector headers_; + }; + + namespace detail + { + template + class TypedExceptionMapper final : public ExceptionHandlerBase + { + public: + explicit TypedExceptionMapper(Mapper mapper) : mapper_(std::move(mapper)) {} + + bool try_handle(std::exception_ptr exception, HttpContext& context) override + { + try + { + std::rethrow_exception(exception); + } + catch (const Exception& value) + { + auto response = std::invoke(mapper_, value); + apply_typed_response(context, std::move(response)); + return true; + } + catch (...) + { + return false; + } + } + + private: + Mapper mapper_; + }; + } +} + +#endif // KHTTPD_FRAMEWORK_EXCEPTION_HTTP_EXCEPTION_HPP_ diff --git a/framework/router/http_result.hpp b/framework/router/http_result.hpp new file mode 100644 index 0000000..066772b --- /dev/null +++ b/framework/router/http_result.hpp @@ -0,0 +1,175 @@ +#ifndef KHTTPD_FRAMEWORK_ROUTER_HTTP_RESULT_HPP_ +#define KHTTPD_FRAMEWORK_ROUTER_HTTP_RESULT_HPP_ + +#include "context/http_context.hpp" + +#include + +#include +#include +#include +#include +#include +#include +#include + +namespace khttpd::framework +{ + struct HttpHeader + { + std::string name; + std::string value; + }; + + namespace detail + { + inline std::string ascii_lower(std::string value) + { + std::transform(value.begin(), value.end(), value.begin(), [](const unsigned char c) + { + return static_cast(std::tolower(c)); + }); + return value; + } + + inline bool is_header_token_character(const unsigned char c) + { + return std::isalnum(c) != 0 || std::string("!#$%&'*+-.^_`|~").find(static_cast(c)) != std::string::npos; + } + + inline void validate_response_header(const std::string& name, const std::string& value) + { + if (name.empty() || !std::all_of(name.begin(), name.end(), [](const unsigned char c) + { + return is_header_token_character(c); + })) + { + throw std::invalid_argument("invalid HTTP response header name"); + } + + if (std::any_of(value.begin(), value.end(), [](const unsigned char c) + { + return (c < 0x20 && c != '\t') || c == 0x7f; + })) + { + throw std::invalid_argument("invalid HTTP response header value"); + } + + const auto normalized = ascii_lower(name); + if (normalized == "content-length" || normalized == "transfer-encoding" || + normalized == "connection" || normalized == "keep-alive" || + normalized == "proxy-connection" || normalized == "te" || + normalized == "trailer" || normalized == "upgrade") + { + throw std::invalid_argument("HTTP framing and hop-by-hop headers are managed by khttpd"); + } + } + + inline void apply_headers(HttpContext& context, const std::vector& headers) + { + for (const auto& header : headers) + { + context.set_header(header.name, header.value); + } + } + } + + template + class HttpResult + { + public: + HttpResult(const boost::beast::http::status status, T body) + : status_(status), body_(std::move(body)) + { + } + + static HttpResult ok(T body) + { + return HttpResult(boost::beast::http::status::ok, std::move(body)); + } + + static HttpResult created(T body) + { + return HttpResult(boost::beast::http::status::created, std::move(body)); + } + + HttpResult& header(std::string name, std::string value) + { + detail::validate_response_header(name, value); + headers_.push_back({std::move(name), std::move(value)}); + return *this; + } + + boost::beast::http::status status() const noexcept { return status_; } + const T& body() const noexcept { return body_; } + const std::vector& headers() const noexcept { return headers_; } + + private: + boost::beast::http::status status_; + T body_; + std::vector headers_; + }; + + template <> + class HttpResult + { + public: + explicit HttpResult(const boost::beast::http::status status) : status_(status) {} + + static HttpResult no_content() + { + return HttpResult(boost::beast::http::status::no_content); + } + + HttpResult& header(std::string name, std::string value) + { + detail::validate_response_header(name, value); + headers_.push_back({std::move(name), std::move(value)}); + return *this; + } + + boost::beast::http::status status() const noexcept { return status_; } + const std::vector& headers() const noexcept { return headers_; } + + private: + boost::beast::http::status status_; + std::vector headers_; + }; + + namespace detail + { + template + struct is_http_result : std::false_type {}; + + template + struct is_http_result> : std::true_type {}; + + template + inline constexpr bool is_http_result_v = is_http_result>::value; + + template + void apply_typed_response(HttpContext& context, const HttpResult& result) + { + context.set_status(result.status()); + context.set_body_from(result.body()); + apply_headers(context, result.headers()); + } + + inline void apply_typed_response(HttpContext& context, const HttpResult& result) + { + context.set_status(result.status()); + context.set_body(""); + context.get_response().erase(boost::beast::http::field::content_type); + apply_headers(context, result.headers()); + } + + template , int> = 0> + void apply_typed_response(HttpContext& context, T&& body) + { + context.set_status(boost::beast::http::status::ok); + context.set_body_from(std::forward(body)); + } + } +} + +#endif // KHTTPD_FRAMEWORK_ROUTER_HTTP_RESULT_HPP_ diff --git a/framework/router/http_router.cpp b/framework/router/http_router.cpp index 2cf5b91..2274836 100644 --- a/framework/router/http_router.cpp +++ b/framework/router/http_router.cpp @@ -3,9 +3,31 @@ #include #include #include +#include namespace khttpd::framework { + namespace + { + void write_internal_server_error(HttpContext& ctx) + { + ctx.set_status(boost::beast::http::status::internal_server_error); + boost::json::object error; + error.emplace("code", "INTERNAL_SERVER_ERROR"); + error.emplace("message", "Internal server error"); + ctx.set_body_json(error); + } + + void reset_exception_response(HttpContext& ctx) + { + auto& response = ctx.get_response(); + response = {}; + response.version(ctx.get_request().version()); + response.keep_alive(ctx.get_request().keep_alive()); + response.set(boost::beast::http::field::server, BOOST_BEAST_VERSION_STRING); + } + } + HttpRouter::HttpRouter() = default; std::tuple, int, int> HttpRouter::parse_path_pattern( @@ -98,8 +120,20 @@ namespace khttpd::framework } void HttpRouter::add_route(const std::string& path_pattern, const boost::beast::http::verb method, - HttpHandler handler) + HttpHandler handler, + std::optional request_schema, + std::optional response_schema, + const bool documented) { + if (documented) + record_route_descriptor(path_pattern, method, std::move(request_schema), std::move(response_schema)); + else + route_descriptors_.erase(std::remove_if(route_descriptors_.begin(), route_descriptors_.end(), + [&](const RouteDescriptor& descriptor) + { + return descriptor.path == path_pattern && descriptor.method == method; + }), route_descriptors_.end()); + for (auto& entry : routes_) { if (entry.original_path == path_pattern) @@ -126,6 +160,36 @@ namespace khttpd::framework std::string(boost::beast::http::to_string(method)), path_pattern, literal_count, dynamic_count); } + void HttpRouter::add_typed_route(const std::string& path_pattern, + const boost::beast::http::verb method, + detail::TypedRouteHandler handler) + { + add_route(path_pattern, method, std::move(handler.handler), + std::move(handler.request_schema), std::move(handler.response_schema)); + } + + void HttpRouter::record_route_descriptor(const std::string& path, + const boost::beast::http::verb method, + std::optional request_schema, + std::optional 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); + return; + } + } + route_descriptors_.push_back({path, method, std::move(request_schema), std::move(response_schema)}); + } + + std::vector HttpRouter::route_descriptors() const + { + return route_descriptors_; + } + void HttpRouter::get(const std::string& path, HttpHandler handler) { add_route(path, boost::beast::http::verb::get, std::move(handler)); @@ -154,6 +218,7 @@ namespace khttpd::framework void HttpRouter::stream(const std::string& path_pattern, const boost::beast::http::verb method, HttpStreamHandler handler) { + record_route_descriptor(path_pattern, method); for (auto& entry : routes_) { if (entry.original_path == path_pattern) @@ -247,6 +312,7 @@ namespace khttpd::framework void HttpRouter::async_route(const std::string& path, boost::beast::http::verb method, HttpAsyncHandler handler) { + record_route_descriptor(path, method); auto [path_regex, param_names, literal_count, dynamic_count] = parse_path_pattern(path); for (auto& entry : routes_) { @@ -394,6 +460,8 @@ namespace khttpd::framework void HttpRouter::handle_exception(std::exception_ptr eptr, HttpContext& ctx) const { + reset_exception_response(ctx); + if (!eptr) { // Should not happen, but safeguard against null pointer @@ -402,10 +470,39 @@ namespace khttpd::framework return; } + try + { + std::rethrow_exception(eptr); + } + catch (const HttpException& exception) + { + spdlog::warn("HTTP exception: {}", exception.what()); + exception.apply(ctx); + return; + } + catch (...) + { + } + for (const auto& handler : exception_handlers_) { - if (handler->try_handle(eptr, ctx)) + try + { + if (handler->try_handle(eptr, ctx)) + { + return; + } + } + catch (const std::exception& mapper_error) { + spdlog::error("Exception handler failed: {}", mapper_error.what()); + write_internal_server_error(ctx); + return; + } + catch (...) + { + spdlog::error("Exception handler failed with a non-standard exception."); + write_internal_server_error(ctx); return; } } @@ -418,9 +515,7 @@ namespace khttpd::framework catch (const std::exception& e) { spdlog::error("Unhandled exception: {}", e.what()); - ctx.set_status(boost::beast::http::status::internal_server_error); - ctx.set_content_type("text/html"); - ctx.set_body(fmt::format("

500 Internal Server Error

Exception: {}

", e.what())); + write_internal_server_error(ctx); return; } catch (...) @@ -440,8 +535,6 @@ namespace khttpd::framework } spdlog::error("Unknown exception occurred."); - ctx.set_status(boost::beast::http::status::internal_server_error); - ctx.set_content_type("text/html"); - ctx.set_body("

500 Internal Server Error

An unknown error occurred.

"); + write_internal_server_error(ctx); } } diff --git a/framework/router/http_router.hpp b/framework/router/http_router.hpp index 183b9ad..29eb91b 100644 --- a/framework/router/http_router.hpp +++ b/framework/router/http_router.hpp @@ -7,15 +7,23 @@ #include "context/http_response_stream.hpp" #include "interceptor/interceptor.hpp" #include "exception/exception_handler.hpp" +#include "exception/http_exception.hpp" +#include "router/typed_route.hpp" #include #include #include #include #include #include +#include namespace khttpd::framework { + struct OpenApiInfo; + class HttpRouter; + void install_openapi_routes(HttpRouter& router, const OpenApiInfo& info, + const std::string& spec_path, const std::string& docs_path, bool enabled); + using HttpHandler = std::function; using HttpAsyncComplete = std::function; using HttpAsyncHandler = std::function; @@ -24,6 +32,14 @@ namespace khttpd::framework std::shared_ptr, HttpStreamComplete)>; using UnknownExceptionHandler = std::function; + struct RouteDescriptor + { + std::string path; + boost::beast::http::verb method; + std::optional request_schema; + std::optional response_schema; + }; + // 路由条目结构 struct RouteEntry { @@ -60,6 +76,76 @@ namespace khttpd::framework void put(const std::string& path, HttpHandler handler); void del(const std::string& path, HttpHandler handler); void options(const std::string& path, HttpHandler handler); + + template , int> = 0> + void get(const std::string& path, Handler&& handler) + { + add_typed_route(path, boost::beast::http::verb::get, + detail::make_typed_handler(std::forward(handler))); + } + + template , int> = 0> + void post(const std::string& path, Handler&& handler) + { + add_typed_route(path, boost::beast::http::verb::post, + detail::make_typed_handler(std::forward(handler))); + } + + template , int> = 0> + void put(const std::string& path, Handler&& handler) + { + add_typed_route(path, boost::beast::http::verb::put, + detail::make_typed_handler(std::forward(handler))); + } + + template , int> = 0> + void del(const std::string& path, Handler&& handler) + { + add_typed_route(path, boost::beast::http::verb::delete_, + detail::make_typed_handler(std::forward(handler))); + } + + template , int> = 0> + void options(const std::string& path, Handler&& handler) + { + add_typed_route(path, boost::beast::http::verb::options, + detail::make_typed_handler(std::forward(handler))); + } + + template + void get(const std::string& path, std::shared_ptr controller, Method method) + { + add_typed_route(path, boost::beast::http::verb::get, + detail::make_typed_member_handler(std::move(controller), method)); + } + + template + void post(const std::string& path, std::shared_ptr controller, Method method) + { + add_typed_route(path, boost::beast::http::verb::post, + detail::make_typed_member_handler(std::move(controller), method)); + } + + template + void put(const std::string& path, std::shared_ptr controller, Method method) + { + add_typed_route(path, boost::beast::http::verb::put, + detail::make_typed_member_handler(std::move(controller), method)); + } + + template + void del(const std::string& path, std::shared_ptr controller, Method method) + { + add_typed_route(path, boost::beast::http::verb::delete_, + detail::make_typed_member_handler(std::move(controller), method)); + } + + template + void options(const std::string& path, std::shared_ptr controller, Method method) + { + add_typed_route(path, boost::beast::http::verb::options, + detail::make_typed_member_handler(std::move(controller), method)); + } // Async handlers must invoke complete exactly once, from any thread. void async_route(const std::string& path, boost::beast::http::verb method, HttpAsyncHandler handler); void stream(const std::string& path, boost::beast::http::verb method, HttpStreamHandler handler); @@ -78,6 +164,15 @@ namespace khttpd::framework // Exception handling void add_exception_handler(std::shared_ptr handler); + + template + void map_exception(Mapper&& mapper) + { + using StoredMapper = std::decay_t; + add_exception_handler(std::make_shared>( + StoredMapper(std::forward(mapper)))); + } + void set_unknown_exception_handler(UnknownExceptionHandler handler); void handle_exception(std::exception_ptr eptr, HttpContext& ctx) const; void handle_unknown_exception(HttpContext& ctx) const; @@ -85,14 +180,29 @@ namespace khttpd::framework bool dispatch(HttpContext& ctx, const std::function& static_file_fun = nullptr) const; bool dispatch_async(HttpContext& ctx, HttpAsyncComplete complete) const; + // Returns handler-free copies suitable for documentation and inspection. + std::vector route_descriptors() const; + private: + friend void install_openapi_routes(HttpRouter& router, const OpenApiInfo& info, + const std::string& spec_path, const std::string& docs_path, bool enabled); + std::vector routes_; + std::vector route_descriptors_; std::vector> interceptors_; std::vector> exception_handlers_; UnknownExceptionHandler unknown_exception_handler_; - void add_route(const std::string& path_pattern, boost::beast::http::verb method, HttpHandler handler); + void add_route(const std::string& path_pattern, boost::beast::http::verb method, HttpHandler handler, + std::optional request_schema = std::nullopt, + std::optional response_schema = std::nullopt, + bool documented = true); + void add_typed_route(const std::string& path_pattern, boost::beast::http::verb method, + detail::TypedRouteHandler handler); + 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); 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 new file mode 100644 index 0000000..c6d62b3 --- /dev/null +++ b/framework/router/openapi.cpp @@ -0,0 +1,218 @@ +#include "router/openapi.hpp" + +#include "router/http_router.hpp" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace khttpd::framework +{ + namespace + { + struct DocumentedPath + { + std::string path; + std::vector parameters; + }; + + DocumentedPath document_path(const std::string& route_path) + { + static const std::regex parameter_pattern(":([a-zA-Z_][a-zA-Z0-9_]*)"); + DocumentedPath result; + auto current = route_path.cbegin(); + const std::sregex_iterator end; + for (auto it = std::sregex_iterator(route_path.cbegin(), route_path.cend(), parameter_pattern); + it != end; ++it) + { + result.path.append(current, it->prefix().second); + result.path += "{" + (*it)[1].str() + "}"; + result.parameters.push_back((*it)[1].str()); + current = it->suffix().first; + } + result.path.append(current, route_path.cend()); + return result; + } + + std::string method_name(const boost::beast::http::verb method) + { + std::string result(boost::beast::http::to_string(method)); + std::transform(result.begin(), result.end(), result.begin(), [](const unsigned char c) + { + return static_cast(std::tolower(c)); + }); + return result; + } + + boost::json::object make_operation(const RouteDescriptor& descriptor, + const std::vector& path_parameters) + { + boost::json::object operation; + if (!path_parameters.empty()) + { + boost::json::array parameters; + 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"}}); + if (index + 1 == path_parameters.size()) parameter.emplace("x-khttpd-greedy", true); + parameters.emplace_back(std::move(parameter)); + } + operation.emplace("parameters", std::move(parameters)); + } + + if (descriptor.request_schema) + { + boost::json::object media_type; + media_type.emplace("schema", *descriptor.request_schema); + boost::json::object content; + content.emplace("application/json", std::move(media_type)); + boost::json::object request_body; + request_body.emplace("required", true); + request_body.emplace("content", std::move(content)); + operation.emplace("requestBody", std::move(request_body)); + } + + boost::json::object response; + response.emplace("description", "Successful response"); + if (descriptor.response_schema) + { + boost::json::object media_type; + media_type.emplace("schema", *descriptor.response_schema); + boost::json::object content; + content.emplace("application/json", std::move(media_type)); + response.emplace("content", std::move(content)); + } + boost::json::object responses; + responses.emplace("200", std::move(response)); + operation.emplace("responses", std::move(responses)); + return operation; + } + + void validate_documentation_path(const std::string& path, const char* name) + { + const auto literal_character = [](const unsigned char character) + { + return std::isalnum(character) != 0 || character == '/' || character == '.' || + character == '_' || character == '-' || character == '~'; + }; + if (path.empty() || path.front() != '/' || + !std::all_of(path.begin(), path.end(), literal_character)) + throw std::invalid_argument(std::string(name) + " must be an absolute literal HTTP path"); + } + + std::string escape_html(const std::string& input) + { + std::string escaped; + escaped.reserve(input.size()); + for (const char character : input) + { + switch (character) + { + case '&': escaped += "&"; break; + case '<': escaped += "<"; break; + case '>': escaped += ">"; break; + case '\"': escaped += """; break; + case '\'': escaped += "'"; break; + default: escaped += character; break; + } + } + return escaped; + } + } + + boost::json::object generate_openapi(const HttpRouter& router, const OpenApiInfo& info) + { + using Operations = std::map; + std::map documented_paths; + + for (const auto& descriptor : router.route_descriptors()) + { + auto path = document_path(descriptor.path); + documented_paths[path.path][method_name(descriptor.method)] = + make_operation(descriptor, path.parameters); + } + + boost::json::object paths; + for (auto& [path, methods] : documented_paths) + { + boost::json::object path_item; + for (auto& [method, operation] : methods) + path_item.emplace(method, std::move(operation)); + paths.emplace(path, std::move(path_item)); + } + + boost::json::object document; + document.emplace("openapi", "3.1.0"); + document.emplace("info", boost::json::object{{"title", info.title}, {"version", info.version}}); + document.emplace("paths", std::move(paths)); + return document; + } + + void install_openapi_routes(HttpRouter& router, const OpenApiInfo& info, + const std::string& spec_path, const std::string& docs_path, + const bool enabled) + { + if (!enabled) return; + + validate_documentation_path(spec_path, "OpenAPI specification path"); + validate_documentation_path(docs_path, "OpenAPI documentation path"); + if (spec_path == docs_path) + throw std::invalid_argument("OpenAPI specification and documentation paths must differ"); + + for (const auto& descriptor : router.route_descriptors()) + { + if (descriptor.method == boost::beast::http::verb::get && + (descriptor.path == spec_path || descriptor.path == docs_path)) + throw std::invalid_argument("OpenAPI documentation path conflicts with an existing GET route: " + + descriptor.path); + } + + auto serialized_document = std::make_shared( + boost::json::serialize(generate_openapi(router, info))); + router.add_route(spec_path, boost::beast::http::verb::get, + [serialized_document](HttpContext& context) + { + context.set_status(boost::beast::http::status::ok); + context.set_content_type("application/json"); + context.set_body(*serialized_document); + }, std::nullopt, std::nullopt, false); + + auto page = std::make_shared( + "khttpd API documentation" + "

khttpd API documentation

OpenAPI 3.1 JSON

"); + router.add_route(docs_path, boost::beast::http::verb::get, + [page](HttpContext& context) + { + context.set_status(boost::beast::http::status::ok); + context.set_content_type("text/html"); + context.set_body(*page); + }, std::nullopt, std::nullopt, false); + } + + void export_openapi(const HttpRouter& router, const std::string& output_path, + const OpenApiInfo& info) + { + if (output_path.empty()) throw std::invalid_argument("OpenAPI output path must not be empty"); + + std::ofstream output(output_path, std::ios::binary | std::ios::trunc); + if (!output.is_open()) + throw std::runtime_error("Unable to open OpenAPI output path: " + output_path); + + output << boost::json::serialize(generate_openapi(router, info)) << '\n'; + output.close(); + if (!output) + throw std::runtime_error("Unable to write OpenAPI output path: " + output_path); + } +} diff --git a/framework/router/openapi.hpp b/framework/router/openapi.hpp new file mode 100644 index 0000000..5d30c40 --- /dev/null +++ b/framework/router/openapi.hpp @@ -0,0 +1,29 @@ +#ifndef KHTTPD_FRAMEWORK_ROUTER_OPENAPI_HPP_ +#define KHTTPD_FRAMEWORK_ROUTER_OPENAPI_HPP_ + +#include + +#include + +namespace khttpd::framework +{ + class HttpRouter; + + struct OpenApiInfo + { + std::string title = "khttpd API"; + std::string version = "1.0.0"; + }; + + boost::json::object generate_openapi(const HttpRouter& router, const OpenApiInfo& info = {}); + + void install_openapi_routes(HttpRouter& router, const OpenApiInfo& info = {}, + const std::string& spec_path = "/openapi.json", + const std::string& docs_path = "/docs", + bool enabled = true); + + void export_openapi(const HttpRouter& router, const std::string& output_path, + const OpenApiInfo& info = {}); +} + +#endif // KHTTPD_FRAMEWORK_ROUTER_OPENAPI_HPP_ diff --git a/framework/router/openapi_schema.hpp b/framework/router/openapi_schema.hpp new file mode 100644 index 0000000..1e0c625 --- /dev/null +++ b/framework/router/openapi_schema.hpp @@ -0,0 +1,93 @@ +#ifndef KHTTPD_FRAMEWORK_ROUTER_OPENAPI_SCHEMA_HPP_ +#define KHTTPD_FRAMEWORK_ROUTER_OPENAPI_SCHEMA_HPP_ + +#include +#include +#include + +#include +#include +#include +#include + +namespace khttpd::framework::detail +{ + template + struct is_optional : std::false_type {}; + + template + struct is_optional> : std::true_type + { + using value_type = T; + }; + + template + inline constexpr bool is_optional_v = is_optional::value; + + template + struct is_vector : std::false_type {}; + + template + struct is_vector> : std::true_type + { + using value_type = T; + }; + + template + boost::json::value openapi_schema() + { + using Value = std::remove_cv_t>; + boost::json::object schema; + + if constexpr (is_optional_v) + { + return openapi_schema::value_type>(); + } + else if constexpr (std::is_same_v) + { + schema.emplace("type", "boolean"); + } + else if constexpr (std::is_integral_v) + { + schema.emplace("type", "integer"); + } + else if constexpr (std::is_floating_point_v) + { + schema.emplace("type", "number"); + } + else if constexpr (std::is_same_v || std::is_enum_v) + { + schema.emplace("type", "string"); + } + else if constexpr (is_vector::value) + { + schema.emplace("type", "array"); + schema.emplace("items", openapi_schema::value_type>()); + } + else if constexpr (boost::describe::has_describe_members::value) + { + boost::json::object properties; + boost::json::array required; + using Members = boost::describe::describe_members; + boost::mp11::mp_for_each([&](auto descriptor) + { + using Member = std::remove_cv_t().*descriptor.pointer)>>; + properties.emplace(descriptor.name, openapi_schema()); + if constexpr (!is_optional_v) required.emplace_back(descriptor.name); + }); + schema.emplace("type", "object"); + schema.emplace("properties", std::move(properties)); + if (!required.empty()) schema.emplace("required", std::move(required)); + } + else + { + // C++17 cannot reflect arbitrary tag_invoke converters. They remain valid typed + // routes, but their generated schema is intentionally conservative. + schema.emplace("type", "object"); + } + + return schema; + } +} + +#endif // KHTTPD_FRAMEWORK_ROUTER_OPENAPI_SCHEMA_HPP_ diff --git a/framework/router/typed_route.hpp b/framework/router/typed_route.hpp new file mode 100644 index 0000000..5e4fa61 --- /dev/null +++ b/framework/router/typed_route.hpp @@ -0,0 +1,232 @@ +#ifndef KHTTPD_FRAMEWORK_ROUTER_TYPED_ROUTE_HPP_ +#define KHTTPD_FRAMEWORK_ROUTER_TYPED_ROUTE_HPP_ + +#include "router/http_result.hpp" +#include "router/openapi_schema.hpp" + +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace khttpd::framework::detail +{ + struct TypedRouteHandler + { + std::function handler; + boost::json::value request_schema; + std::optional response_schema; + }; + + template + struct typed_response_body + { + using type = T; + }; + + template + struct typed_response_body> + { + using type = T; + }; + + template + struct callable_signature + { + using return_type = R; + static constexpr std::size_t arity = sizeof...(Args); + + template + using argument = std::tuple_element_t>; + }; + + template + struct callable_traits : callable_traits::operator())> {}; + + template + struct callable_traits : callable_signature {}; + + template + struct callable_traits : callable_signature {}; + + template + struct callable_traits> : callable_signature {}; + + template + struct callable_traits : callable_signature {}; + + template + struct callable_traits : callable_signature {}; + + template + using remove_cvref_t = std::remove_cv_t>; + + inline void write_invalid_request_body(HttpContext& context) + { + context.set_status(boost::beast::http::status::bad_request); + boost::json::object error; + error.emplace("code", "INVALID_REQUEST_BODY"); + error.emplace("message", "Request body must be valid JSON matching the expected schema"); + 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) + { + content_type.resize(semicolon); + } + + const auto first = std::find_if_not(content_type.begin(), content_type.end(), [](const unsigned char c) + { + return std::isspace(c) != 0; + }); + const auto last = std::find_if_not(content_type.rbegin(), content_type.rend(), [](const unsigned char c) + { + return std::isspace(c) != 0; + }).base(); + if (first >= last) return false; + + const auto media_type = ascii_lower(std::string(first, last)); + constexpr std::string_view prefix = "application/"; + constexpr std::string_view suffix = "+json"; + return media_type == "application/json" || + (media_type.size() > prefix.size() + suffix.size() && + media_type.compare(0, prefix.size(), prefix) == 0 && + media_type.compare(media_type.size() - suffix.size(), suffix.size(), suffix) == 0); + } + + template + TypedRouteHandler make_typed_handler(Handler&& input_handler) + { + using StoredHandler = std::decay_t; + using Traits = callable_traits; + static_assert(Traits::arity == 1 || Traits::arity == 2, + "typed handlers must accept (const Request&) or (const Request&, HttpContext&)"); + + using RequestArgument = typename Traits::template argument<0>; + using Request = remove_cvref_t; + using Response = typename Traits::return_type; + + static_assert(!std::is_same_v, + "legacy HttpContext handlers must use the existing route overload"); + 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"); + + if constexpr (Traits::arity == 2) + { + using ContextArgument = typename Traits::template argument<1>; + static_assert(std::is_same_v, + "the optional second typed-handler argument must be HttpContext&"); + } + + auto adapted = [handler = StoredHandler(std::forward(input_handler))](HttpContext& context) mutable + { + const auto content_type = context.get_header(boost::beast::http::field::content_type); + if (!content_type || !is_json_media_type(*content_type)) + { + write_invalid_request_body(context); + return; + } + + std::optional json; + std::optional request; + try + { + json.emplace(boost::json::parse(context.body())); + request.emplace(boost::json::value_to(*json)); + } + catch (const std::bad_alloc&) + { + throw; + } + catch (const std::exception&) + { + write_invalid_request_body(context); + return; + } + + if constexpr (Traits::arity == 1) + { + auto response = std::invoke(handler, static_cast(*request)); + apply_typed_response(context, std::move(response)); + } + else + { + auto response = std::invoke(handler, static_cast(*request), context); + apply_typed_response(context, std::move(response)); + } + }; + + 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)}; + } + + template + TypedRouteHandler make_typed_member_handler( + std::shared_ptr controller, + Response (Controller::*method)(const Request&)) + { + std::function bound = + [controller = std::move(controller), method](const Request& request) + { + return std::invoke(method, *controller, request); + }; + return make_typed_handler(std::move(bound)); + } + + template + TypedRouteHandler make_typed_member_handler( + std::shared_ptr controller, + Response (Controller::*method)(const Request&) const) + { + std::function bound = + [controller = std::move(controller), method](const Request& request) + { + return std::invoke(method, *controller, request); + }; + return make_typed_handler(std::move(bound)); + } + + template + TypedRouteHandler make_typed_member_handler( + std::shared_ptr controller, + Response (Controller::*method)(const Request&, HttpContext&)) + { + std::function bound = + [controller = std::move(controller), method](const Request& request, HttpContext& context) + { + return std::invoke(method, *controller, request, context); + }; + return make_typed_handler(std::move(bound)); + } + + template + TypedRouteHandler make_typed_member_handler( + std::shared_ptr controller, + Response (Controller::*method)(const Request&, HttpContext&) const) + { + std::function bound = + [controller = std::move(controller), method](const Request& request, HttpContext& context) + { + return std::invoke(method, *controller, request, context); + }; + return make_typed_handler(std::move(bound)); + } +} + +#endif // KHTTPD_FRAMEWORK_ROUTER_TYPED_ROUTE_HPP_ diff --git a/framework/session/http_session.cpp b/framework/session/http_session.cpp index 697bba8..b8ea40c 100644 --- a/framework/session/http_session.cpp +++ b/framework/session/http_session.cpp @@ -379,16 +379,26 @@ void HttpSession::handle_request() catch (...) { router_.handle_exception(std::current_exception(), *ctx); - send_response(std::move(res_)); + try + { + router_.run_post_interceptors(*ctx); + } + catch (...) + { + router_.handle_exception(std::current_exception(), *ctx); + } + send_context_response(); } } void HttpSession::dispatch_request_after_interceptors(InterceptorResult result) { + bool post_interceptors_started = false; try { if (result == InterceptorResult::Stop) { + post_interceptors_started = true; router_.run_post_interceptors(*ctx); return send_context_response(); } @@ -409,12 +419,25 @@ void HttpSession::dispatch_request_after_interceptors(InterceptorResult result) return static_file_served; }); if (static_file_served) return; + post_interceptors_started = true; router_.run_post_interceptors(*ctx); send_context_response(); } catch (...) { router_.handle_exception(std::current_exception(), *ctx); + if (!post_interceptors_started) + { + try + { + post_interceptors_started = true; + router_.run_post_interceptors(*ctx); + } + catch (...) + { + router_.handle_exception(std::current_exception(), *ctx); + } + } send_context_response(); } } diff --git a/framework/tests/BUILD.bazel b/framework/tests/BUILD.bazel index ac2d444..79076e0 100644 --- a/framework/tests/BUILD.bazel +++ b/framework/tests/BUILD.bazel @@ -254,3 +254,33 @@ cc_test( "@googletest//:gtest_main", ], ) + +cc_test( + name = "typed_route_test", + srcs = ["typed_route_test.cpp"], + copts = [ + "-std=c++17", + "-Wall", + "-pedantic", + ], + deps = [ + "//framework", + "@googletest//:gtest", + "@googletest//:gtest_main", + ], +) + +cc_test( + name = "openapi_test", + srcs = ["openapi_test.cpp"], + copts = [ + "-std=c++17", + "-Wall", + "-pedantic", + ], + deps = [ + "//framework", + "@googletest//:gtest", + "@googletest//:gtest_main", + ], +) diff --git a/framework/tests/openapi_test.cpp b/framework/tests/openapi_test.cpp new file mode 100644 index 0000000..c0bf525 --- /dev/null +++ b/framework/tests/openapi_test.cpp @@ -0,0 +1,273 @@ +#include "framework/router/http_router.hpp" +#include "framework/router/http_result.hpp" +#include "framework/router/openapi.hpp" + +#include +#if defined(__clang__) +#pragma clang diagnostic push +#pragma clang diagnostic ignored "-Wgnu-zero-variadic-macro-arguments" +#pragma clang diagnostic ignored "-Wvariadic-macros" +#pragma clang diagnostic ignored "-Wpedantic" +#elif defined(__GNUC__) +#pragma GCC diagnostic push +#pragma GCC diagnostic ignored "-Wvariadic-macros" +#pragma GCC diagnostic ignored "-Wpedantic" +#endif +#include +#include +#include + +#include +#include +#include +#include +#include +#include +#include + +namespace fw = khttpd::framework; +namespace http = boost::beast::http; + +struct OpenApiCreatePetRequest +{ + std::string name; + int age; + std::optional nickname; +}; + +struct OpenApiPetResponse +{ + int id; + std::string name; + std::vector tags; +}; + +BOOST_DESCRIBE_STRUCT(OpenApiCreatePetRequest, (), (name, age, nickname)) +BOOST_DESCRIBE_STRUCT(OpenApiPetResponse, (), (id, name, tags)) + +#if defined(__clang__) +#pragma clang diagnostic pop +#elif defined(__GNUC__) +#pragma GCC diagnostic pop +#endif + +namespace +{ + const boost::json::object& operation_at(const boost::json::object& document, + const std::string& path, + const std::string& method) + { + return document.at("paths").as_object().at(path).as_object().at(method).as_object(); + } + + std::string test_output_path(const std::string& filename) + { + const auto* directory = std::getenv("TEST_TMPDIR"); + return std::string(directory ? directory : "/tmp") + "/" + filename; + } + + std::string read_file(const std::string& path) + { + std::ifstream input(path, std::ios::binary); + std::ostringstream contents; + contents << input.rdbuf(); + return contents.str(); + } +} + +TEST(OpenApiTest, IncludesLegacyMethodsAndPathParameters) +{ + fw::HttpRouter router; + router.get("/users/:id", [](fw::HttpContext&) {}); + router.post("/users/:id", [](fw::HttpContext&) {}); + + const auto document = fw::generate_openapi(router, {"Accounts API", "9.2.0"}); + + EXPECT_EQ(document.at("openapi"), "3.1.0"); + EXPECT_EQ(document.at("info").as_object().at("title"), "Accounts API"); + EXPECT_EQ(document.at("info").as_object().at("version"), "9.2.0"); + const auto& path = document.at("paths").as_object().at("/users/{id}").as_object(); + EXPECT_TRUE(path.contains("get")); + EXPECT_TRUE(path.contains("post")); + + const auto& parameters = path.at("get").as_object().at("parameters").as_array(); + ASSERT_EQ(parameters.size(), 1U); + const auto& parameter = parameters.front().as_object(); + EXPECT_EQ(parameter.at("name"), "id"); + EXPECT_EQ(parameter.at("in"), "path"); + EXPECT_EQ(parameter.at("required"), true); + EXPECT_EQ(parameter.at("schema").as_object().at("type"), "string"); + EXPECT_EQ(parameter.at("x-khttpd-greedy"), true); +} + +TEST(OpenApiTest, IncludesTypedDescribeSchemas) +{ + fw::HttpRouter router; + router.post("/pets", [](const OpenApiCreatePetRequest& request) + { + return fw::HttpResult::created({7, request.name, {"new"}}); + }); + + const auto document = fw::generate_openapi(router); + const auto& operation = operation_at(document, "/pets", "post"); + 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"); + const auto& request_properties = request_schema.at("properties").as_object(); + EXPECT_EQ(request_properties.at("name").as_object().at("type"), "string"); + EXPECT_EQ(request_properties.at("age").as_object().at("type"), "integer"); + EXPECT_EQ(request_properties.at("nickname").as_object().at("type"), "string"); + const auto& required = request_schema.at("required").as_array(); + ASSERT_EQ(required.size(), 2U); + EXPECT_EQ(required[0], "name"); + EXPECT_EQ(required[1], "age"); + + const auto& response_schema = 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_schema.at("type"), "object"); + EXPECT_EQ(response_schema.at("properties").as_object().at("id").as_object().at("type"), "integer"); + const auto& tags = response_schema.at("properties").as_object().at("tags").as_object(); + EXPECT_EQ(tags.at("type"), "array"); + EXPECT_EQ(tags.at("items").as_object().at("type"), "string"); +} + +TEST(OpenApiTest, DocumentsAsyncAndStreamSkeletons) +{ + fw::HttpRouter router; + router.async_route("/jobs/:job_id", http::verb::post, + [](fw::HttpContext&, fw::HttpAsyncComplete complete) { complete(); }); + router.stream("/downloads/:path", http::verb::get, + [](fw::HttpContext&, std::shared_ptr, + std::shared_ptr, fw::HttpStreamComplete complete) + { + complete(); + }); + + const auto document = fw::generate_openapi(router); + + EXPECT_TRUE(document.at("paths").as_object().at("/jobs/{job_id}").as_object().contains("post")); + EXPECT_TRUE(document.at("paths").as_object().at("/downloads/{path}").as_object().contains("get")); + EXPECT_TRUE(operation_at(document, "/jobs/{job_id}", "post").contains("responses")); + EXPECT_TRUE(operation_at(document, "/downloads/{path}", "get").contains("responses")); +} + +TEST(OpenApiTest, ProducesDeterministicOutput) +{ + fw::HttpRouter router; + router.get("/z-last", [](fw::HttpContext&) {}); + router.post("/a-first", [](fw::HttpContext&) {}); + router.get("/a-first", [](fw::HttpContext&) {}); + + const auto first = boost::json::serialize(fw::generate_openapi(router, {"Stable", "1"})); + const auto second = boost::json::serialize(fw::generate_openapi(router, {"Stable", "1"})); + + EXPECT_EQ(first, second); + EXPECT_LT(first.find("/a-first"), first.find("/z-last")); + EXPECT_LT(first.find("\"get\""), first.find("\"post\"")); +} + +TEST(OpenApiTest, ServesHiddenRuntimeDocument) +{ + fw::HttpRouter router; + router.get("/health", [](fw::HttpContext& context) + { + context.set_status(http::status::ok); + context.set_body("ok"); + }); + fw::install_openapi_routes(router, {"Runtime API", "2.0"}); + + http::request request(http::verb::get, "/openapi.json", 11); + http::response response; + fw::HttpContext context(request, response); + + EXPECT_TRUE(router.dispatch(context)); + EXPECT_EQ(response.result(), http::status::ok); + EXPECT_EQ(response[http::field::content_type], "application/json"); + const auto document = boost::json::parse(response.body()).as_object(); + EXPECT_EQ(document.at("info").as_object().at("title"), "Runtime API"); + EXPECT_TRUE(document.at("paths").as_object().contains("/health")); + + http::request docs_request(http::verb::get, "/docs", 11); + http::response docs_response; + fw::HttpContext docs_context(docs_request, docs_response); + EXPECT_TRUE(router.dispatch(docs_context)); + EXPECT_EQ(docs_response.result(), http::status::ok); + EXPECT_EQ(docs_response[http::field::content_type], "text/html"); + EXPECT_NE(docs_response.body().find("/openapi.json"), std::string::npos); +} + +TEST(OpenApiTest, DoesNotDocumentRuntimeDocumentationRoutes) +{ + fw::HttpRouter router; + router.get("/health", [](fw::HttpContext&) {}); + fw::install_openapi_routes(router); + + const auto paths = fw::generate_openapi(router).at("paths").as_object(); + + EXPECT_TRUE(paths.contains("/health")); + EXPECT_FALSE(paths.contains("/openapi.json")); + EXPECT_FALSE(paths.contains("/docs")); +} + +TEST(OpenApiTest, DoesNotInstallRuntimeDocumentationRoutesWhenDisabled) +{ + fw::HttpRouter router; + router.get("/health", [](fw::HttpContext&) {}); + fw::install_openapi_routes(router, {}, "/openapi.json", "/docs", false); + + http::request spec_request(http::verb::get, "/openapi.json", 11); + http::response spec_response; + fw::HttpContext spec_context(spec_request, spec_response); + EXPECT_FALSE(router.dispatch(spec_context)); + EXPECT_EQ(spec_response.result(), http::status::not_found); + + http::request docs_request(http::verb::get, "/docs", 11); + http::response docs_response; + fw::HttpContext docs_context(docs_request, docs_response); + EXPECT_FALSE(router.dispatch(docs_context)); + EXPECT_EQ(docs_response.result(), http::status::not_found); + EXPECT_TRUE(fw::generate_openapi(router).at("paths").as_object().contains("/health")); +} + +TEST(OpenApiTest, ExportsDeterministicJsonFile) +{ + fw::HttpRouter router; + router.get("/z", [](fw::HttpContext&) {}); + router.get("/a", [](fw::HttpContext&) {}); + const auto first_path = test_output_path("openapi-first.json"); + const auto second_path = test_output_path("openapi-second.json"); + + fw::export_openapi(router, first_path, {"Export API", "3"}); + fw::export_openapi(router, second_path, {"Export API", "3"}); + + const auto first = read_file(first_path); + const auto second = read_file(second_path); + EXPECT_EQ(first, second); + ASSERT_FALSE(first.empty()); + EXPECT_EQ(first.back(), '\n'); + const auto document = boost::json::parse(first).as_object(); + EXPECT_EQ(document.at("info").as_object().at("version"), "3"); +} + +TEST(OpenApiTest, RejectsInvalidOutputPath) +{ + fw::HttpRouter router; + const auto missing_parent = test_output_path("missing-parent/openapi.json"); + + EXPECT_THROW(fw::export_openapi(router, ""), std::invalid_argument); + EXPECT_THROW(fw::export_openapi(router, missing_parent), std::runtime_error); +} + +TEST(OpenApiSecurityTest, RejectsConflictingOrDynamicDocumentationPaths) +{ + fw::HttpRouter conflicting_router; + conflicting_router.get("/openapi.json", [](fw::HttpContext&) {}); + + EXPECT_THROW(fw::install_openapi_routes(conflicting_router), std::invalid_argument); + + fw::HttpRouter dynamic_router; + EXPECT_THROW(fw::install_openapi_routes(dynamic_router, {}, "/spec/:name", "/docs"), + std::invalid_argument); + EXPECT_THROW(fw::install_openapi_routes(dynamic_router, {}, "/spec.json", "/docs\r\nInjected"), + std::invalid_argument); +} diff --git a/framework/tests/session_test.cpp b/framework/tests/session_test.cpp index c09f1c5..567bf26 100644 --- a/framework/tests/session_test.cpp +++ b/framework/tests/session_test.cpp @@ -241,6 +241,121 @@ TEST(HttpSessionTest, AsyncInterceptorSeesTransportPeerAndCanDenyRequest) EXPECT_NE(auth->seen_peer->port(), 0); } +TEST(HttpSessionTest, TypedRouteCannotBypassAuthorizationInterceptor) +{ + struct DenyAccess final : khttpd_fw::Interceptor + { + khttpd_fw::InterceptorResult handle_request(khttpd_fw::HttpContext& ctx) override + { + ctx.set_status(http::status::forbidden); + ctx.set_content_type("application/json"); + ctx.set_body(R"({"code":"FORBIDDEN"})"); + return khttpd_fw::InterceptorResult::Stop; + } + }; + + TempStaticTree tree; + khttpd_fw::HttpRouter router; + khttpd_fw::WebsocketRouter websocket_router; + int handler_calls = 0; + router.add_interceptor(std::make_shared()); + router.post("/typed-private", [&handler_calls](const boost::json::object& body) + { + ++handler_calls; + return body; + }); + + http::request req{http::verb::post, "/typed-private", 11}; + req.set(http::field::content_type, "application/json"); + req.body() = R"({"secret":"request"})"; + req.prepare_payload(); + req.keep_alive(false); + + auto res = round_trip(router, websocket_router, tree.web, std::move(req)); + + EXPECT_EQ(res.result(), http::status::forbidden); + EXPECT_EQ(res.body(), R"({"code":"FORBIDDEN"})"); + EXPECT_EQ(handler_calls, 0); +} + +TEST(HttpSessionTest, TypedHandlerExceptionUsesRegisteredMapper) +{ + class SessionValidationError : public std::runtime_error + { + public: + using std::runtime_error::runtime_error; + }; + class SecurityHeaders final : public khttpd_fw::Interceptor + { + public: + void handle_response(khttpd_fw::HttpContext& ctx) override + { + ctx.set_header("X-Content-Type-Options", "nosniff"); + } + }; + + TempStaticTree tree; + khttpd_fw::HttpRouter router; + khttpd_fw::WebsocketRouter websocket_router; + router.add_interceptor(std::make_shared()); + router.map_exception([](const SessionValidationError& error) + { + boost::json::object body; + body.emplace("code", "SESSION_VALIDATION_FAILED"); + body.emplace("message", error.what()); + return khttpd_fw::HttpResult(http::status::unprocessable_entity, std::move(body)); + }); + router.post("/typed-error", [](const boost::json::object&) -> boost::json::object + { + throw SessionValidationError("typed request rejected"); + }); + + http::request req{http::verb::post, "/typed-error", 11}; + req.set(http::field::content_type, "application/json"); + req.body() = R"({"value":1})"; + req.prepare_payload(); + req.keep_alive(false); + + auto res = round_trip(router, websocket_router, tree.web, std::move(req)); + + EXPECT_EQ(res.result(), http::status::unprocessable_entity); + EXPECT_EQ(res[http::field::content_type], "application/json"); + EXPECT_EQ(res["X-Content-Type-Options"], "nosniff"); + EXPECT_EQ(res.body(), + R"({"code":"SESSION_VALIDATION_FAILED","message":"typed request rejected"})"); +} + +TEST(HttpSessionTest, ThrowingPostInterceptorRunsOnlyOnceAndReturnsSafeError) +{ + class ThrowingPostInterceptor final : public khttpd_fw::Interceptor + { + public: + int calls = 0; + + void handle_response(khttpd_fw::HttpContext&) override + { + ++calls; + throw std::runtime_error("post interceptor secret"); + } + }; + + TempStaticTree tree; + khttpd_fw::HttpRouter router; + khttpd_fw::WebsocketRouter websocket_router; + auto interceptor = std::make_shared(); + router.add_interceptor(interceptor); + router.get("/post-error", [](khttpd_fw::HttpContext& ctx) { ctx.set_body("success"); }); + + http::request req{http::verb::get, "/post-error", 11}; + req.keep_alive(false); + auto res = round_trip(router, websocket_router, tree.web, std::move(req)); + + EXPECT_EQ(interceptor->calls, 1); + EXPECT_EQ(res.result(), http::status::internal_server_error); + EXPECT_EQ(res.body(), R"({"code":"INTERNAL_SERVER_ERROR","message":"Internal server error"})"); + EXPECT_EQ(res.body().find("post interceptor secret"), std::string::npos); +} + TEST(HttpSessionTest, AsyncRouteCompletesResponseFromAnotherThread) { TempStaticTree tree; diff --git a/framework/tests/typed_route_test.cpp b/framework/tests/typed_route_test.cpp new file mode 100644 index 0000000..78173a0 --- /dev/null +++ b/framework/tests/typed_route_test.cpp @@ -0,0 +1,555 @@ +#include "framework/exception/http_exception.hpp" +#include "framework/context/http_context.hpp" +#include "framework/controller/http_controller.hpp" +#include "framework/router/http_result.hpp" +#include "framework/router/http_router.hpp" + +#include +#if defined(__clang__) +#pragma clang diagnostic push +#pragma clang diagnostic ignored "-Wgnu-zero-variadic-macro-arguments" +#pragma clang diagnostic ignored "-Wvariadic-macros" +#pragma clang diagnostic ignored "-Wpedantic" +#elif defined(__GNUC__) +#pragma GCC diagnostic push +#pragma GCC diagnostic ignored "-Wvariadic-macros" +#pragma GCC diagnostic ignored "-Wpedantic" +#endif +#include +#include +#include + +#include +#include + +namespace http = boost::beast::http; +namespace fw = khttpd::framework; + +struct DescribedPayload +{ + int value; +}; + +BOOST_DESCRIBE_STRUCT(DescribedPayload, (), (value)) +#if defined(__clang__) +#pragma clang diagnostic pop +#elif defined(__GNUC__) +#pragma GCC diagnostic pop +#endif + +namespace +{ + struct Reply + { + int id; + std::string name; + }; + + struct CreateRequest + { + std::string name; + int age; + }; + + struct ErrorReply + { + std::string code; + std::string message; + }; + + class ValidationError : public std::runtime_error + { + public: + using std::runtime_error::runtime_error; + }; + + CreateRequest tag_invoke(boost::json::value_to_tag, const boost::json::value& value) + { + const auto& object = value.as_object(); + return { + boost::json::value_to(object.at("name")), + boost::json::value_to(object.at("age")), + }; + } + + void tag_invoke(boost::json::value_from_tag, boost::json::value& value, const ErrorReply& error) + { + value = { + {"code", error.code}, + {"message", error.message}, + }; + } + + void tag_invoke(boost::json::value_from_tag, boost::json::value& value, const Reply& reply) + { + value = { + {"id", reply.id}, + {"name", reply.name}, + }; + } + + fw::HttpContext make_context(http::request& request, + http::response& response) + { + return fw::HttpContext(request, response); + } + + http::request json_request(const std::string& target, const std::string& body) + { + http::request request(http::verb::post, target, 11); + request.set(http::field::content_type, "application/json"); + request.body() = body; + request.prepare_payload(); + return request; + } + + class TypedController final : public fw::BaseController + { + public: + std::shared_ptr register_routes(fw::HttpRouter& router) override + { + KHTTPD_TYPED_ROUTE(post, "/member", create); + KHTTPD_TYPED_ROUTE(post, "/const-member", lookup); + KHTTPD_TYPED_ROUTE(post, "/with-context", with_context); + return shared_from_this(); + } + + private: + fw::HttpResult create(const CreateRequest& request) + { + return fw::HttpResult::created(Reply{request.age, request.name}); + } + + Reply lookup(const CreateRequest& request) const + { + return Reply{request.age + 1, request.name}; + } + + Reply with_context(const CreateRequest& request, fw::HttpContext& context) + { + return Reply{request.age, context.get_header("X-Display-Name").value_or(request.name)}; + } + }; +} + +TEST(HttpResultTest, AppliesStatusJsonBodyAndCustomHeaders) +{ + http::request request; + http::response response; + auto context = make_context(request, response); + + fw::HttpResult result(http::status::created, Reply{7, "Ada"}); + result.header("Location", "/users/7").header("X-Request-Id", "req-123"); + + fw::detail::apply_typed_response(context, result); + + EXPECT_EQ(response.result(), http::status::created); + EXPECT_EQ(response[http::field::content_type], "application/json"); + EXPECT_EQ(response[http::field::location], "/users/7"); + EXPECT_EQ(response["X-Request-Id"], "req-123"); + EXPECT_EQ(response.body(), R"({"id":7,"name":"Ada"})"); +} + +TEST(HttpResultTest, WrapsBareResponseAsJsonOk) +{ + http::request request; + http::response response; + auto context = make_context(request, response); + + fw::detail::apply_typed_response(context, Reply{8, "Grace"}); + + EXPECT_EQ(response.result(), http::status::ok); + EXPECT_EQ(response[http::field::content_type], "application/json"); + EXPECT_EQ(response.body(), R"({"id":8,"name":"Grace"})"); +} + +TEST(HttpResultTest, SupportsEmptyNoContentResponse) +{ + http::request request; + http::response response; + auto context = make_context(request, response); + + auto result = fw::HttpResult::no_content(); + result.header("X-Request-Id", "req-204"); + fw::detail::apply_typed_response(context, result); + + EXPECT_EQ(response.result(), http::status::no_content); + EXPECT_EQ(response["X-Request-Id"], "req-204"); + EXPECT_TRUE(response.body().empty()); + EXPECT_EQ(response.find(http::field::content_type), response.end()); +} + +TEST(HttpResultSecurityTest, RejectsHeaderInjectionAndInvalidNames) +{ + fw::HttpResult result(http::status::ok, Reply{1, "test"}); + + EXPECT_THROW(result.header("X-Test\r\nInjected", "value"), std::invalid_argument); + EXPECT_THROW(result.header("X Test", "value"), std::invalid_argument); + EXPECT_THROW(result.header("X-Test", "value\r\nInjected: yes"), std::invalid_argument); + EXPECT_THROW(result.header("X-Test", std::string("ok\0bad", 6)), std::invalid_argument); + EXPECT_THROW(result.header("X-Test", std::string("ok\x01", 3)), std::invalid_argument); + EXPECT_THROW(result.header("X-Test", std::string("ok\x7f", 3)), std::invalid_argument); +} + +TEST(HttpResultSecurityTest, RejectsApplicationControlledFramingHeaders) +{ + fw::HttpResult result(http::status::ok, Reply{1, "test"}); + + EXPECT_THROW(result.header("Content-Length", "1"), std::invalid_argument); + EXPECT_THROW(result.header("content-length", "1"), std::invalid_argument); + EXPECT_THROW(result.header("Transfer-Encoding", "chunked"), std::invalid_argument); + EXPECT_THROW(result.header("Connection", "keep-alive"), std::invalid_argument); + EXPECT_THROW(result.header("Keep-Alive", "timeout=10"), std::invalid_argument); + EXPECT_THROW(result.header("Upgrade", "websocket"), std::invalid_argument); + EXPECT_THROW(result.header("Trailer", "X-Checksum"), std::invalid_argument); +} + +TEST(HttpResultSecurityTest, AcceptsOrdinaryResponseHeaders) +{ + fw::HttpResult result(http::status::ok, Reply{1, "test"}); + + EXPECT_NO_THROW(result.header("Location", "/safe").header("X-Correlation-Id", "abc-123")); + ASSERT_EQ(result.headers().size(), 2U); + EXPECT_EQ(result.headers()[0].name, "Location"); + EXPECT_EQ(result.headers()[1].value, "abc-123"); +} + +TEST(TypedRouteTest, ConvertsJsonAndAppliesHttpResult) +{ + fw::HttpRouter router; + int calls = 0; + router.post("/users", [&calls](const CreateRequest& request) + { + ++calls; + auto result = fw::HttpResult::created(Reply{request.age, request.name}); + return result.header("Location", "/users/42"); + }); + + auto request = json_request("/users", R"({"name":"Ada","age":42})"); + http::response response; + auto context = make_context(request, response); + + EXPECT_TRUE(router.dispatch(context)); + EXPECT_EQ(calls, 1); + EXPECT_EQ(response.result(), http::status::created); + EXPECT_EQ(response[http::field::location], "/users/42"); + EXPECT_EQ(response.body(), R"({"id":42,"name":"Ada"})"); +} + +TEST(TypedRouteTest, InvalidBodiesReturnStableBadRequestWithoutCallingHandler) +{ + const std::string expected = + R"({"code":"INVALID_REQUEST_BODY","message":"Request body must be valid JSON matching the expected schema"})"; + + struct Case + { + const char* body; + bool json_content_type; + }; + const Case cases[] = { + {R"({"name":"Ada","age":42})", false}, + {R"({"name":)", true}, + {R"({"name":"Ada","age":"old"})", true}, + }; + + for (const auto& test_case : cases) + { + fw::HttpRouter router; + int calls = 0; + router.post("/users", [&calls](const CreateRequest& request) + { + ++calls; + return Reply{request.age, request.name}; + }); + + http::request request(http::verb::post, "/users", 11); + if (test_case.json_content_type) request.set(http::field::content_type, "application/json"); + request.body() = test_case.body; + request.prepare_payload(); + 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(), expected); + } +} + +TEST(TypedRouteSecurityTest, RejectsMisleadingJsonMediaType) +{ + fw::HttpRouter router; + int calls = 0; + router.post("/users", [&calls](const CreateRequest& request) + { + ++calls; + return Reply{request.age, request.name}; + }); + + auto request = json_request("/users", R"({"name":"Ada","age":42})"); + request.set(http::field::content_type, "application/json.evil"); + 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.body(), + R"({"code":"INVALID_REQUEST_BODY","message":"Request body must be valid JSON matching the expected schema"})"); +} + +TEST(TypedRouteTest, AcceptsJsonMediaTypeParameters) +{ + fw::HttpRouter router; + router.post("/users", [](const CreateRequest& request) + { + return Reply{request.age, request.name}; + }); + + auto request = json_request("/users", R"({"name":"Ada","age":42})"); + request.set(http::field::content_type, "Application/JSON; charset=utf-8"); + 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"})"); +} + +TEST(TypedRouteTest, SupportsBoostDescribeDtoWithoutCustomJsonConverters) +{ + fw::HttpRouter router; + router.post("/described", [](const DescribedPayload& request) + { + return DescribedPayload{request.value + 1}; + }); + + auto request = json_request("/described", R"({"value":41})"); + 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"({"value":42})"); +} + +TEST(TypedRouteTest, SupportsStdFunctionAndEveryBufferedVerb) +{ + fw::HttpRouter router; + std::function get_handler = [](const CreateRequest& request) + { + return Reply{request.age, "get:" + request.name}; + }; + router.get("/typed-get", std::move(get_handler)); + router.post("/typed-post", [](const CreateRequest& request) { return Reply{request.age, "post:" + request.name}; }); + router.put("/typed-put", [](const CreateRequest& request) { return Reply{request.age, "put:" + request.name}; }); + router.del("/typed-delete", [](const CreateRequest& request) { return Reply{request.age, "delete:" + request.name}; }); + router.options("/typed-options", [](const CreateRequest& request) { return Reply{request.age, "options:" + request.name}; }); + + struct Case + { + http::verb verb; + const char* path; + const char* expected_name; + }; + const Case cases[] = { + {http::verb::get, "/typed-get", "get:Ada"}, + {http::verb::post, "/typed-post", "post:Ada"}, + {http::verb::put, "/typed-put", "put:Ada"}, + {http::verb::delete_, "/typed-delete", "delete:Ada"}, + {http::verb::options, "/typed-options", "options:Ada"}, + }; + + for (const auto& test_case : cases) + { + auto request = json_request(test_case.path, R"({"name":"Ada","age":42})"); + request.method(test_case.verb); + 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(), + std::string(R"({"id":42,"name":")") + test_case.expected_name + R"("})"); + } +} + +TEST(TypedRouteTest, SupportsControllerMembersConstMembersAndContextInjection) +{ + fw::HttpRouter router; + auto controller = std::make_shared(); + std::weak_ptr lifetime = controller; + controller->register_routes(router); + controller.reset(); + ASSERT_FALSE(lifetime.expired()); + + { + auto request = json_request("/member", R"({"name":"Ada","age":10})"); + http::response response; + auto context = make_context(request, response); + EXPECT_TRUE(router.dispatch(context)); + EXPECT_EQ(response.result(), http::status::created); + EXPECT_EQ(response.body(), R"({"id":10,"name":"Ada"})"); + } + + { + auto request = json_request("/const-member", R"({"name":"Grace","age":10})"); + 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":11,"name":"Grace"})"); + } + + { + auto request = json_request("/with-context", R"({"name":"fallback","age":12})"); + request.set("X-Display-Name", "Header Name"); + http::response response; + auto context = make_context(request, response); + EXPECT_TRUE(router.dispatch(context)); + EXPECT_EQ(response.body(), R"({"id":12,"name":"Header Name"})"); + } +} + +TEST(TypedRouteCompatibilityTest, LegacyHttpContextHandlerRemainsUnchanged) +{ + fw::HttpRouter router; + router.post("/legacy", [](fw::HttpContext& context) + { + context.set_status(http::status::accepted); + context.set_content_type("text/plain"); + context.set_body("legacy"); + }); + + http::request request(http::verb::post, "/legacy", 11); + http::response response; + auto context = make_context(request, response); + + EXPECT_TRUE(router.dispatch(context)); + EXPECT_EQ(response.result(), http::status::accepted); + EXPECT_EQ(response[http::field::content_type], "text/plain"); + EXPECT_EQ(response.body(), "legacy"); +} + +TEST(TypedExceptionTest, MapsRegisteredExceptionToTypedResponse) +{ + fw::HttpRouter router; + int mapper_calls = 0; + router.map_exception([&mapper_calls](const ValidationError& error) + { + ++mapper_calls; + fw::HttpResult result( + http::status::unprocessable_entity, + ErrorReply{"VALIDATION_FAILED", error.what()}); + return result.header("X-Error-Source", "validation"); + }); + + http::request request; + http::response response; + auto context = make_context(request, response); + router.handle_exception(std::make_exception_ptr(ValidationError("age is invalid")), context); + + EXPECT_EQ(mapper_calls, 1); + EXPECT_EQ(response.result(), http::status::unprocessable_entity); + EXPECT_EQ(response["X-Error-Source"], "validation"); + EXPECT_EQ(response.body(), R"({"code":"VALIDATION_FAILED","message":"age is invalid"})"); +} + +TEST(TypedExceptionTest, SerializesHttpExceptionBodyStatusAndHeaders) +{ + fw::HttpRouter router; + fw::HttpException exception( + http::status::conflict, + ErrorReply{"VERSION_CONFLICT", "resource was updated"}, + "optimistic lock conflict for internal record 99"); + exception.header("Retry-After", "1"); + + http::request request; + http::response response; + auto context = make_context(request, response); + router.handle_exception(std::make_exception_ptr(exception), context); + + EXPECT_EQ(response.result(), http::status::conflict); + EXPECT_EQ(response[http::field::retry_after], "1"); + EXPECT_EQ(response.body(), R"({"code":"VERSION_CONFLICT","message":"resource was updated"})"); + EXPECT_EQ(response.body().find("internal record 99"), std::string::npos); +} + +TEST(TypedExceptionSecurityTest, UnknownStdExceptionDoesNotLeakDetails) +{ + fw::HttpRouter router; + http::request request; + http::response response; + auto context = make_context(request, response); + + router.handle_exception( + std::make_exception_ptr(std::runtime_error("database password=secret at 10.0.0.4")), context); + + EXPECT_EQ(response.result(), http::status::internal_server_error); + EXPECT_EQ(response[http::field::content_type], "application/json"); + EXPECT_EQ(response.body(), R"({"code":"INTERNAL_SERVER_ERROR","message":"Internal server error"})"); + EXPECT_EQ(response.body().find("database"), std::string::npos); + EXPECT_EQ(response.body().find("password"), std::string::npos); + EXPECT_EQ(response.body().find("secret"), std::string::npos); +} + +TEST(TypedExceptionSecurityTest, ExceptionResponseClearsPartiallyWrittenSensitiveState) +{ + fw::HttpRouter router; + http::request request; + http::response response; + auto context = make_context(request, response); + context.set_status(http::status::ok); + context.set_header("X-Internal-Secret", "sensitive"); + context.set_body("partial secret response"); + + router.handle_exception(std::make_exception_ptr(std::runtime_error("failed")), context); + + EXPECT_EQ(response.result(), http::status::internal_server_error); + EXPECT_EQ(response.find("X-Internal-Secret"), response.end()); + EXPECT_EQ(response.body(), R"({"code":"INTERNAL_SERVER_ERROR","message":"Internal server error"})"); +} + +TEST(TypedExceptionSecurityTest, NullExceptionPointerAlsoClearsPartialResponse) +{ + fw::HttpRouter router; + http::request request; + http::response response; + auto context = make_context(request, response); + context.set_header("X-Internal-Secret", "sensitive"); + context.set_body("partial secret response"); + + router.handle_exception(nullptr, context); + + EXPECT_EQ(response.result(), http::status::internal_server_error); + EXPECT_EQ(response.find("X-Internal-Secret"), response.end()); + EXPECT_EQ(response.body(), R"({"code":"INTERNAL_SERVER_ERROR","message":"Internal server error"})"); +} + +TEST(TypedExceptionSecurityTest, MapperFailureFallsBackToSafeInternalServerError) +{ + fw::HttpRouter router; + router.map_exception([](const ValidationError&) -> ErrorReply + { + throw std::runtime_error("mapper secret"); + }); + + http::request request; + http::response response; + auto context = make_context(request, response); + + EXPECT_NO_THROW(router.handle_exception(std::make_exception_ptr(ValidationError("invalid")), context)); + EXPECT_EQ(response.result(), http::status::internal_server_error); + EXPECT_EQ(response.body(), R"({"code":"INTERNAL_SERVER_ERROR","message":"Internal server error"})"); + EXPECT_EQ(response.body().find("mapper secret"), std::string::npos); +} + +TEST(TypedExceptionSecurityTest, HttpExceptionRejectsInjectedHeaders) +{ + fw::HttpException exception( + http::status::bad_request, + ErrorReply{"BAD_REQUEST", "invalid"}); + + EXPECT_THROW(exception.header("X-Test", "safe\r\nInjected: yes"), std::invalid_argument); + EXPECT_THROW(exception.header("Content-Length", "999"), std::invalid_argument); +} From 3783b9028bd5948d98e775e03ce2b5376ec3cf4b Mon Sep 17 00:00:00 2001 From: kekxv Date: Tue, 18 Aug 2026 03:25:44 +0000 Subject: [PATCH 7/9] 0.3.1 --- MODULE.bazel | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/MODULE.bazel b/MODULE.bazel index de61ac6..b1c0bcc 100644 --- a/MODULE.bazel +++ b/MODULE.bazel @@ -4,13 +4,13 @@ module( ) 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") From 7a62ba51edcde2d75863ef42068ac69bf67301de Mon Sep 17 00:00:00 2001 From: kekxv Date: Tue, 18 Aug 2026 05:56:49 +0000 Subject: [PATCH 8/9] feat: add OpenAPI documentation support --- MODULE.bazel | 2 +- README.md | 27 +- doc/advanced.md | 2 + doc/api-reference.md | 40 +- example/BUILD.bazel | 7 + example/HelloController.hpp | 3 +- example/HelloStreamController.hpp | 3 +- example/HelloWsController.hpp | 3 +- example/TypedHelloController.hpp | 4 +- example/export_openapi_test.sh | 8 + example/homepage_docs_link_test.sh | 39 ++ example/main.cpp | 27 +- framework/controller/http_controller.hpp | 8 + framework/router/http_router.cpp | 65 ++- framework/router/http_router.hpp | 100 +++- framework/router/openapi.cpp | 644 ++++++++++++++++++++++- framework/router/openapi_schema.hpp | 1 + framework/tests/openapi_test.cpp | 160 ++++++ 18 files changed, 1118 insertions(+), 25 deletions(-) create mode 100755 example/homepage_docs_link_test.sh diff --git a/MODULE.bazel b/MODULE.bazel index b1c0bcc..c541663 100644 --- a/MODULE.bazel +++ b/MODULE.bazel @@ -1,6 +1,6 @@ module( name = "khttpd", - version = "0.3.1", + version = "0.4.0", ) bazel_dep(name = "platforms", version = "1.1.0") diff --git a/README.md b/README.md index d4fefdd..a6f0531 100644 --- a/README.md +++ b/README.md @@ -273,6 +273,18 @@ 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 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" @@ -282,8 +294,19 @@ khttpd::framework::install_openapi_routes( // GET /openapi.json and GET /docs ``` -The example includes `POST /typed/greetings`, `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: +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` +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 diff --git a/doc/advanced.md b/doc/advanced.md index 0ff7c2d..3660ce8 100644 --- a/doc/advanced.md +++ b/doc/advanced.md @@ -335,6 +335,8 @@ 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 以及打开/写入失败可见,不负责替业务决定允许写入哪些目录。 diff --git a/doc/api-reference.md b/doc/api-reference.md index 820a712..73274e9 100644 --- a/doc/api-reference.md +++ b/doc/api-reference.md @@ -412,11 +412,43 @@ struct RouteDescriptor { boost::beast::http::verb method; std::optional request_schema; std::optional response_schema; + RouteDocumentation documentation; +}; + +struct RouteDocumentation { + std::string summary; + std::string description; }; ``` `HttpRouter::route_descriptors()` 返回不含 handler、正则表达式、拦截器和异常映射器的副本,调用方无法借此修改路由器内部状态。 +### 接口说明 + +可在普通或强类型 `get/post/put/del/options` 注册时追加 `RouteDocumentation`;原有两个参数形式保持不变: + +```cpp +router.post("/messages", handle_message, + {"发送消息", "接收消息并返回投递结果。"}); +``` + +对于已注册的 async 或 stream 路由,可在其后设置说明: + +```cpp +router.document_route("/messages", boost::beast::http::verb::post, + {"发送消息", "接收消息并返回投递结果。"}); +``` + +`summary` 与 `description` 分别写入 OpenAPI operation 的同名字段,并显示在 `/docs`。Controller 可使用 +`KHTTPD_DOCUMENTED_ROUTE` 或 `KHTTPD_DOCUMENTED_TYPED_ROUTE`,参数形式相同: + +```cpp +KHTTPD_DOCUMENTED_TYPED_ROUTE(post, "/greetings", create_greeting, + {"创建问候语", "校验名称并返回新建的问候语。"}); +``` + +调用 `document_route` 时目标路由必须已注册,否则抛出 `std::invalid_argument`。example 覆盖了 lambda 直接传入、Controller 宏和注册后补充说明三种方式。 + ### 生成和导出 ```cpp @@ -439,7 +471,9 @@ struct CreateRequest { std::string name; int age; }; BOOST_DESCRIBE_STRUCT(CreateRequest, (), (name, age)) ``` -仅通过自定义 `tag_invoke` 序列化且没有 Boost.Describe 元数据的类型会退化为 `{ "type": "object" }`,不会猜测字段。`std::optional` 字段不进入 `required`;字符串、布尔、整数、浮点和 `std::vector` 会生成对应 schema。 +仅通过自定义 `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`。 文件输出采用确定性路径/方法顺序并以换行结尾。空路径、无法打开或无法完整写入会抛出异常。API 不替调用方限制目标目录,因此不要把未经授权的网络输入直接作为 `output_path`。 @@ -456,6 +490,10 @@ void install_openapi_routes( 先注册业务路由,再调用该函数。它会安装只读 JSON 与 HTML 入口,并从生成文档中隐藏自身。两个路径必须是互不相同的绝对字面路径,且不能与已有 GET 路由冲突;冲突会抛出 `std::invalid_argument`,不会覆盖业务 handler。路由仍走标准 session、前置/后置 interceptor 和授权流程,不存在单独的越权 dispatch 通道。 +`/docs` 与 `/openapi.json` 都由 framework 实现,example 仅演示如何调用 `install_openapi_routes`。`/docs` 是无外部 CDN 的响应式 HTML 文档页,提供 endpoint 导航、method 色标、参数表、字段化 request/response schema、请求样例、cURL 复制和在线发送。在线请求默认不携带浏览器凭据。原始规范仍可通过 `/openapi.json` 下载。 + +页面会转义所有动态内容,并设置 CSP、`nosniff` 和 `no-referrer` 响应头。交互脚本使用每次安装时生成的 CSP nonce,不需要开放任意 inline script;若控制台仍报告其他内联脚本被拒绝,应检查浏览器扩展或开发者工具注入的脚本。 + 传入 `enabled = false` 时函数不注册 `/openapi.json` 或 `/docs`,可用于按环境、租户或权限策略手动关闭运行时文档;离线 `export_openapi` 不受此开关影响。 --- diff --git a/example/BUILD.bazel b/example/BUILD.bazel index 53380be..fcade9b 100644 --- a/example/BUILD.bazel +++ b/example/BUILD.bazel @@ -28,3 +28,10 @@ sh_test( data = [":app"], tags = ["exclusive"], ) + +sh_test( + name = "homepage_docs_link_test", + srcs = ["homepage_docs_link_test.sh"], + data = [":app"], + tags = ["exclusive"], +) diff --git a/example/HelloController.hpp b/example/HelloController.hpp index 51d3d0d..5a60ae4 100644 --- a/example/HelloController.hpp +++ b/example/HelloController.hpp @@ -19,7 +19,8 @@ class HelloController : public khttpd::framework::BaseController register_routes(khttpd::framework::HttpRouter& router) override { - KHTTPD_ROUTE(get, "/hello", handle_hello); + KHTTPD_DOCUMENTED_ROUTE(get, "/hello", handle_hello, + {"Controller greeting", "Returns a greeting from a controller-managed route."}); return shared_from_this(); } diff --git a/example/HelloStreamController.hpp b/example/HelloStreamController.hpp index 37f405f..5df7c9f 100644 --- a/example/HelloStreamController.hpp +++ b/example/HelloStreamController.hpp @@ -13,7 +13,8 @@ class HelloStreamController : public khttpd::framework::BaseController register_routes(khttpd::framework::HttpRouter& router) override { - KHTTPD_ROUTE(get, "/stream/:size", handle_stream); + KHTTPD_DOCUMENTED_ROUTE(get, "/stream/:size", handle_stream, + {"Stream response chunks", "Streams up to 100 JSON chunks for the requested size."}); return shared_from_this(); } diff --git a/example/HelloWsController.hpp b/example/HelloWsController.hpp index 7db8f6b..ee4d262 100644 --- a/example/HelloWsController.hpp +++ b/example/HelloWsController.hpp @@ -15,7 +15,8 @@ class HelloWsController : public khttpd::framework::BaseController register_routes(khttpd::framework::HttpRouter& router) override { - KHTTPD_ROUTE(get, "/hellows", handle_hello); + KHTTPD_DOCUMENTED_ROUTE(get, "/hellows", handle_hello, + {"WebSocket echo upgrade", "Explains how to connect to the echo WebSocket endpoint."}); return shared_from_this(); } diff --git a/example/TypedHelloController.hpp b/example/TypedHelloController.hpp index 3cd238a..88a7e9d 100644 --- a/example/TypedHelloController.hpp +++ b/example/TypedHelloController.hpp @@ -45,7 +45,9 @@ class TypedHelloController final : public khttpd::framework::BaseController register_routes(khttpd::framework::HttpRouter& router) override { - KHTTPD_TYPED_ROUTE(post, "/greetings", create_greeting); + KHTTPD_DOCUMENTED_TYPED_ROUTE(post, "/greetings", create_greeting, + {"Create a greeting", + "Validates a name and returns a created greeting with its Location header."}); return shared_from_this(); } diff --git a/example/export_openapi_test.sh b/example/export_openapi_test.sh index 8f2ad90..9834c6e 100755 --- a/example/export_openapi_test.sh +++ b/example/export_openapi_test.sh @@ -44,10 +44,18 @@ import sys document = json.loads(pathlib.Path(sys.argv[1]).read_text()) operation = document["paths"]["/typed/greetings"]["post"] +home = document["paths"]["/"]["get"] +hello = document["paths"]["/hello"]["get"] +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 response["properties"]["message"]["type"] == "string" +assert home["summary"] == "Example service home" +assert hello["summary"] == "Greet a visitor" +assert stream["summary"] == "Stream response chunks" +assert operation["summary"] == "Create a greeting" +assert operation["description"] == "Validates a name and returns a created greeting with its Location header." assert "/openapi.json" not in document["paths"] assert "/docs" not in document["paths"] PY diff --git a/example/homepage_docs_link_test.sh b/example/homepage_docs_link_test.sh new file mode 100755 index 0000000..ca4246a --- /dev/null +++ b/example/homepage_docs_link_test.sh @@ -0,0 +1,39 @@ +#!/usr/bin/env bash +set -euo pipefail + +app="${TEST_SRCDIR}/${TEST_WORKSPACE}/example/app" +log="${TEST_TMPDIR}/app.log" + +"${app}" --enable-openapi-docs >"${log}" 2>&1 & +app_pid=$! +trap 'kill "${app_pid}" 2>/dev/null || true; wait "${app_pid}" 2>/dev/null || true' EXIT + +python3 - <<'PY' +import socket +import time + +for _ in range(100): + try: + with socket.create_connection(("127.0.0.1", 8080), timeout=0.1): + break + except OSError: + time.sleep(0.02) +else: + raise SystemExit("example server did not listen on port 8080") + +def request(path): + chunks = [] + with socket.create_connection(("127.0.0.1", 8080), timeout=1) as sock: + sock.sendall(f"GET {path} HTTP/1.1\r\nHost: localhost\r\nConnection: close\r\n\r\n".encode()) + while True: + chunk = sock.recv(4096) + if not chunk: + break + chunks.append(chunk) + return b"".join(chunks).decode("iso-8859-1") + +homepage = request("/") +assert homepage.startswith("HTTP/1.1 200") +assert 'href="/docs"' in homepage, "homepage must link to API documentation" +assert request("/docs").startswith("HTTP/1.1 200") +PY diff --git a/example/main.cpp b/example/main.cpp index fd06062..c292525 100644 --- a/example/main.cpp +++ b/example/main.cpp @@ -43,8 +43,8 @@ namespace ctx.set_status(beast::http::status::ok); ctx.set_content_type("text/html"); ctx.set_body( - R"(

Hello from khttpd!

Try /hello?name=World or /info

Dynamic paths: /users/123, /users/profile, /items/book/id/456, /files/a/b/c.txt

POST examples: /api/json, /api/form, /api/upload

Or connect to WebSocket

Or connect to WebSocket Chat

)"); - }); + R"(

Hello from khttpd!

API documentation

Try /hello?name=World or /info

Dynamic paths: /users/123, /users/profile, /items/book/id/456, /files/a/b/c.txt

POST examples: /api/json, /api/form, /api/upload

Or connect to WebSocket

Or connect to WebSocket Chat

)"); + }, {"Example service home", "Links to the sample HTTP, streaming, WebSocket, and API documentation endpoints."}); http_router.get("/hello", [](khttpd::framework::HttpContext& ctx) { @@ -213,6 +213,29 @@ namespace "

WebSocket Chat Endpoint

This is a WebSocket chat endpoint. Please use a WebSocket client to connect.

"); }); + http_router.document_route("/hello", beast::http::verb::get, + {"Greet a visitor", "Returns a text greeting for the optional name query parameter."}); + http_router.document_route("/info", beast::http::verb::get, + {"Inspect the request", "Shows the method, path, and User-Agent received by the server."}); + http_router.document_route("/api/json", beast::http::verb::post, + {"Echo JSON", "Accepts an application/json body and returns the serialized payload."}); + http_router.document_route("/api/form", beast::http::verb::post, + {"Submit a form", "Reads name and email fields from an URL-encoded form body."}); + http_router.document_route("/api/upload", beast::http::verb::post, + {"Upload multipart data", "Reads a multipart description and optional uploaded file."}); + http_router.document_route("/users/profile", beast::http::verb::get, + {"Read the current profile", "Returns the static profile example before the dynamic user route."}); + http_router.document_route("/users/:id", beast::http::verb::get, + {"Read a user", "Returns a user identifier captured from the path."}); + http_router.document_route("/items/:category/id/:item_id", beast::http::verb::get, + {"Read an item", "Shows multiple path parameters in one route."}); + http_router.document_route("/files/:filepath", beast::http::verb::get, + {"Read a file path", "Demonstrates a final greedy path parameter."}); + http_router.document_route("/ws", beast::http::verb::get, + {"WebSocket echo upgrade", "Returns upgrade guidance for the echo WebSocket endpoint."}); + http_router.document_route("/chat", beast::http::verb::get, + {"WebSocket chat upgrade", "Returns upgrade guidance for the chat WebSocket endpoint."}); + ws_router.add_handler( "/ws", // onopen diff --git a/framework/controller/http_controller.hpp b/framework/controller/http_controller.hpp index 7339d5b..cdb1e5a 100644 --- a/framework/controller/http_controller.hpp +++ b/framework/controller/http_controller.hpp @@ -12,10 +12,18 @@ namespace khttpd::framework #define KHTTPD_ROUTE(VERB, PATH, METHOD_NAME) \ router.VERB(base_path() + PATH, bind_handler(&std::decay_t::METHOD_NAME)) #endif +#ifndef KHTTPD_DOCUMENTED_ROUTE +#define KHTTPD_DOCUMENTED_ROUTE(VERB, PATH, METHOD_NAME, ...) \ +router.VERB(base_path() + PATH, bind_handler(&std::decay_t::METHOD_NAME), __VA_ARGS__) +#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) #endif +#ifndef KHTTPD_DOCUMENTED_TYPED_ROUTE +#define KHTTPD_DOCUMENTED_TYPED_ROUTE(VERB, PATH, METHOD_NAME, ...) \ +router.VERB(base_path() + PATH, this->shared_from_this(), &std::decay_t::METHOD_NAME, __VA_ARGS__) +#endif #ifndef KHTTPD_WSROUTE #define KHTTPD_WSROUTE_NULL_HANDLER nullptr diff --git a/framework/router/http_router.cpp b/framework/router/http_router.cpp index 2274836..a88f5f9 100644 --- a/framework/router/http_router.cpp +++ b/framework/router/http_router.cpp @@ -4,6 +4,7 @@ #include #include #include +#include namespace khttpd::framework { @@ -123,10 +124,12 @@ namespace khttpd::framework HttpHandler handler, std::optional request_schema, std::optional response_schema, - const bool documented) + const bool documented, + RouteDocumentation documentation) { if (documented) - record_route_descriptor(path_pattern, method, std::move(request_schema), std::move(response_schema)); + record_route_descriptor(path_pattern, method, std::move(request_schema), std::move(response_schema), + std::move(documentation)); else route_descriptors_.erase(std::remove_if(route_descriptors_.begin(), route_descriptors_.end(), [&](const RouteDescriptor& descriptor) @@ -162,16 +165,19 @@ namespace khttpd::framework void HttpRouter::add_typed_route(const std::string& path_pattern, const boost::beast::http::verb method, - detail::TypedRouteHandler handler) + detail::TypedRouteHandler handler, + RouteDocumentation documentation) { add_route(path_pattern, method, std::move(handler.handler), - std::move(handler.request_schema), std::move(handler.response_schema)); + std::move(handler.request_schema), std::move(handler.response_schema), true, + std::move(documentation)); } void HttpRouter::record_route_descriptor(const std::string& path, const boost::beast::http::verb method, std::optional request_schema, - std::optional response_schema) + std::optional response_schema, + RouteDocumentation documentation) { for (auto& descriptor : route_descriptors_) { @@ -179,10 +185,13 @@ namespace khttpd::framework { descriptor.request_schema = std::move(request_schema); descriptor.response_schema = std::move(response_schema); + if (!documentation.summary.empty() || !documentation.description.empty()) + descriptor.documentation = std::move(documentation); return; } } - route_descriptors_.push_back({path, method, std::move(request_schema), std::move(response_schema)}); + route_descriptors_.push_back( + {path, method, std::move(request_schema), std::move(response_schema), std::move(documentation)}); } std::vector HttpRouter::route_descriptors() const @@ -190,31 +199,75 @@ namespace khttpd::framework return route_descriptors_; } + void HttpRouter::document_route(const std::string& path, const boost::beast::http::verb method, + RouteDocumentation documentation) + { + for (auto& descriptor : route_descriptors_) + { + if (descriptor.path == path && descriptor.method == method) + { + descriptor.documentation = std::move(documentation); + return; + } + } + throw std::invalid_argument("Cannot document an unregistered route: " + path); + } + void HttpRouter::get(const std::string& path, HttpHandler handler) { add_route(path, boost::beast::http::verb::get, std::move(handler)); } + void HttpRouter::get(const std::string& path, HttpHandler handler, RouteDocumentation documentation) + { + add_route(path, boost::beast::http::verb::get, std::move(handler), std::nullopt, std::nullopt, true, + std::move(documentation)); + } + void HttpRouter::post(const std::string& path, HttpHandler handler) { add_route(path, boost::beast::http::verb::post, std::move(handler)); } + void HttpRouter::post(const std::string& path, HttpHandler handler, RouteDocumentation documentation) + { + add_route(path, boost::beast::http::verb::post, std::move(handler), std::nullopt, std::nullopt, true, + std::move(documentation)); + } + void HttpRouter::put(const std::string& path, HttpHandler handler) { add_route(path, boost::beast::http::verb::put, std::move(handler)); } + void HttpRouter::put(const std::string& path, HttpHandler handler, RouteDocumentation documentation) + { + add_route(path, boost::beast::http::verb::put, std::move(handler), std::nullopt, std::nullopt, true, + std::move(documentation)); + } + void HttpRouter::del(const std::string& path, HttpHandler handler) { add_route(path, boost::beast::http::verb::delete_, std::move(handler)); } + void HttpRouter::del(const std::string& path, HttpHandler handler, RouteDocumentation documentation) + { + add_route(path, boost::beast::http::verb::delete_, std::move(handler), std::nullopt, std::nullopt, true, + std::move(documentation)); + } + void HttpRouter::options(const std::string& path, HttpHandler handler) { add_route(path, boost::beast::http::verb::options, std::move(handler)); } + void HttpRouter::options(const std::string& path, HttpHandler handler, RouteDocumentation documentation) + { + add_route(path, boost::beast::http::verb::options, std::move(handler), std::nullopt, std::nullopt, true, + std::move(documentation)); + } + void HttpRouter::stream(const std::string& path_pattern, const boost::beast::http::verb method, HttpStreamHandler handler) { diff --git a/framework/router/http_router.hpp b/framework/router/http_router.hpp index 29eb91b..db1c3a4 100644 --- a/framework/router/http_router.hpp +++ b/framework/router/http_router.hpp @@ -32,12 +32,19 @@ namespace khttpd::framework std::shared_ptr, HttpStreamComplete)>; using UnknownExceptionHandler = std::function; + struct RouteDocumentation + { + std::string summary; + std::string description; + }; + struct RouteDescriptor { std::string path; boost::beast::http::verb method; std::optional request_schema; std::optional response_schema; + RouteDocumentation documentation; }; // 路由条目结构 @@ -77,6 +84,12 @@ namespace khttpd::framework void del(const std::string& path, HttpHandler handler); void options(const std::string& path, HttpHandler handler); + void get(const std::string& path, HttpHandler handler, RouteDocumentation documentation); + void post(const std::string& path, HttpHandler handler, RouteDocumentation documentation); + void put(const std::string& path, HttpHandler handler, RouteDocumentation documentation); + void del(const std::string& path, HttpHandler handler, RouteDocumentation documentation); + void options(const std::string& path, HttpHandler handler, RouteDocumentation documentation); + template , int> = 0> void get(const std::string& path, Handler&& handler) { @@ -112,6 +125,41 @@ namespace khttpd::framework detail::make_typed_handler(std::forward(handler))); } + template , int> = 0> + void get(const std::string& path, Handler&& handler, RouteDocumentation documentation) + { + add_typed_route(path, boost::beast::http::verb::get, + detail::make_typed_handler(std::forward(handler)), std::move(documentation)); + } + + template , int> = 0> + void post(const std::string& path, Handler&& handler, RouteDocumentation documentation) + { + add_typed_route(path, boost::beast::http::verb::post, + detail::make_typed_handler(std::forward(handler)), std::move(documentation)); + } + + template , int> = 0> + void put(const std::string& path, Handler&& handler, RouteDocumentation documentation) + { + add_typed_route(path, boost::beast::http::verb::put, + detail::make_typed_handler(std::forward(handler)), std::move(documentation)); + } + + template , int> = 0> + void del(const std::string& path, Handler&& handler, RouteDocumentation documentation) + { + add_typed_route(path, boost::beast::http::verb::delete_, + detail::make_typed_handler(std::forward(handler)), std::move(documentation)); + } + + template , int> = 0> + void options(const std::string& path, Handler&& handler, RouteDocumentation documentation) + { + add_typed_route(path, boost::beast::http::verb::options, + detail::make_typed_handler(std::forward(handler)), std::move(documentation)); + } + template void get(const std::string& path, std::shared_ptr controller, Method method) { @@ -146,6 +194,46 @@ namespace khttpd::framework add_typed_route(path, boost::beast::http::verb::options, detail::make_typed_member_handler(std::move(controller), method)); } + + template + void get(const std::string& path, std::shared_ptr controller, Method method, + RouteDocumentation documentation) + { + add_typed_route(path, boost::beast::http::verb::get, + detail::make_typed_member_handler(std::move(controller), method), std::move(documentation)); + } + + template + void post(const std::string& path, std::shared_ptr controller, Method method, + RouteDocumentation documentation) + { + add_typed_route(path, boost::beast::http::verb::post, + detail::make_typed_member_handler(std::move(controller), method), std::move(documentation)); + } + + template + void put(const std::string& path, std::shared_ptr controller, Method method, + RouteDocumentation documentation) + { + add_typed_route(path, boost::beast::http::verb::put, + detail::make_typed_member_handler(std::move(controller), method), std::move(documentation)); + } + + template + void del(const std::string& path, std::shared_ptr controller, Method method, + RouteDocumentation documentation) + { + add_typed_route(path, boost::beast::http::verb::delete_, + detail::make_typed_member_handler(std::move(controller), method), std::move(documentation)); + } + + template + void options(const std::string& path, std::shared_ptr controller, Method method, + RouteDocumentation documentation) + { + add_typed_route(path, boost::beast::http::verb::options, + detail::make_typed_member_handler(std::move(controller), method), std::move(documentation)); + } // Async handlers must invoke complete exactly once, from any thread. void async_route(const std::string& path, boost::beast::http::verb method, HttpAsyncHandler handler); void stream(const std::string& path, boost::beast::http::verb method, HttpStreamHandler handler); @@ -183,6 +271,10 @@ namespace khttpd::framework // Returns handler-free copies suitable for documentation and inspection. std::vector route_descriptors() const; + // Adds or replaces OpenAPI summary and description for a registered route. + void document_route(const std::string& path, boost::beast::http::verb method, + RouteDocumentation documentation); + private: friend void install_openapi_routes(HttpRouter& router, const OpenApiInfo& info, const std::string& spec_path, const std::string& docs_path, bool enabled); @@ -197,12 +289,14 @@ namespace khttpd::framework void add_route(const std::string& path_pattern, boost::beast::http::verb method, HttpHandler handler, std::optional request_schema = std::nullopt, std::optional response_schema = std::nullopt, - bool documented = true); + bool documented = true, + RouteDocumentation documentation = {}); void add_typed_route(const std::string& path_pattern, boost::beast::http::verb method, - detail::TypedRouteHandler handler); + 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); + std::optional response_schema = std::nullopt, + RouteDocumentation documentation = {}); 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 c6d62b3..262c78c 100644 --- a/framework/router/openapi.cpp +++ b/framework/router/openapi.cpp @@ -7,6 +7,7 @@ #include #include #include +#include #include #include #include @@ -55,6 +56,10 @@ namespace khttpd::framework const std::vector& path_parameters) { boost::json::object operation; + if (!descriptor.documentation.summary.empty()) + operation.emplace("summary", descriptor.documentation.summary); + if (!descriptor.documentation.description.empty()) + operation.emplace("description", descriptor.documentation.description); if (!path_parameters.empty()) { boost::json::array parameters; @@ -129,6 +134,627 @@ namespace khttpd::framework } return escaped; } + + std::string uppercase(const std::string& input) + { + std::string result(input); + std::transform(result.begin(), result.end(), result.begin(), [](const unsigned char character) + { + return static_cast(std::toupper(character)); + }); + return result; + } + + std::string shell_single_quote(const std::string& input) + { + std::string result = "'"; + for (const char character : input) + { + if (character == '\'') result += "'\\''"; + else result += character; + } + return result + "'"; + } + + std::string operation_anchor(const std::string& method, const std::string& path) + { + std::string result(method); + bool last_was_separator = false; + for (const unsigned char character : path) + { + if (std::isalnum(character) != 0) + { + result += static_cast(std::tolower(character)); + last_was_separator = false; + } + else if (!last_was_separator) + { + result += '-'; + last_was_separator = true; + } + } + while (!result.empty() && result.back() == '-') result.pop_back(); + return result; + } + + std::string method_class(const std::string& method) + { + if (method == "get" || method == "post" || method == "put" || method == "patch" || + method == "delete" || method == "options" || method == "head") + return method; + return "other"; + } + + bool schema_property_is_required(const boost::json::object& schema, + const boost::json::string_view property_name) + { + const auto* required = schema.if_contains("required"); + if (required == nullptr || !required->is_array()) return false; + return std::any_of(required->as_array().begin(), required->as_array().end(), + [property_name](const boost::json::value& value) + { + return value.is_string() && value.as_string() == property_name; + }); + } + + std::string schema_type_name(const boost::json::object& schema) + { + const auto* type = schema.if_contains("type"); + if (type != nullptr && type->is_string()) return std::string(type->as_string()); + if (schema.contains("properties")) return "object"; + if (schema.contains("items")) return "array"; + return "value"; + } + + std::string render_schema_view(const boost::json::value& schema_value, + const bool include_raw = true, + const std::size_t depth = 0) + { + if (!schema_value.is_object() || depth > 8) + return "
" +
+          escape_html(boost::json::serialize(schema_value)) + "
"; + + const auto& schema = schema_value.as_object(); + const auto type = schema_type_name(schema); + std::string result = "
" + "" + escape_html(type) + ""; + const auto* format = schema.if_contains("format"); + if (format != nullptr && format->is_string()) + result += "" + escape_html(std::string(format->as_string())) + ""; + result += "
"; + + const auto* properties = schema.if_contains("properties"); + if (type == "object" && properties != nullptr && properties->is_object()) + { + if (properties->as_object().empty()) + { + result += "

Object fields are not reflected.

"; + } + else + { + result += "
"; + for (const auto& property : properties->as_object()) + { + const auto required = schema_property_is_required(schema, property.key()); + result += "
" + + escape_html(std::string(property.key())) + ""; + if (property.value().is_object()) + result += "" + + escape_html(schema_type_name(property.value().as_object())) + ""; + if (required) result += "required"; + result += "
"; + if (property.value().is_object()) + { + const auto& property_schema = property.value().as_object(); + const auto* description = property_schema.if_contains("description"); + if (description != nullptr && description->is_string()) + result += "

" + + escape_html(std::string(description->as_string())) + "

"; + if (property_schema.contains("properties")) + result += render_schema_view(property.value(), false, depth + 1); + const auto* items = property_schema.if_contains("items"); + if (items != nullptr) + result += "
Items" + + render_schema_view(*items, false, depth + 1) + "
"; + } + result += "
"; + } + result += "
"; + } + } + else + { + const auto* items = schema.if_contains("items"); + if (type == "array" && items != nullptr) + result += "
Items" + + render_schema_view(*items, false, depth + 1) + "
"; + } + + if (include_raw) + result += "
JSON Schema
" +
+          escape_html(boost::json::serialize(schema_value)) + "
"; + return result + "
"; + } + + boost::json::value schema_example(const boost::json::value& schema_value, + const std::size_t depth = 0) + { + if (!schema_value.is_object() || depth > 8) return nullptr; + const auto& schema = schema_value.as_object(); + const auto* example = schema.if_contains("example"); + if (example != nullptr) return *example; + const auto* default_value = schema.if_contains("default"); + if (default_value != nullptr) return *default_value; + const auto* enum_values = schema.if_contains("enum"); + if (enum_values != nullptr && enum_values->is_array() && !enum_values->as_array().empty()) + return enum_values->as_array().front(); + + const auto type = schema_type_name(schema); + if (type == "object") + { + boost::json::object result; + const auto* properties = schema.if_contains("properties"); + if (properties != nullptr && properties->is_object()) + for (const auto& property : properties->as_object()) + result.emplace(property.key(), schema_example(property.value(), depth + 1)); + return result; + } + if (type == "array") return boost::json::array{}; + if (type == "string") return ""; + if (type == "integer" || type == "number") return 0; + if (type == "boolean") return false; + return nullptr; + } + + const boost::json::value* json_request_schema(const boost::json::object& operation) + { + const auto* request_body = operation.if_contains("requestBody"); + if (request_body == nullptr || !request_body->is_object()) return nullptr; + const auto* content = request_body->as_object().if_contains("content"); + if (content == nullptr || !content->is_object()) return nullptr; + const auto* media_type = content->as_object().if_contains("application/json"); + if (media_type == nullptr || !media_type->is_object()) return nullptr; + return media_type->as_object().if_contains("schema"); + } + + std::string render_parameters(const boost::json::object& operation) + { + std::string result = "

Parameters

"; + const auto* parameters = operation.if_contains("parameters"); + if (parameters == nullptr || !parameters->is_array() || parameters->as_array().empty()) + return result + "

No parameters.

"; + + result += "
" + ""; + for (const auto& parameter_value : parameters->as_array()) + { + if (!parameter_value.is_object()) continue; + const auto& parameter = parameter_value.as_object(); + const auto* name = parameter.if_contains("name"); + const auto* location = parameter.if_contains("in"); + const auto* required = parameter.if_contains("required"); + const auto* schema = parameter.if_contains("schema"); + result += ""; + } + return result + "
NameLocationRequiredSchema
" + + escape_html(name != nullptr && name->is_string() ? std::string(name->as_string()) : "") + + "" + + escape_html(location != nullptr && location->is_string() ? + std::string(location->as_string()) : "") + "" + + (required != nullptr && required->is_bool() && required->as_bool() ? "Yes" : "No") + + "" + + (schema != nullptr ? "" + escape_html(boost::json::serialize(*schema)) + "" : "—") + + "
"; + } + + std::string render_request_body(const boost::json::object& operation) + { + std::string result = "

Request body

"; + const auto* request_body_value = operation.if_contains("requestBody"); + if (request_body_value == nullptr || !request_body_value->is_object()) + return result + "

No request body.

"; + + const auto& request_body = request_body_value->as_object(); + const auto* required = request_body.if_contains("required"); + result += "

"; + result += required != nullptr && required->is_bool() && required->as_bool() ? "Required" : "Optional"; + result += "

"; + const auto* content_value = request_body.if_contains("content"); + if (content_value != nullptr && content_value->is_object()) + { + for (const auto& media_type : content_value->as_object()) + { + result += "
" + + escape_html(std::string(media_type.key())) + "
"; + if (media_type.value().is_object()) + { + const auto* schema = media_type.value().as_object().if_contains("schema"); + if (schema != nullptr) result += render_schema_view(*schema); + } + } + } + return result + ""; + } + + std::string render_responses(const boost::json::object& operation) + { + std::string result = "

Responses

"; + const auto* responses_value = operation.if_contains("responses"); + if (responses_value == nullptr || !responses_value->is_object() || + responses_value->as_object().empty()) + return result + "

No responses documented.

"; + + for (const auto& response_entry : responses_value->as_object()) + { + result += "
" + + escape_html(std::string(response_entry.key())) + ""; + if (response_entry.value().is_object()) + { + const auto& response = response_entry.value().as_object(); + const auto* description = response.if_contains("description"); + if (description != nullptr && description->is_string()) + result += "" + escape_html(std::string(description->as_string())) + ""; + result += "
"; + const auto* content_value = response.if_contains("content"); + if (content_value != nullptr && content_value->is_object()) + { + for (const auto& media_type : content_value->as_object()) + { + result += "
" + + escape_html(std::string(media_type.key())) + "
"; + if (media_type.value().is_object()) + { + const auto* schema = media_type.value().as_object().if_contains("schema"); + if (schema != nullptr) result += render_schema_view(*schema); + } + } + } + } + else + { + result += "
"; + } + result += ""; + } + return result + ""; + } + + std::string render_operation_description(const boost::json::object& operation) + { + std::string result; + const auto* summary = operation.if_contains("summary"); + if (summary != nullptr && summary->is_string()) + result += "

" + + escape_html(std::string(summary->as_string())) + "

"; + const auto* description = operation.if_contains("description"); + if (description != nullptr && description->is_string()) + result += "

" + + escape_html(std::string(description->as_string())) + "

"; + return result; + } + + std::string render_try_panel(const boost::json::object& operation, + const std::string& method, + const std::string& path) + { + const auto upper_method = uppercase(method); + const auto* request_schema = json_request_schema(operation); + const std::string request_body = request_schema != nullptr ? + boost::json::serialize(schema_example(*request_schema)) : ""; + std::string curl_command = "curl -i -X " + upper_method + " " + shell_single_quote(path); + if (!request_body.empty() && upper_method != "GET" && upper_method != "HEAD") + curl_command += " \\\n -H " + shell_single_quote("Content-Type: application/json") + + " \\\n --data-binary " + shell_single_quote(request_body); + + std::string result = "
"; + result += "Try request
" + "

Requests are sent to this server without browser credentials.

"; + + const auto* parameters = operation.if_contains("parameters"); + if (parameters != nullptr && parameters->is_array()) + { + for (const auto& parameter_value : parameters->as_array()) + { + if (!parameter_value.is_object()) continue; + const auto& parameter = parameter_value.as_object(); + const auto* location = parameter.if_contains("in"); + const auto* name = parameter.if_contains("name"); + if (location == nullptr || !location->is_string() || location->as_string() != "path" || + name == nullptr || !name->is_string()) continue; + const std::string parameter_name(name->as_string()); + result += ""; + } + } + + if (request_schema != nullptr) + result += ""; + + result += "
" +
+        escape_html(curl_command) + "
"; + return result; + } + + std::string documentation_script() + { + return R"SCRIPT((() => { + const shellQuote = value => "'" + value.replace(/'/g, "'\\''") + "'"; + + function requestFor(panel, requireParameters) { + let path = panel.dataset.path; + let missingParameter = ""; + panel.querySelectorAll("[data-path-param]").forEach(input => { + const marker = "{" + input.dataset.pathParam + "}"; + if (input.value.length === 0) { + if (requireParameters) missingParameter = input.dataset.pathParam; + } else { + path = path.replace(marker, encodeURIComponent(input.value)); + } + }); + if (missingParameter) throw new Error("Enter path parameter: " + missingParameter); + + const method = panel.dataset.method; + const url = window.location.origin + (path.startsWith("/") ? path : "/" + path); + const bodyInput = panel.querySelector("[data-request-body]"); + const body = bodyInput ? bodyInput.value.trim() : ""; + let command = "curl -i -X " + method + " " + shellQuote(url); + if (body && method !== "GET" && method !== "HEAD") { + command += " \\\n -H " + shellQuote("Content-Type: application/json") + + " \\\n --data-binary " + shellQuote(body); + } + panel.querySelector("[data-curl-output]").textContent = command; + return {method, url, body, command}; + } + + function feedback(panel, message, error = false) { + const output = panel.querySelector("[data-feedback]"); + output.textContent = message; + output.classList.toggle("error", error); + } + + async function copyText(value) { + if (navigator.clipboard && window.isSecureContext) { + await navigator.clipboard.writeText(value); + return; + } + const temporary = document.createElement("textarea"); + temporary.value = value; + temporary.setAttribute("readonly", ""); + document.body.appendChild(temporary); + temporary.select(); + const copied = document.execCommand("copy"); + temporary.remove(); + if (!copied) throw new Error("Clipboard is unavailable"); + } + + document.querySelectorAll("[data-try-panel]").forEach(panel => { + requestFor(panel, false); + panel.addEventListener("input", () => { + try { + requestFor(panel, false); + feedback(panel, ""); + } catch (error) { + feedback(panel, error.message, true); + } + }); + }); + + document.addEventListener("click", async event => { + const button = event.target.closest("[data-action]"); + if (!button) return; + const panel = button.closest("[data-try-panel]"); + if (!panel) return; + + try { + const request = requestFor(panel, button.dataset.action === "send-request"); + if (button.dataset.action === "copy-curl") { + await copyText(request.command); + feedback(panel, "cURL copied"); + return; + } + + if (request.body) JSON.parse(request.body); + button.disabled = true; + feedback(panel, "Sending…"); + const options = { + method: request.method, + credentials: "omit", + cache: "no-store", + redirect: "manual", + headers: {Accept: "application/json"} + }; + if (request.body && request.method !== "GET" && request.method !== "HEAD") { + options.headers["Content-Type"] = "application/json"; + options.body = request.body; + } + const response = await fetch(request.url, options); + const responseBody = await response.text(); + const responsePanel = panel.querySelector("[data-response]"); + responsePanel.hidden = false; + panel.querySelector("[data-response-status]").textContent = + response.status + " " + response.statusText; + panel.querySelector("[data-response-headers]").textContent = + Array.from(response.headers.entries()).map(entry => entry[0] + ": " + entry[1]).join("\n"); + panel.querySelector("[data-response-body]").textContent = responseBody; + feedback(panel, "Request completed"); + } catch (error) { + feedback(panel, error instanceof SyntaxError ? "Request body is not valid JSON" : error.message, true); + } finally { + button.disabled = false; + } + }); +})();)SCRIPT"; + } + + std::string make_csp_nonce() + { + static constexpr char hexadecimal[] = "0123456789abcdef"; + std::random_device source; + std::string nonce; + nonce.reserve(32); + for (std::size_t index = 0; index < 16; ++index) + { + const auto value = source(); + nonce += hexadecimal[(value >> 4U) & 0x0fU]; + nonce += hexadecimal[value & 0x0fU]; + } + return nonce; + } + + std::string make_documentation_page(const boost::json::object& document, + const OpenApiInfo& info, + const std::string& spec_path, + const std::string& script_nonce) + { + const auto& paths = document.at("paths").as_object(); + struct OperationView + { + std::string path; + std::string method; + std::string anchor; + const boost::json::object* operation; + }; + std::vector operations; + std::map anchors; + for (const auto& path : paths) + { + for (const auto& operation : path.value().as_object()) + { + const std::string method(operation.key()); + std::string anchor = operation_anchor(method, std::string(path.key())); + const auto occurrence = ++anchors[anchor]; + if (occurrence > 1) anchor += "-" + std::to_string(occurrence); + operations.push_back({std::string(path.key()), method, std::move(anchor), + &operation.value().as_object()}); + } + } + + std::string page = + "" + "" + escape_html(info.title) + " · API documentation

" + "OpenAPI 3.1

" + escape_html(info.title) + "

Version " + + escape_html(info.version) + "

View raw specification  ↗

API reference

" + "

Select an endpoint to inspect its contract and schemas.

"; + + for (const auto& operation : operations) + { + const auto css_class = method_class(operation.method); + page += "
" + + escape_html(uppercase(operation.method)) + "

" + escape_html(operation.path) + + "

" + + render_operation_description(*operation.operation) + render_parameters(*operation.operation) + + render_request_body(*operation.operation) + render_responses(*operation.operation) + + render_try_panel(*operation.operation, operation.method, operation.path) + + "
Raw operation JSON
" +
+          escape_html(boost::json::serialize(*operation.operation)) +
+          "
"; + } + page += "
"; + return page; + } } boost::json::object generate_openapi(const HttpRouter& router, const OpenApiInfo& info) @@ -178,25 +804,31 @@ namespace khttpd::framework descriptor.path); } - auto serialized_document = std::make_shared( - boost::json::serialize(generate_openapi(router, info))); + const auto document = generate_openapi(router, info); + auto serialized_document = std::make_shared(boost::json::serialize(document)); router.add_route(spec_path, boost::beast::http::verb::get, [serialized_document](HttpContext& context) { context.set_status(boost::beast::http::status::ok); context.set_content_type("application/json"); + context.set_header("X-Content-Type-Options", "nosniff"); context.set_body(*serialized_document); }, std::nullopt, std::nullopt, false); + auto script_nonce = std::make_shared(make_csp_nonce()); auto page = std::make_shared( - "khttpd API documentation" - "

khttpd API documentation

OpenAPI 3.1 JSON

"); + make_documentation_page(document, info, spec_path, *script_nonce)); router.add_route(docs_path, boost::beast::http::verb::get, - [page](HttpContext& context) + [page, script_nonce](HttpContext& context) { context.set_status(boost::beast::http::status::ok); context.set_content_type("text/html"); + context.set_header("X-Content-Type-Options", "nosniff"); + context.set_header("Content-Security-Policy", + "default-src 'none'; style-src 'unsafe-inline'; base-uri 'none'; " + "script-src 'nonce-" + *script_nonce + "'; connect-src 'self'; " + "form-action 'none'; frame-ancestors 'none'"); + context.set_header("Referrer-Policy", "no-referrer"); context.set_body(*page); }, std::nullopt, std::nullopt, false); } diff --git a/framework/router/openapi_schema.hpp b/framework/router/openapi_schema.hpp index 1e0c625..17ba0ab 100644 --- a/framework/router/openapi_schema.hpp +++ b/framework/router/openapi_schema.hpp @@ -84,6 +84,7 @@ namespace khttpd::framework::detail // C++17 cannot reflect arbitrary tag_invoke converters. They remain valid typed // routes, but their generated schema is intentionally conservative. schema.emplace("type", "object"); + schema.emplace("properties", boost::json::object{}); } return schema; diff --git a/framework/tests/openapi_test.cpp b/framework/tests/openapi_test.cpp index c0bf525..9b5ccc5 100644 --- a/framework/tests/openapi_test.cpp +++ b/framework/tests/openapi_test.cpp @@ -21,6 +21,7 @@ #include #include #include +#include #include #include #include @@ -42,8 +43,37 @@ struct OpenApiPetResponse std::vector tags; }; +struct OpenApiOpaquePayload +{ + int value; +}; + +struct OpenApiMessageRequest +{ + std::string message; +}; + +struct OpenApiMessageResponse +{ + std::string message; +}; + +OpenApiOpaquePayload tag_invoke(boost::json::value_to_tag, + const boost::json::value& value) +{ + return {boost::json::value_to(value.as_object().at("value"))}; +} + +void tag_invoke(boost::json::value_from_tag, boost::json::value& value, + const OpenApiOpaquePayload& payload) +{ + value = {{"value", payload.value}}; +} + BOOST_DESCRIBE_STRUCT(OpenApiCreatePetRequest, (), (name, age, nickname)) BOOST_DESCRIBE_STRUCT(OpenApiPetResponse, (), (id, name, tags)) +BOOST_DESCRIBE_STRUCT(OpenApiMessageRequest, (), (message)) +BOOST_DESCRIBE_STRUCT(OpenApiMessageResponse, (), (message)) #if defined(__clang__) #pragma clang diagnostic pop @@ -131,6 +161,24 @@ TEST(OpenApiTest, IncludesTypedDescribeSchemas) EXPECT_EQ(tags.at("items").as_object().at("type"), "string"); } +TEST(OpenApiTest, EmitsPropertiesForUnreflectedObjectSchemas) +{ + fw::HttpRouter router; + router.post("/opaque", [](const OpenApiOpaquePayload& request) { return request; }); + + const auto document = fw::generate_openapi(router); + const auto& operation = operation_at(document, "/opaque", "post"); + const auto& request_schema = operation.at("requestBody").as_object() + .at("content").as_object().at("application/json").as_object().at("schema").as_object(); + const auto& response_schema = operation.at("responses").as_object().at("200").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().empty()); + EXPECT_EQ(response_schema.at("type"), "object"); + EXPECT_TRUE(response_schema.at("properties").as_object().empty()); +} + TEST(OpenApiTest, DocumentsAsyncAndStreamSkeletons) { fw::HttpRouter router; @@ -166,6 +214,39 @@ TEST(OpenApiTest, ProducesDeterministicOutput) EXPECT_LT(first.find("\"get\""), first.find("\"post\"")); } +TEST(OpenApiTest, IncludesRouteSummaryAndDescription) +{ + fw::HttpRouter router; + router.post("/messages", [](fw::HttpContext&) {}); + router.document_route("/messages", http::verb::post, + {"Send a message", "Accepts a message and returns its delivery result."}); + + const auto document = fw::generate_openapi(router); + const auto& operation = operation_at(document, "/messages", "post"); + EXPECT_EQ(operation.at("summary"), "Send a message"); + EXPECT_EQ(operation.at("description"), "Accepts a message and returns its delivery result."); + + fw::install_openapi_routes(router); + http::request request(http::verb::get, "/docs", 11); + http::response response; + fw::HttpContext context(request, response); + ASSERT_TRUE(router.dispatch(context)); + EXPECT_NE(response.body().find("Send a message"), std::string::npos); + EXPECT_NE(response.body().find("Accepts a message and returns its delivery result."), std::string::npos); +} + +TEST(OpenApiTest, DocumentsRouteAtRegistration) +{ + fw::HttpRouter router; + router.get("/status", [](fw::HttpContext&) {}, + {"Service status", "Returns the current service status."}); + + const auto document = fw::generate_openapi(router); + const auto& operation = operation_at(document, "/status", "get"); + EXPECT_EQ(operation.at("summary"), "Service status"); + EXPECT_EQ(operation.at("description"), "Returns the current service status."); +} + TEST(OpenApiTest, ServesHiddenRuntimeDocument) { fw::HttpRouter router; @@ -193,7 +274,65 @@ TEST(OpenApiTest, ServesHiddenRuntimeDocument) EXPECT_TRUE(router.dispatch(docs_context)); EXPECT_EQ(docs_response.result(), http::status::ok); EXPECT_EQ(docs_response[http::field::content_type], "text/html"); + EXPECT_EQ(docs_response["X-Content-Type-Options"], "nosniff"); + EXPECT_NE(docs_response["Content-Security-Policy"].find("default-src 'none'"), + boost::beast::string_view::npos); EXPECT_NE(docs_response.body().find("/openapi.json"), std::string::npos); + EXPECT_NE(docs_response.body().find("/health"), std::string::npos); + EXPECT_NE(docs_response.body().find(" request(http::verb::get, "/docs", 11); + http::response response; + fw::HttpContext context(request, response); + ASSERT_TRUE(router.dispatch(context)); + + const auto& body = response.body(); + EXPECT_NE(body.find("message"), std::string::npos); + EXPECT_NE(body.find("required"), std::string::npos); + EXPECT_NE(body.find("data-path=\"/messages/{channel}\""), std::string::npos); + EXPECT_NE(body.find(">Send request"), std::string::npos); + EXPECT_NE(body.find(">Copy cURL"), std::string::npos); + EXPECT_NE(body.find("curl -i -X POST '/messages/{channel}'"), std::string::npos); + EXPECT_NE(body.find("--data-binary '{"message":""}'"), + std::string::npos); + + const std::string policy(response["Content-Security-Policy"]); + std::smatch nonce_match; + ASSERT_TRUE(std::regex_search(policy, nonce_match, + std::regex("script-src 'nonce-([a-f0-9]{32})'"))); + EXPECT_EQ(policy.find("script-src 'unsafe-inline'"), std::string::npos); + EXPECT_NE(body.find("", "<2>"}); + + http::request request(http::verb::get, "/docs", 11); + http::response response; + fw::HttpContext context(request, response); + ASSERT_TRUE(router.dispatch(context)); + + EXPECT_EQ(response.body().find("