From 973aec43ea54bbf95b64fbcb636403401d1ca60e Mon Sep 17 00:00:00 2001 From: Rutger Broekhoff Date: Fri, 28 Aug 2026 18:03:05 +0200 Subject: Import from e4b104792206ee7ea64bf39c6b7d2c0c230f9d14 --- server/src/http_server.cppm | 661 ++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 661 insertions(+) create mode 100644 server/src/http_server.cppm (limited to 'server/src/http_server.cppm') diff --git a/server/src/http_server.cppm b/server/src/http_server.cppm new file mode 100644 index 0000000..0770d97 --- /dev/null +++ b/server/src/http_server.cppm @@ -0,0 +1,661 @@ +module; + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +export module routemon:http.server; + +import std; +import :config; +import :trace; +export import :http.common; +import :problem; + +namespace net = boost::asio; +using tcp = net::ip::tcp; + +namespace routemon::http { + + struct readable_request { + util::not_null*> p; + util::not_null strm; + util::not_null buf; + }; + + class presponse { + public: + using const_buffers_type = beast::span; + + private: + struct impl_base { + virtual ~impl_base() = default; + virtual auto header() -> bhttp::response_header& = 0; + virtual auto header() const -> bhttp::response_header const& = 0; + virtual auto is_done() const -> bool = 0; + virtual auto prepare(beast::error_code&) -> const_buffers_type = 0; + virtual auto consume(std::size_t n) -> void = 0; + virtual auto keep_alive() const -> bool = 0; + }; + std::unique_ptr impl_; + + template + class impl : public impl_base { + // Initializes in the response state. + // At the first call to prepare, we switch to the message generator state. + // After that point, header may not be called anymore (it will throw). + std::variant, bhttp::message_generator> state_; + + auto ensure_message_generator() -> bhttp::message_generator& { + if (auto prsp = std::get_if>(&state_)) { + auto rsp = bhttp::response{std::move(*prsp)}; + state_.template emplace(std::move(rsp)); + } + return std::get(state_); + } + + public: + explicit impl(bhttp::response&& rsp) : state_{std::move(rsp)} {} + + auto header() -> bhttp::response_header& override { + if (auto prsp = std::get_if>(&state_)) { + return prsp->base(); + } else { + // TODO: define custom exception type presponse::bad_header_access + throw std::logic_error{"header() may not be called after prepare()"}; + } + } + auto header() const -> bhttp::response_header const& override { + if (auto prsp = std::get_if>(&state_)) { + return prsp->base(); + } else { + throw std::logic_error{"header() may not be called after prepare()"}; + } + } + + auto is_done() const -> bool override { + if (auto pgen = std::get_if(&state_)) { + return pgen->is_done(); + } else /* still in the response state */ { + return false; + } + } + + auto prepare(beast::error_code& ec) -> const_buffers_type override { + return ensure_message_generator().prepare(ec); + } + + auto consume(std::size_t n) -> void override { + ensure_message_generator().consume(n); + } + + auto keep_alive() const noexcept -> bool override { + return state_.visit(util::overloaded{ + [](bhttp::response const& rsp) -> bool { + return rsp.keep_alive(); + }, + [](bhttp::message_generator const& gen) -> bool { + return gen.keep_alive(); + }, + }); + } + }; + + public: + template + explicit presponse(bhttp::response&& rsp) + : impl_{new impl{std::move(rsp)}} + {} + + auto header() -> bhttp::response_header& { + return impl_->header(); + } + auto header() const -> bhttp::response_header const& { + return impl_->header(); + } + + auto is_done() const -> bool { + return impl_->is_done(); + } + + auto prepare(beast::error_code& ec) -> const_buffers_type { + return impl_->prepare(ec); + } + + auto consume(std::size_t n) -> void { + return impl_->consume(n); + } + + auto keep_alive() const noexcept -> bool { + return impl_->keep_alive(); + } + }; + static_assert(beast::concepts::buffers_generator); + + template + using next_handler_t = std::function net::awaitable>; + + template + using middleware_t = std::function&, next_handler_t) -> net::awaitable>; + + template + auto lax_cors_middleware(Ctx ctx, bhttp::request_header& req_hdr, next_handler_t next) -> net::awaitable { + std::ignore = req_hdr; + auto prersp = co_await next(ctx); + prersp.header().set(bhttp::field::access_control_allow_origin, "*"); + co_return std::move(prersp); + } + + template + struct trace_id_ctx : InnerCtx { + trace::id trace_id = {}; + }; + + template + auto trace_id_middleware(OuterCtx ctx0, bhttp::request_header& req_hdr, next_handler_t> next) -> net::awaitable { + std::ignore = req_hdr; + auto ctx = trace_id_ctx{std::move(ctx0)}; + auto prersp = co_await next(std::move(ctx)); + prersp.header().set("X-Routemon-Trace-Id", std::string_view{ctx.trace_id.as_string()}); + prersp.header().insert(bhttp::field::access_control_expose_headers, "X-Routemon-Trace-Id"); + co_return std::move(prersp); + } + + struct base_ctx { + std::locale locale; + }; + + template + using basic_route_handler_fn_t = std::function const& matches) -> net::awaitable>; + + struct keep_alive { + bool value; + + explicit keep_alive(bool value) : value{value} {} + }; + + template + auto make_rsp(bhttp::status status, keep_alive ka) -> bhttp::response { + auto rsp = bhttp::response{}; // HTTP version gets set later + rsp.result(status); + rsp.keep_alive(ka.value); + return rsp; + } + + auto problem_rsp(base_ctx const& ctx, problem::details const& problem, keep_alive ka) -> presponse { + auto rsp = make_rsp(problem.status, ka); + rsp.set(bhttp::field::content_type, "application/problem+json"); + rsp.body() = json::serialize(json::value_from(problem, ctx.locale)); + rsp.prepare_payload(); + return presponse{std::move(rsp)}; + } + + struct preflight_response { + verb_set allow_methods; + std::vector allow_headers; + }; + auto make_preflight_rsp(preflight_response res, keep_alive ka) -> bhttp::response { + auto rsp = make_rsp(bhttp::status::no_content, ka); + auto allow_headers_str = res.allow_headers + | std::views::transform([](auto const& field) -> std::string_view { return bhttp::to_string(field); }) + | std::views::join_with(std::string_view{", "}) + | std::ranges::to(); + rsp.set(bhttp::field::access_control_allow_methods, res.allow_methods.to_string()); + rsp.set(bhttp::field::access_control_allow_headers, allow_headers_str); + rsp.prepare_payload(); + return rsp; + } + + // Using base_ctx instead of a template here since that saves you + // typing on invocation (and we do not care about the context type + // anyway, but all context types should derive from base_ctx). + template + auto read_request(base_ctx const& ctx, readable_request&& r) -> net::awaitable, presponse>> { + std::ignore = ctx; + auto p = bhttp::request_parser{std::move(*r.p)}; + co_await bhttp::async_read(*r.strm, *r.buf, p); + co_return std::move(p.release()); + } + + template<> + auto read_request(base_ctx const& ctx, readable_request&& r) -> net::awaitable, presponse>> { + auto [ec, _] = co_await bhttp::async_read(*r.strm, *r.buf, *r.p, net::as_tuple); + if (ec == bhttp::error::unexpected_body) { + auto tpl = problem::tpl{ + .status = bhttp::status::bad_request, + .title = translate("No body expected for this request"), + .type_uri = "https://routemon.fautchen.eu/problems/unexpected-body", + }; + co_return std::unexpected{problem_rsp(ctx, tpl.instantiate(), keep_alive{false})}; + } else if (ec) { + throw boost::system::system_error{ec}; + } + co_return r.p->release(); + } + + template + struct routed_ctx : InnerCtx { + verb_set route_methods; + }; + + template + requires requires(Ctx ctx) { + // Ctx must be derived from an instantiation of routed_ctx + [](routed_ctx const&) {}(ctx); + } + auto default_options_handler(Ctx const& ctx, readable_request r, std::vector const&) -> net::awaitable { + auto mreq = co_await read_request(ctx, std::move(r)); + if (!mreq) + co_return std::move(mreq.error()); + + if (mreq->find(bhttp::field::access_control_request_method) != mreq->end()) { + // CORS preflight request + co_return make_preflight_rsp(preflight_response{ + // TODO: should access-control-allow-methods contain OPTIONS? + .allow_methods = ctx.route_methods, + .allow_headers = {bhttp::field::content_type}, + }, keep_alive{mreq->keep_alive()}); + } else { + // Normal OPTIONS request + auto rsp = make_rsp(bhttp::status::no_content, keep_alive{mreq->keep_alive()}); + rsp.set(bhttp::field::allow, ctx.route_methods.to_string()); + rsp.prepare_payload(); + co_return std::move(rsp); + } + } + + auto global_options_handler(base_ctx const& ctx, readable_request r) -> net::awaitable { + // TODO: switch to "small (4KB) discarded" body type, similar to what Go does? + // Same goes for default_options_handler? Not sure. + if (auto res = co_await read_request(ctx, std::move(r)); !res) + co_return std::move(res.error()); + auto req = r.p->release(); + auto rsp = make_rsp(bhttp::status::no_content, keep_alive{req.keep_alive()}); + rsp.prepare_payload(); + co_return std::move(rsp); + } + + template + auto id_middleware(Ctx ctx, bhttp::request_header&, next_handler_t next) -> net::awaitable { + co_return co_await next(std::move(ctx)); + } + + template + auto middleware_compose(middleware_t ab, middleware_t bc) -> middleware_t { + return [ab = std::move(ab), bc = std::move(bc)](A a, bhttp::request_header& header, next_handler_t next) -> net::awaitable { + co_return co_await ab(std::move(a), header, [&](B b) -> net::awaitable { + co_return co_await bc(std::move(b), header, next); + }); + }; + } + + template + auto middleware_wrap_fn(middleware_t ab, basic_route_handler_fn_t fn) -> basic_route_handler_fn_t { + return [ab = std::move(ab), fn = std::move(fn)](A a_ctx, readable_request r, std::vector const& matches) -> net::awaitable { + co_return co_await ab(std::move(a_ctx), r.p->get().base(), [&](B b_ctx) -> net::awaitable { + co_return co_await fn(std::move(b_ctx), r, matches); + }); + }; + } + + template + requires requires(V v) { + { static_cast(v) }; + } + struct handler_map { + V options = {}; + V delete_ = {}; + V get = {}; + V head = {}; + V post = {}; + V put = {}; + + template + auto lookup(this Self&& self, supported_verb v) -> auto&& { + switch (v.value) { + case supported_verb::options: return std::forward(self).options; + case supported_verb::delete_: return std::forward(self).delete_; + case supported_verb::get: return std::forward(self).get; + case supported_verb::head: return std::forward(self).head; + case supported_verb::post: return std::forward(self).post; + case supported_verb::put: return std::forward(self).put; + } + } + + auto verbs() const -> verb_set { + auto set = verb_set{}; + if (static_cast(options)) + set.enable(supported_verb::options); + if (static_cast(delete_)) + set.enable(supported_verb::delete_); + if (static_cast(get)) + set.enable(supported_verb::get); + if (static_cast(head)) + set.enable(supported_verb::head); + if (static_cast(post)) + set.enable(supported_verb::post); + if (static_cast(put)) + set.enable(supported_verb::put); + return set; + } + + auto empty() const -> bool { + return verbs().empty(); + } + + template + auto map(std::invocable auto f) const -> handler_map + requires std::assignable_from> + { + return { + .options = static_cast(options) ? f(options) : U{}, + .delete_ = static_cast(delete_) ? f(delete_) : U{}, + .get = static_cast(get) ? f(get) : U{}, + .head = static_cast(head) ? f(head) : U{}, + .post = static_cast(post) ? f(post) : U{}, + .put = static_cast(put) ? f(put) : U{}, + }; + } + }; + + template + struct route_tree { + using leaves = handler_map>; + using named_subtrees = std::unordered_map; + using wildcard_subtree = std::indirect; + + leaves here; + // TODO: consider making the first alternative a radix tree + // Note: the map is the first variant here; the variant will be + // default-constructed with the default-constructed first + // alternative. The empty map denotes a lack of subtrees. + std::variant sub; + }; + + template + auto middleware_wrap_tree(middleware_t mw, route_tree const& tree) -> route_tree { + auto new_leaves = tree.here.template map>(std::bind_front(middleware_wrap_fn, mw)); + auto new_sub = tree.sub.visit(util::overloaded{ + [&mw](route_tree::named_subtrees const& subtrees) -> decltype(route_tree::sub) { + auto new_subtrees = typename route_tree::named_subtrees{}; + for (auto [seg, subtree] : subtrees) + new_subtrees[seg] = middleware_wrap_tree(mw, subtree); + return new_subtrees; + }, + [&mw](route_tree::wildcard_subtree const& subtree) -> decltype(route_tree::sub) { + return typename route_tree::wildcard_subtree{middleware_wrap_tree(mw, *subtree)}; + }, + }); + return {.here = new_leaves, .sub = new_sub}; + } + + template + concept match_arg = std::constructible_from; + + template + using route_handler_fn_t = std::function net::awaitable>; + + template + auto degen_route_handler(route_handler_fn_t fn) -> basic_route_handler_fn_t { + return [fn = std::move(fn)](Ctx ctx, readable_request r, std::vector const& matches) -> net::awaitable { + if (sizeof...(MatchArgs) != matches.size()) + throw std::runtime_error{"got unexpected amount of matches"}; + auto it = matches.begin(); + co_return co_await fn(std::move(ctx), r, MatchArgs{static_cast(*it++)}...); + }; + } + + template + struct ctree : route_tree { + template + auto wrap(middleware_t mw) const -> ctree { + return {middleware_wrap_tree(std::move(mw), *this)}; + } + }; + + template + struct dtree : handler_map> { + [[nodiscard]] auto to_leaves() const -> typename route_tree::leaves { + auto here = this->template map>(degen_route_handler); + if (!here.verbs().empty() && !static_cast(this->options)) + here.options = default_options_handler; + return here; + } + + [[nodiscard]] auto named_subtrees(std::initializer_list>> subtrees) const -> ctree { + auto sub = typename route_tree::named_subtrees{ + std::from_range, + subtrees | std::views::transform([](auto const& p) { + return std::make_pair(p.first, static_cast>(p.second)); + }) + }; + return {route_tree{.here = to_leaves(), .sub = sub}}; + } + + template + [[nodiscard]] auto wildcard_subtree(ctree subtree) -> ctree { + return {route_tree{.here = to_leaves(), .sub = typename route_tree::wildcard_subtree{static_cast>(subtree)}}}; + } + + [[nodiscard]] auto no_subtrees() const -> ctree { + return {route_tree{.here = to_leaves(), .sub = {}}}; + } + }; + + template PreRouteCtx> + class server { + log::logger l_; + locale::selector lsel_; + middleware_t global_middleware_; + route_tree> routes_; + + public: + explicit server(log::logger const& l, locale::selector&& lsel, middleware_t global_middleware, route_tree> routes) + : l_{l.sub("http_server")}, lsel_{std::move(lsel)}, global_middleware_{std::move(global_middleware)}, routes_{std::move(routes)} + {} + + struct match_result { + util::not_null>> const*> route_handlers; + std::vector wildcard_matches; + + auto allowed_methods() const -> verb_set { + return route_handlers->verbs(); + } + }; + + auto match(boost::urls::segments_view segments) const -> std::optional { + auto const* tree = &routes_; + auto wildcard_matches = std::vector{}; + for (auto const& seg : segments) { + tree->sub.visit(util::overloaded{ + [&](route_tree>::named_subtrees const& subtrees) { + auto it = subtrees.find(seg); + tree = it == subtrees.end() ? nullptr : &it->second; + }, + [&](route_tree>::wildcard_subtree const& wildcard_subtree) { + wildcard_matches.push_back(seg); + tree = &*wildcard_subtree; + }, + }); + if (!tree) return std::nullopt; + } + if (tree->here.empty()) return std::nullopt; + return match_result{ + .route_handlers = util::not_null{&tree->here}, + .wildcard_matches = wildcard_matches, + }; + } + + auto route_request(PreRouteCtx ctx, readable_request r) const -> net::awaitable { + auto req_base = r.p->get().base(); + + auto const bad_request_tpl = problem::tpl{ + .status = bhttp::status::bad_request, + .title = translate("Bad request"), + .type_uri = "https://routemon.fautchen.eu/problems/bad-request", + }; + + if (req_base.target() == "*") { + // request-target is in asterisk-form (RFC 9112, § 3.2.4), + // so the request must be a server-wide OPTIONS request. + + if (req_base.method() != bhttp::verb::options) { + auto tpl = problem::tpl{ + .status = bhttp::status::method_not_allowed, + .title = translate("Method not allowed"), + .type_uri = "https://routemon.fautchen.eu/problems/method-not-allowed", + }; + co_return problem_rsp(ctx, tpl.instantiate(), keep_alive{false}); + } + + co_return co_await global_options_handler(ctx, r); + } else if (auto mreq_url0 = boost::urls::parse_origin_form(req_base.target())) { + // request-target is in origin-form (RFC 9112, § 3.2.1), + // so it must be a normal request (not a CONNECT or + // server-wide OPTIONS request). + + auto req_url = boost::urls::url{*mreq_url0}; + req_url.normalize(); + if (!req_url.is_path_absolute()) { + auto problem = bad_request_tpl.instantiate(). + set_detail(translate("Path of normalized (RFC 3986, § 6) " + "origin-form request-target (RFC " + "9112, § 3.2.1) should be " + "absolute")); + co_return problem_rsp(ctx, problem, keep_alive{false}); + } + + auto mres = match(req_url.segments()); + if (!mres) { + auto tpl = problem::tpl{ + .status = bhttp::status::not_found, + .title = translate("Not found"), + .type_uri = "https://routemon.fautchen.eu/problems/not-found", + }; + co_return problem_rsp(ctx, tpl.instantiate(), keep_alive{false}); + } + + auto mverb = supported_verb::from(req_base.method()); + if (!mverb) { + // Method not implemented. + auto tpl = problem::tpl{ + .status = bhttp::status::not_implemented, + .title = translate("Method not implemented"), + .type_uri = "https://routemon.fautchen.eu/problems/method-not-implemented", + }; + co_return problem_rsp(ctx, tpl.instantiate(), keep_alive{false}); + } + + if (auto mhdl = mres->route_handlers->lookup(*mverb)) { + auto new_ctx = routed_ctx{std::move(ctx), mres->allowed_methods()}; + co_return co_await mhdl(std::move(new_ctx), r, mres->wildcard_matches); + } else { + // Path recognized, but method not allowed. + auto tpl = problem::tpl{ + .status = bhttp::status::method_not_allowed, + .title = translate("Method not allowed"), + .type_uri = "https://routemon.fautchen.eu/problems/method-not-allowed", + }; + auto rsp = problem_rsp(ctx, tpl.instantiate(), keep_alive{false}); + rsp.header().set(bhttp::field::allow, mres->allowed_methods().to_string()); + co_return std::move(rsp); + } + } else { + // We do not accept any other request-target forms. + + auto problem = bad_request_tpl.instantiate(). + set_detail(translate("Invalid request-target, expected " + "asterisk-form or origin-form " + "(see RFC 9112, § 3.2)")); + co_return problem_rsp(ctx, problem, keep_alive{false}); + } + } + + auto handle_request(readable_request r) const -> net::awaitable { + auto header = r.p->get().base(); + auto locale = lsel_.select(header[bhttp::field::accept_language]); + auto ctx0 = base_ctx{.locale = locale}; + + co_return co_await global_middleware_(std::move(ctx0), header, [&](PreRouteCtx ctx) -> net::awaitable { + co_return co_await route_request(std::move(ctx), std::move(r)); + }); + } + + auto do_session(beast::tcp_stream strm) -> net::awaitable { + auto buf = beast::flat_buffer{}; + + while (true) { + auto p0 = bhttp::request_parser{}; + p0.body_limit(boost::none); + auto [ec, _] = co_await bhttp::async_read_header(strm, buf, p0, net::as_tuple); + if (ec == bhttp::error::end_of_stream) { + break; + } else if (ec) { + throw boost::system::system_error{ec}; + } + + auto http_version = p0.get().version(); + auto&& rsp = co_await handle_request(readable_request{ + .p = util::not_null{&p0}, + .strm = util::not_null{&strm}, + .buf = util::not_null{&buf}, + }); + rsp.header().version(http_version); + bool keep_alive = rsp.keep_alive(); + co_await beast::async_write(strm, std::move(rsp)); + if (!keep_alive) { + break; + } + } + + strm.socket().shutdown(tcp::socket::shutdown_send); + } + + auto do_listen(tcp::endpoint endpoint) -> net::awaitable { + auto executor = co_await net::this_coro::executor; + auto acceptor = tcp::acceptor{executor, endpoint}; + + l_.with("endpoint", endpoint.address().to_string()). + with("port", std::to_string(endpoint.port())). + info("Serving"); + while (true) { + net::co_spawn(executor, + do_session(beast::tcp_stream{co_await acceptor.async_accept()}), + [this](std::exception_ptr e) { + if (e) { + try { + std::rethrow_exception(e); + } catch (std::exception const& e) { + l_.error("Error in session: {}", e.what()); + } + } + }); + } + } + + auto spawn(net::io_context& ioc) -> void { + auto const addr = net::ip::make_address("0.0.0.0"); + auto const endpoint = tcp::endpoint{addr, 8284}; + + // TODO: make exception handling as nice as in srv.cpp + net::co_spawn(ioc, + do_listen(endpoint), + [this](std::exception_ptr e) { + if (e) { + try { + std::rethrow_exception(e); + } catch (std::exception const& e) { + l_.error("Error: {}", e.what()); + } + } + }); + } + }; + +} // namespace routemon::http -- cgit v1.3