diff --git a/MODULE.bazel b/MODULE.bazel index fb9e526..59c36f5 100644 --- a/MODULE.bazel +++ b/MODULE.bazel @@ -1,6 +1,6 @@ module( name = "khttpd", - version = "0.4.2", + version = "0.4.3", ) bazel_dep(name = "platforms", version = "1.1.0") diff --git a/README.md b/README.md index 7c9ca51..da7f877 100644 --- a/README.md +++ b/README.md @@ -267,6 +267,19 @@ Typed request bodies require `application/json` or an `application/*+json` media response without invoking the handler. Typed routes register as ordinary buffered routes, so interceptors and authorization checks run before them exactly as they do for legacy routes. +Invalid media types, malformed JSON, and DTO conversion failures throw `TypedRequestValidationError` through the router's +exception pipeline. Without a mapper they retain the default `400 INVALID_REQUEST_BODY` response; applications that use a +shared error envelope can map them once for every typed route: + +```cpp +router.map_exception( + [](const auto& error) { + return khttpd::framework::HttpResult( + boost::beast::http::status::bad_request, + {"INVALID_REQUEST", error.what()}); + }); +``` + ### OpenAPI 3.1 documentation Route registration also records handler-free documentation metadata. Legacy routes contribute their method, path, and path diff --git a/doc/api-reference.md b/doc/api-reference.md index 2db9d23..a64ed02 100644 --- a/doc/api-reference.md +++ b/doc/api-reference.md @@ -182,7 +182,18 @@ 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。 +或 `application/*+json`;媒体类型或 JSON/DTO 转换失败时不会调用业务 handler,而是抛出 +`TypedRequestValidationError` 并进入 `HttpRouter` 的异常映射管线。未映射时保持默认 HTTP 400 +`INVALID_REQUEST_BODY` 响应;服务若需统一错误 envelope,可注册一次映射: + +```cpp +router.map_exception( + [](const auto& error) { + return khttpd::framework::HttpResult( + boost::beast::http::status::bad_request, + {"INVALID_REQUEST", error.what()}); + }); +``` Controller 可使用: diff --git a/example/BUILD.bazel b/example/BUILD.bazel index fcade9b..e4faa55 100644 --- a/example/BUILD.bazel +++ b/example/BUILD.bazel @@ -35,3 +35,10 @@ sh_test( data = [":app"], tags = ["exclusive"], ) + +sh_test( + name = "typed_request_error_test", + srcs = ["typed_request_error_test.sh"], + data = [":app"], + tags = ["exclusive"], +) diff --git a/example/main.cpp b/example/main.cpp index c292525..d497282 100644 --- a/example/main.cpp +++ b/example/main.cpp @@ -33,6 +33,13 @@ namespace beast::http::status::bad_request, {"INVALID_GREETING", "The greeting name must not be empty"}); }); + http_router.map_exception( + [](const khttpd::framework::TypedRequestValidationError& error) + { + return khttpd::framework::HttpResult( + beast::http::status::bad_request, + {"INVALID_TYPED_REQUEST", error.what()}); + }); HelloController::create()->register_routes(http_router)->register_routes(ws_router); HelloStreamController::create()->register_routes(http_router)->register_routes(ws_router); HelloWsController::create()->register_routes(http_router)->register_routes(ws_router); diff --git a/example/typed_request_error_test.sh b/example/typed_request_error_test.sh new file mode 100755 index 0000000..9232f07 --- /dev/null +++ b/example/typed_request_error_test.sh @@ -0,0 +1,46 @@ +#!/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") + +body = b'{"name":42}' +request = ( + b"POST /typed/greetings HTTP/1.1\r\n" + b"Host: localhost\r\n" + b"Content-Type: application/json\r\n" + + f"Content-Length: {len(body)}\r\n".encode() + + b"Connection: close\r\n\r\n" + + body +) +with socket.create_connection(("127.0.0.1", 8080), timeout=1) as sock: + sock.sendall(request) + chunks = [] + while True: + chunk = sock.recv(4096) + if not chunk: + break + chunks.append(chunk) + +response = b"".join(chunks).decode("iso-8859-1") +assert response.startswith("HTTP/1.1 400"), response +assert response.endswith( + '{"code":"INVALID_TYPED_REQUEST","message":"Request body must be valid JSON matching the expected schema"}'), response +PY diff --git a/framework/router/http_router.cpp b/framework/router/http_router.cpp index 8da820d..0d2d482 100644 --- a/framework/router/http_router.cpp +++ b/framework/router/http_router.cpp @@ -455,7 +455,14 @@ namespace khttpd::framework } ctx.set_path_params(std::move(path_params)); - method_it->second(ctx); + try + { + method_it->second(ctx); + } + catch (...) + { + handle_exception(std::current_exception(), ctx); + } return true; } if (request_method != boost::beast::http::verb::get && request_method != boost::beast::http::verb::head) @@ -563,6 +570,19 @@ namespace khttpd::framework } } + try + { + std::rethrow_exception(eptr); + } + catch (const TypedRequestValidationError&) + { + detail::write_invalid_request_body(ctx); + return; + } + catch (...) + { + } + // Default handling for std::exception if no specific handler matched try { diff --git a/framework/router/typed_route.hpp b/framework/router/typed_route.hpp index 5e4fa61..f4fc396 100644 --- a/framework/router/typed_route.hpp +++ b/framework/router/typed_route.hpp @@ -14,11 +14,22 @@ #include #include #include +#include #include #include #include #include +namespace khttpd::framework +{ + // Thrown when a typed route cannot parse a request body before invoking its handler. + class TypedRequestValidationError final : public std::runtime_error + { + public: + using std::runtime_error::runtime_error; + }; +} + namespace khttpd::framework::detail { struct TypedRouteHandler @@ -137,8 +148,8 @@ namespace khttpd::framework::detail 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; + throw TypedRequestValidationError( + "Request body must be valid JSON matching the expected schema"); } std::optional json; @@ -154,8 +165,8 @@ namespace khttpd::framework::detail } catch (const std::exception&) { - write_invalid_request_body(context); - return; + throw TypedRequestValidationError( + "Request body must be valid JSON matching the expected schema"); } if constexpr (Traits::arity == 1) diff --git a/framework/tests/typed_route_test.cpp b/framework/tests/typed_route_test.cpp index 78173a0..c45754d 100644 --- a/framework/tests/typed_route_test.cpp +++ b/framework/tests/typed_route_test.cpp @@ -277,6 +277,33 @@ TEST(TypedRouteTest, InvalidBodiesReturnStableBadRequestWithoutCallingHandler) } } +TEST(TypedRouteTest, MapsInvalidRequestBodiesThroughTheExceptionPipeline) +{ + fw::HttpRouter router; + int calls = 0; + router.map_exception([](const fw::TypedRequestValidationError& error) + { + return fw::HttpResult( + http::status::bad_request, + ErrorReply{"AUTH_INVALID_REQUEST", error.what()}); + }); + router.post("/users", [&calls](const CreateRequest& request) + { + ++calls; + return Reply{request.age, request.name}; + }); + + auto request = json_request("/users", R"({"name":"Ada","age":"old"})"); + 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":"AUTH_INVALID_REQUEST","message":"Request body must be valid JSON matching the expected schema"})"); +} + TEST(TypedRouteSecurityTest, RejectsMisleadingJsonMediaType) { fw::HttpRouter router;