Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion MODULE.bazel
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
module(
name = "khttpd",
version = "0.4.2",
version = "0.4.3",
)

bazel_dep(name = "platforms", version = "1.1.0")
Expand Down
13 changes: 13 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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<khttpd::framework::TypedRequestValidationError>(
[](const auto& error) {
return khttpd::framework::HttpResult<ErrorResponse>(
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
Expand Down
13 changes: 12 additions & 1 deletion doc/api-reference.md
Original file line number Diff line number Diff line change
Expand Up @@ -182,7 +182,18 @@ Response method(const Request&, HttpContext&);

`Response` 可以是 JSON 可序列化裸类型、`HttpResult<T>` 或 `HttpResult<void>`。裸类型自动返回 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<khttpd::framework::TypedRequestValidationError>(
[](const auto& error) {
return khttpd::framework::HttpResult<ErrorResponse>(
boost::beast::http::status::bad_request,
{"INVALID_REQUEST", error.what()});
});
```

Controller 可使用:

Expand Down
7 changes: 7 additions & 0 deletions example/BUILD.bazel
Original file line number Diff line number Diff line change
Expand Up @@ -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"],
)
7 changes: 7 additions & 0 deletions example/main.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,13 @@ namespace
beast::http::status::bad_request,
{"INVALID_GREETING", "The greeting name must not be empty"});
});
http_router.map_exception<khttpd::framework::TypedRequestValidationError>(
[](const khttpd::framework::TypedRequestValidationError& error)
{
return khttpd::framework::HttpResult<GreetingErrorResponse>(
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);
Expand Down
46 changes: 46 additions & 0 deletions example/typed_request_error_test.sh
Original file line number Diff line number Diff line change
@@ -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
22 changes: 21 additions & 1 deletion framework/router/http_router.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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
{
Expand Down
19 changes: 15 additions & 4 deletions framework/router/typed_route.hpp
Original file line number Diff line number Diff line change
Expand Up @@ -14,11 +14,22 @@
#include <memory>
#include <new>
#include <optional>
#include <stdexcept>
#include <string_view>
#include <type_traits>
#include <tuple>
#include <utility>

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
Expand Down Expand Up @@ -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<boost::json::value> json;
Expand All @@ -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)
Expand Down
27 changes: 27 additions & 0 deletions framework/tests/typed_route_test.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -277,6 +277,33 @@ TEST(TypedRouteTest, InvalidBodiesReturnStableBadRequestWithoutCallingHandler)
}
}

TEST(TypedRouteTest, MapsInvalidRequestBodiesThroughTheExceptionPipeline)
{
fw::HttpRouter router;
int calls = 0;
router.map_exception<fw::TypedRequestValidationError>([](const fw::TypedRequestValidationError& error)
{
return fw::HttpResult<ErrorReply>(
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<http::string_body> 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;
Expand Down
Loading