From dc86abe0faeb33f57e7abdb4704c128fa547a28f Mon Sep 17 00:00:00 2001 From: Vinnie Falco Date: Sun, 1 Feb 2026 18:11:57 -0800 Subject: [PATCH 1/6] Calculate Allow field value for OPTIONS --- include/boost/http/method.hpp | 2 + include/boost/http/server/basic_router.hpp | 39 ++++ .../boost/http/server/detail/router_base.hpp | 14 ++ src/server/detail/route_match.hpp | 7 +- src/server/flat_router.cpp | 169 +++++++++++++++++- test/unit/server/flat_router.cpp | 128 +++++++++++++ 6 files changed, 354 insertions(+), 5 deletions(-) diff --git a/include/boost/http/method.hpp b/include/boost/http/method.hpp index 8dbb7fce..574a83d5 100644 --- a/include/boost/http/method.hpp +++ b/include/boost/http/method.hpp @@ -27,6 +27,8 @@ namespace http { */ enum class method : char { + // Values must remain sequential starting at 0 - used as bit shift amounts + /** An unknown method. This value indicates that the request method string is not diff --git a/include/boost/http/server/basic_router.hpp b/include/boost/http/server/basic_router.hpp index 300c9026..9a620c45 100644 --- a/include/boost/http/server/basic_router.hpp +++ b/include/boost/http/server/basic_router.hpp @@ -334,6 +334,25 @@ class basic_router : public detail::router_base return std::make_unique>(std::forward(h)); } + template + struct options_handler_impl : options_handler + { + std::decay_t h; + + template + explicit options_handler_impl(H_&& h_) + : h(std::forward(h_)) + { + } + + route_task invoke( + route_params_base& rp, + std::string_view allow) const override + { + return h(static_cast(rp), allow); + } + }; + template struct handlers_impl : handlers { @@ -737,6 +756,26 @@ class basic_router : public detail::router_base { return fluent_route(*this, pattern); } + + /** Set the handler for automatic OPTIONS responses. + + When an OPTIONS request matches a route but no explicit OPTIONS + handler is registered, this handler is invoked with the pre-built + Allow header value. This follows Express.js semantics where + explicit OPTIONS handlers take priority. + + @param h A callable with signature `route_task(P&, std::string_view)` + where the string_view contains the pre-built Allow header value. + */ + template + void set_options_handler(H&& h) + { + static_assert( + std::is_invocable_r_v&, P&, std::string_view>, + "Handler must have signature: route_task(P&, std::string_view)"); + this->options_handler_ = std::make_unique>( + std::forward(h)); + } }; template diff --git a/include/boost/http/server/detail/router_base.hpp b/include/boost/http/server/detail/router_base.hpp index 4d252359..8526d7bb 100644 --- a/include/boost/http/server/detail/router_base.hpp +++ b/include/boost/http/server/detail/router_base.hpp @@ -18,6 +18,7 @@ #include #include #include +#include #include #include @@ -73,6 +74,18 @@ class BOOST_HTTP_DECL handler_ptr* p; }; + // Handler for automatic OPTIONS responses + struct BOOST_HTTP_DECL + options_handler + { + virtual ~options_handler() = default; + virtual route_task invoke( + route_params_base&, + std::string_view allow) const = 0; + }; + + using options_handler_ptr = std::unique_ptr; + protected: using match_result = route_params_base::match_result; struct matcher; @@ -90,6 +103,7 @@ class BOOST_HTTP_DECL void add_impl(layer&, http::method, handlers); void add_impl(layer&, std::string_view, handlers); void set_nested_depth(std::size_t parent_depth); + options_handler_ptr options_handler_; public: /** Maximum nesting depth for routers. diff --git a/src/server/detail/route_match.hpp b/src/server/detail/route_match.hpp index 03aa154b..7232d315 100644 --- a/src/server/detail/route_match.hpp +++ b/src/server/detail/route_match.hpp @@ -14,6 +14,9 @@ #include #include "src/server/detail/route_rule.hpp" #include "src/server/detail/stable_string.hpp" +#include +#include +#include namespace boost { namespace http { @@ -33,8 +36,9 @@ struct router_base::matcher match_result& mr) const; private: - // 24 bytes (vector) + std::string allow_header_; path_rule_t::value_type pv_; + std::vector custom_verbs_; // 16 bytes (pointer + size) stable_string decoded_pat_; @@ -42,6 +46,7 @@ struct router_base::matcher // 8 bytes each std::size_t first_entry_ = 0; // flat_router: first entry using this matcher std::size_t skip_ = 0; // flat_router: entry index to jump to on failure + std::uint64_t allowed_methods_ = 0; // flat_router: bitmask of allowed methods // 4 bytes each opt_flags effective_opts_ = 0; // flat_router: computed opts for this scope diff --git a/src/server/flat_router.cpp b/src/server/flat_router.cpp index e1705f10..296d495b 100644 --- a/src/server/flat_router.cpp +++ b/src/server/flat_router.cpp @@ -15,6 +15,8 @@ #include "src/server/detail/pct_decode.hpp" #include "src/server/detail/route_match.hpp" +#include + namespace boost { namespace http { @@ -28,11 +30,17 @@ struct flat_router::impl using matcher = detail::router_base::matcher; using opt_flags = detail::router_base::opt_flags; using handler_ptr = detail::router_base::handler_ptr; + using options_handler_ptr = detail::router_base::options_handler_ptr; using match_result = route_params_base::match_result; std::vector entries; std::vector matchers; + std::uint64_t global_methods_ = 0; + std::vector global_custom_verbs_; + std::string global_allow_header_; + options_handler_ptr options_handler_; + // RAII scope tracker sets matcher's skip_ when scope ends struct scope_tracker { @@ -56,6 +64,71 @@ struct flat_router::impl } }; + // Build Allow header string from bitmask and custom verbs + static std::string + build_allow_header( + std::uint64_t methods, + std::vector const& custom) + { + if(methods == ~0ULL) + return "DELETE, GET, HEAD, OPTIONS, PATCH, POST, PUT"; + + std::string result; + // Methods in alphabetical order + static constexpr std::pair known[] = { + {http::method::acl, "ACL"}, + {http::method::bind, "BIND"}, + {http::method::checkout, "CHECKOUT"}, + {http::method::connect, "CONNECT"}, + {http::method::copy, "COPY"}, + {http::method::delete_, "DELETE"}, + {http::method::get, "GET"}, + {http::method::head, "HEAD"}, + {http::method::link, "LINK"}, + {http::method::lock, "LOCK"}, + {http::method::merge, "MERGE"}, + {http::method::mkactivity, "MKACTIVITY"}, + {http::method::mkcalendar, "MKCALENDAR"}, + {http::method::mkcol, "MKCOL"}, + {http::method::move, "MOVE"}, + {http::method::msearch, "M-SEARCH"}, + {http::method::notify, "NOTIFY"}, + {http::method::options, "OPTIONS"}, + {http::method::patch, "PATCH"}, + {http::method::post, "POST"}, + {http::method::propfind, "PROPFIND"}, + {http::method::proppatch, "PROPPATCH"}, + {http::method::purge, "PURGE"}, + {http::method::put, "PUT"}, + {http::method::rebind, "REBIND"}, + {http::method::report, "REPORT"}, + {http::method::search, "SEARCH"}, + {http::method::subscribe, "SUBSCRIBE"}, + {http::method::trace, "TRACE"}, + {http::method::unbind, "UNBIND"}, + {http::method::unlink, "UNLINK"}, + {http::method::unlock, "UNLOCK"}, + {http::method::unsubscribe, "UNSUBSCRIBE"}, + }; + for(auto const& [m, name] : known) + { + if(methods & (1ULL << static_cast(m))) + { + if(!result.empty()) + result += ", "; + result += name; + } + } + // Append custom verbs + for(auto const& v : custom) + { + if(!result.empty()) + result += ", "; + result += v; + } + return result; + } + static opt_flags compute_effective_opts( opt_flags parent, @@ -82,6 +155,27 @@ struct flat_router::impl flatten(detail::router_base::impl& src) { flatten_recursive(src, opt_flags{}, 0); + build_allow_headers(); + } + + void + build_allow_headers() + { + // Build per-matcher Allow header strings + for(auto& m : matchers) + { + if(m.end_) + m.allow_header_ = build_allow_header( + m.allowed_methods_, m.custom_verbs_); + } + + // Deduplicate global custom verbs and build global Allow header + std::sort(global_custom_verbs_.begin(), global_custom_verbs_.end()); + global_custom_verbs_.erase( + std::unique(global_custom_verbs_.begin(), global_custom_verbs_.end()), + global_custom_verbs_.end()); + global_allow_header_ = build_allow_header( + global_methods_, global_custom_verbs_); } void @@ -116,6 +210,26 @@ struct flat_router::impl } else { + // Collect methods for OPTIONS (only for end routes) + if(m.end_) + { + // Per-matcher collection + if(e.all) + m.allowed_methods_ = ~0ULL; + else if(e.verb != http::method::unknown) + m.allowed_methods_ |= (1ULL << static_cast(e.verb)); + else if(!e.verb_str.empty()) + m.custom_verbs_.push_back(e.verb_str); + + // Global collection (for OPTIONS *) + if(e.all) + global_methods_ = ~0ULL; + else if(e.verb != http::method::unknown) + global_methods_ |= (1ULL << static_cast(e.verb)); + else if(!e.verb_str.empty()) + global_custom_verbs_.push_back(e.verb_str); + } + // Set matcher_idx, then move entire entry e.matcher_idx = matcher_idx; entries.emplace_back(std::move(e)); @@ -143,13 +257,17 @@ struct flat_router::impl } route_task - dispatch_loop(route_params_base& p) const + dispatch_loop(route_params_base& p, bool is_options) const { // All checks happen BEFORE co_await to minimize coroutine launches. // Avoid touching p.ep_ (expensive atomic on Windows) - use p.kind_ for mode checks. std::size_t last_matched = SIZE_MAX; std::uint32_t current_depth = 0; + + // Collect methods from all matching end-route matchers for OPTIONS + std::uint64_t options_methods = 0; + std::vector options_custom_verbs; // Stack of base_path lengths at each depth level. // path_stack[d] = base_path.size() before any matcher at depth d was tried. @@ -245,6 +363,14 @@ struct flat_router::impl if(!ancestors_ok) continue; + // Collect methods from matching end-route matchers for OPTIONS + if(is_options && m.end_) + { + options_methods |= m.allowed_methods_; + for(auto const& v : m.custom_verbs_) + options_custom_verbs.push_back(v); + } + // Check method match (only for end routes) if(m.end_ && !e.match_method( const_cast(p))) @@ -333,6 +459,14 @@ struct flat_router::impl if(p.kind_ == detail::router_base::is_error) co_return route_error(p.ec_); + // OPTIONS fallback: path matched but no explicit OPTIONS handler + if(is_options && options_methods != 0 && options_handler_) + { + // Build Allow header from collected methods + std::string allow = build_allow_header(options_methods, options_custom_verbs); + co_return co_await options_handler_->invoke(p, allow); + } + co_return route_next; // no handler matched } }; @@ -345,6 +479,7 @@ flat_router( : impl_(std::make_shared()) { impl_->flatten(*src.impl_); + impl_->options_handler_ = std::move(src.options_handler_); } route_task @@ -357,6 +492,18 @@ dispatch( if(verb == http::method::unknown) detail::throw_invalid_argument(); + // Handle OPTIONS * before normal dispatch + if(verb == http::method::options && + url.encoded_path() == "*") + { + if(impl_->options_handler_) + { + return impl_->options_handler_->invoke( + p, impl_->global_allow_header_); + } + // No handler, let it fall through to 404 + } + // Initialize params p.kind_ = detail::router_base::is_plain; p.verb_ = verb; @@ -376,7 +523,7 @@ dispatch( p.addedSlash_ = false; } - return impl_->dispatch_loop(p); + return impl_->dispatch_loop(p, verb == http::method::options); } route_task @@ -389,9 +536,23 @@ dispatch( if(verb.empty()) detail::throw_invalid_argument(); + auto const method = http::string_to_method(verb); + bool const is_options = (method == http::method::options); + + // Handle OPTIONS * before normal dispatch + if(is_options && url.encoded_path() == "*") + { + if(impl_->options_handler_) + { + return impl_->options_handler_->invoke( + p, impl_->global_allow_header_); + } + // No handler, let it fall through to 404 + } + // Initialize params p.kind_ = detail::router_base::is_plain; - p.verb_ = http::string_to_method(verb); + p.verb_ = method; if(p.verb_ == http::method::unknown) p.verb_str_ = verb; else @@ -411,7 +572,7 @@ dispatch( p.addedSlash_ = false; } - return impl_->dispatch_loop(p); + return impl_->dispatch_loop(p, is_options); } } // http diff --git a/test/unit/server/flat_router.cpp b/test/unit/server/flat_router.cpp index 060eab90..91ee96d0 100644 --- a/test/unit/server/flat_router.cpp +++ b/test/unit/server/flat_router.cpp @@ -100,11 +100,139 @@ struct flat_router_test BOOST_TEST_EQ(*counter, 1); } + void testOptionsHandler() + { + std::string captured_allow; + test_router r; + r.add(http::method::get, "/api/users", [](params&) -> route_task + { + co_return route_done; + }); + r.add(http::method::post, "/api/users", [](params&) -> route_task + { + co_return route_done; + }); + r.set_options_handler( + [&captured_allow](params&, std::string_view allow) -> route_task + { + captured_allow = allow; + co_return route_done; + }); + + flat_router fr(std::move(r)); + + params req; + capy::test::run_blocking()(fr.dispatch( + http::method::options, urls::url_view("/api/users"), req)); + BOOST_TEST(captured_allow.find("GET") != std::string::npos); + BOOST_TEST(captured_allow.find("POST") != std::string::npos); + } + + void testExplicitOptionsPriority() + { + bool explicit_called = false; + bool fallback_called = false; + + test_router r; + r.add(http::method::get, "/test", [](params&) -> route_task + { + co_return route_done; + }); + // Explicit OPTIONS handler + r.add(http::method::options, "/test", + [&explicit_called](params&) -> route_task + { + explicit_called = true; + co_return route_done; + }); + // Fallback handler + r.set_options_handler( + [&fallback_called](params&, std::string_view) -> route_task + { + fallback_called = true; + co_return route_done; + }); + + flat_router fr(std::move(r)); + + params req; + capy::test::run_blocking()(fr.dispatch( + http::method::options, urls::url_view("/test"), req)); + BOOST_TEST(explicit_called); + BOOST_TEST(!fallback_called); + } + + void testAllMethodsHandler() + { + std::string captured_allow; + test_router r; + // Use route().all() but have handler return route_next + // so OPTIONS fallback can run + r.route("/wildcard").all([](params&) -> route_task + { + co_return route_next; + }); + r.set_options_handler( + [&captured_allow](params&, std::string_view allow) -> route_task + { + captured_allow = allow; + co_return route_done; + }); + + flat_router fr(std::move(r)); + + params req; + capy::test::run_blocking()(fr.dispatch( + http::method::options, urls::url_view("/wildcard"), req)); + // .all() should produce a full Allow header + BOOST_TEST(captured_allow.find("GET") != std::string::npos); + BOOST_TEST(captured_allow.find("POST") != std::string::npos); + BOOST_TEST(captured_allow.find("DELETE") != std::string::npos); + } + + void testOptionsStarGlobal() + { + std::string captured_allow; + test_router r; + r.add(http::method::get, "/a", [](params&) -> route_task + { + co_return route_done; + }); + r.add(http::method::post, "/b", [](params&) -> route_task + { + co_return route_done; + }); + r.add(http::method::put, "/c", [](params&) -> route_task + { + co_return route_done; + }); + r.set_options_handler( + [&captured_allow](params&, std::string_view allow) -> route_task + { + captured_allow = allow; + co_return route_done; + }); + + flat_router fr(std::move(r)); + + params req; + capy::test::run_blocking()(fr.dispatch( + http::method::options, urls::url_view("*"), req)); + // Should contain all registered methods + BOOST_TEST(captured_allow.find("GET") != std::string::npos); + BOOST_TEST(captured_allow.find("POST") != std::string::npos); + BOOST_TEST(captured_allow.find("PUT") != std::string::npos); + } + void run() { testCopyConstruction(); testCopyAssignment(); testDefaultConstruction(); + testOptionsHandler(); + testExplicitOptionsPriority(); + testAllMethodsHandler(); + testOptionsStarGlobal(); } }; From 758ebfe6c578d61f0e22005ea6f8c3fac7cbad94 Mon Sep 17 00:00:00 2001 From: Vinnie Falco Date: Sun, 1 Feb 2026 18:29:25 -0800 Subject: [PATCH 2/6] ABNF, parser, and data structure for route path patterns --- doc/reference/path-abnf.md | 84 +++++++ src/server/route_abnf.cpp | 375 ++++++++++++++++++++++++++++++++ src/server/route_abnf.hpp | 95 ++++++++ test/unit/server/route_abnf.cpp | 362 ++++++++++++++++++++++++++++++ 4 files changed, 916 insertions(+) create mode 100644 doc/reference/path-abnf.md create mode 100644 src/server/route_abnf.cpp create mode 100644 src/server/route_abnf.hpp create mode 100644 test/unit/server/route_abnf.cpp diff --git a/doc/reference/path-abnf.md b/doc/reference/path-abnf.md new file mode 100644 index 00000000..2f6d328a --- /dev/null +++ b/doc/reference/path-abnf.md @@ -0,0 +1,84 @@ +Looking at the source code, I can reverse engineer the ABNF grammar for path-to-regexp. Let me analyze the tokenizer and parser logic: + +## ABNF Grammar for path-to-regexp + +```abnf +; Top-level path pattern +path = *token + +; Token types +token = text / param / wildcard / group + +; Text is one or more literal characters (possibly escaped) +text = 1*(char / escaped-char) + +; Named parameter: :name or :"quoted" +param = ":" name + +; Wildcard parameter: *name or *"quoted" +wildcard = "*" name + +; Optional group (all-or-nothing) +group = "{" *token "}" + +; Parameter name (two forms) +name = identifier / quoted-name + +; JavaScript-style identifier +identifier = id-start *id-continue +id-start = "$" / "_" / ID_Start + ; ID_Start = Unicode category ID_Start +id-continue = "$" / %x200C / %x200D / ID_Continue + ; %x200C = ZWNJ, %x200D = ZWJ + ; ID_Continue = Unicode category ID_Continue + +; Quoted parameter name (allows any characters) +quoted-name = DQUOTE *quoted-char DQUOTE +quoted-char = escaped-char / %x00-21 / %x23-5B / %x5D-10FFFF + ; any char except DQUOTE and backslash, or escaped + +; Escape sequence +escaped-char = "\" CHAR + ; backslash followed by any character + +; Regular character (not special) +char = %x00-20 / %x23-27 / %x2C / %x2D / "." / %x30-39 + / %x3B-3F / %x41-5A / %x5E-7A / %x7C / %x7E-10FFFF + ; excludes: { } ( ) [ ] + ? ! : * \ + +; Characters requiring escape in text +special = "{" / "}" / "(" / ")" / "[" / "]" + / "+" / "?" / "!" / ":" / "*" / "\" + +; Reserved (parsed but invalid in current version) +reserved = "(" / ")" / "[" / "]" / "+" / "?" / "!" +``` + +## Summary Table + +| Syntax | Meaning | +|--------|---------| +| `:name` | Named parameter (matches non-delimiter chars) | +| `*name` | Wildcard parameter (matches everything including `/`) | +| `{...}` | Optional group (matches all-or-nothing) | +| `\"...\"` | Quoted name (allows special chars in param name) | +| `\X` | Escape character X (literal) | +| `(`, `)`, `[`, `]`, `+`, `?`, `!` | Reserved (tokenized but cause parse error) | + +## Examples + +``` +/users/:id ; literal "/users/" + param "id" +/files/*path ; literal "/files/" + wildcard "path" +/api{/v:version} ; "/api" + optional group "/v" + param "version" +/:foo-:bar ; param "foo" + literal "-" + param "bar" +/:"with spaces" ; param with quoted name containing spaces +/path\:literal ; escaped colon (literal text) +``` + +## Key Observations from Code + +1. **No regex modifiers** - Unlike older versions, `(pattern)` after params is reserved but not implemented +2. **Groups are optional** - `{...}` means "include all or none" for path generation +3. **Backtracking protection** - Consecutive params require separator text between them +4. **Unicode-aware** - Parameter names support full Unicode identifiers \ No newline at end of file diff --git a/src/server/route_abnf.cpp b/src/server/route_abnf.cpp new file mode 100644 index 00000000..2f1966f2 --- /dev/null +++ b/src/server/route_abnf.cpp @@ -0,0 +1,375 @@ +// +// Copyright (c) 2025 Vinnie Falco (vinnie dot falco at gmail dot com) +// +// Distributed under the Boost Software License, Version 1.0. (See accompanying +// file LICENSE_1_0.txt or copy at http://www.boost.org/LICENSE_1_0.txt) +// +// Official repository: https://github.com/cppalliance/http +// + +#include "src/server/route_abnf.hpp" +#include + +namespace boost { +namespace http { +namespace detail { + +namespace { + +//------------------------------------------------ +// Character classification +//------------------------------------------------ + +// Special characters that have meaning in patterns +constexpr bool +is_special(char c) noexcept +{ + switch(c) + { + case '{': + case '}': + case '(': + case ')': + case '[': + case ']': + case '+': + case '?': + case '!': + case ':': + case '*': + case '\\': + return true; + default: + return false; + } +} + +// Reserved characters (parsed but invalid) +constexpr bool +is_reserved(char c) noexcept +{ + switch(c) + { + case '(': + case ')': + case '[': + case ']': + case '+': + case '?': + case '!': + return true; + default: + return false; + } +} + +// Valid identifier start (ASCII subset of ID_Start) +constexpr bool +is_id_start(char c) noexcept +{ + return + (c >= 'a' && c <= 'z') || + (c >= 'A' && c <= 'Z') || + c == '_' || c == '$'; +} + +// Valid identifier continuation (ASCII subset of ID_Continue) +constexpr bool +is_id_continue(char c) noexcept +{ + return + is_id_start(c) || + (c >= '0' && c <= '9'); +} + +//------------------------------------------------ +// Parser state +//------------------------------------------------ + +class parser +{ + char const* it_; + char const* end_; + core::string_view original_; + +public: + parser(core::string_view s) + : it_(s.data()) + , end_(s.data() + s.size()) + , original_(s) + { + } + + bool + at_end() const noexcept + { + return it_ == end_; + } + + char + peek() const noexcept + { + return *it_; + } + + void + advance() noexcept + { + ++it_; + } + + char + get() noexcept + { + return *it_++; + } + + std::size_t + pos() const noexcept + { + return static_cast( + it_ - original_.data()); + } + + //-------------------------------------------- + // Name parsing + //-------------------------------------------- + + // Parse identifier: id-start *id-continue + system::result + parse_identifier() + { + if(at_end() || !is_id_start(peek())) + return grammar::error::mismatch; + + std::string result; + result += get(); + + while(!at_end() && is_id_continue(peek())) + result += get(); + + return result; + } + + // Parse quoted name: DQUOTE *quoted-char DQUOTE + system::result + parse_quoted_name() + { + if(at_end() || peek() != '"') + return grammar::error::mismatch; + + advance(); // skip opening quote + std::string result; + + while(!at_end()) + { + char c = peek(); + + if(c == '"') + { + advance(); // skip closing quote + if(result.empty()) + return grammar::error::syntax; + return result; + } + + if(c == '\\') + { + advance(); // skip backslash + if(at_end()) + return grammar::error::syntax; + result += get(); + } + else + { + result += get(); + } + } + + // Unterminated quote + return grammar::error::syntax; + } + + // Parse name: identifier / quoted-name + system::result + parse_name() + { + if(at_end()) + return grammar::error::syntax; + + if(peek() == '"') + return parse_quoted_name(); + + return parse_identifier(); + } + + //-------------------------------------------- + // Token parsing + //-------------------------------------------- + + // Parse text: 1*(char / escaped-char) + system::result + parse_text() + { + std::string result; + + while(!at_end()) + { + char c = peek(); + + // Stop at special characters + if(is_special(c)) + { + if(c == '\\') + { + // Escaped character + advance(); + if(at_end()) + return grammar::error::syntax; + result += get(); + continue; + } + break; + } + + result += get(); + } + + if(result.empty()) + return grammar::error::mismatch; + + return route_token(route_token_type::text, std::move(result)); + } + + // Parse param: ":" name + system::result + parse_param() + { + if(at_end() || peek() != ':') + return grammar::error::mismatch; + + advance(); // skip ':' + + auto rv = parse_name(); + if(rv.has_error()) + return rv.error(); + + return route_token( + route_token_type::param, std::move(rv.value())); + } + + // Parse wildcard: "*" name + system::result + parse_wildcard() + { + if(at_end() || peek() != '*') + return grammar::error::mismatch; + + advance(); // skip '*' + + auto rv = parse_name(); + if(rv.has_error()) + return rv.error(); + + return route_token( + route_token_type::wildcard, std::move(rv.value())); + } + + // Parse group: "{" *token "}" + system::result + parse_group() + { + if(at_end() || peek() != '{') + return grammar::error::mismatch; + + advance(); // skip '{' + + route_token group; + group.type = route_token_type::group; + + // Parse tokens until '}' + while(!at_end() && peek() != '}') + { + auto rv = parse_token(); + if(rv.has_error()) + return rv.error(); + group.children.push_back(std::move(rv.value())); + } + + if(at_end()) + return grammar::error::syntax; // unclosed group + + advance(); // skip '}' + + return group; + } + + // Parse single token + system::result + parse_token() + { + if(at_end()) + return grammar::error::syntax; + + char c = peek(); + + // Check for reserved characters + if(is_reserved(c)) + return grammar::error::syntax; + + // Try each token type + if(c == ':') + return parse_param(); + + if(c == '*') + return parse_wildcard(); + + if(c == '{') + return parse_group(); + + if(c == '}') + return grammar::error::syntax; // unexpected '}' + + // Must be text + return parse_text(); + } + + // Parse entire pattern + system::result> + parse_tokens() + { + std::vector tokens; + + while(!at_end()) + { + auto rv = parse_token(); + if(rv.has_error()) + return rv.error(); + tokens.push_back(std::move(rv.value())); + } + + return tokens; + } +}; + +} // anonymous namespace + +//------------------------------------------------ + +system::result +parse_route_pattern(core::string_view pattern) +{ + parser p(pattern); + auto rv = p.parse_tokens(); + if(rv.has_error()) + return rv.error(); + + route_pattern result; + result.tokens = std::move(rv.value()); + result.original = std::string(pattern); + return result; +} + +} // detail +} // http +} // boost diff --git a/src/server/route_abnf.hpp b/src/server/route_abnf.hpp new file mode 100644 index 00000000..3bdfd9d0 --- /dev/null +++ b/src/server/route_abnf.hpp @@ -0,0 +1,95 @@ +// +// Copyright (c) 2025 Vinnie Falco (vinnie dot falco at gmail dot com) +// +// Distributed under the Boost Software License, Version 1.0. (See accompanying +// file LICENSE_1_0.txt or copy at http://www.boost.org/LICENSE_1_0.txt) +// +// Official repository: https://github.com/cppalliance/http +// + +#ifndef BOOST_HTTP_SERVER_ROUTE_ABNF_HPP +#define BOOST_HTTP_SERVER_ROUTE_ABNF_HPP + +#include +#include +#include +#include +#include + +namespace boost { +namespace http { +namespace detail { + +//------------------------------------------------ + +/** Type of route pattern token +*/ +enum class route_token_type +{ + text, // literal text + param, // :name parameter + wildcard, // *name wildcard + group // {...} optional group +}; + +//------------------------------------------------ + +/** A token in a parsed route pattern +*/ +struct route_token +{ + route_token_type type; + std::string value; // text content or param name + std::vector children; // group contents + + route_token() = default; + + route_token( + route_token_type t, + std::string v) + : type(t) + , value(std::move(v)) + { + } +}; + +//------------------------------------------------ + +/** Result of parsing a route pattern +*/ +struct route_pattern +{ + std::vector tokens; + std::string original; +}; + +//------------------------------------------------ + +/** Parse a route pattern string + + Parses a path-to-regexp style route pattern into tokens. + + @par Grammar + @code + path = *token + token = text / param / wildcard / group + text = 1*(char / escaped-char) + param = ":" name + wildcard = "*" name + group = "{" *token "}" + name = identifier / quoted-name + @endcode + + @param pattern The route pattern string to parse + + @return A result containing the parsed route pattern, or + an error if parsing failed +*/ +system::result +parse_route_pattern(core::string_view pattern); + +} // detail +} // http +} // boost + +#endif diff --git a/test/unit/server/route_abnf.cpp b/test/unit/server/route_abnf.cpp new file mode 100644 index 00000000..af070ae8 --- /dev/null +++ b/test/unit/server/route_abnf.cpp @@ -0,0 +1,362 @@ +// +// Copyright (c) 2025 Vinnie Falco (vinnie dot falco at gmail dot com) +// +// Distributed under the Boost Software License, Version 1.0. (See accompanying +// file LICENSE_1_0.txt or copy at http://www.boost.org/LICENSE_1_0.txt) +// +// Official repository: https://github.com/cppalliance/http +// + +// Test that header file is self-contained +#include "src/server/route_abnf.hpp" + +#include "test_suite.hpp" + +namespace boost { +namespace http { + +struct route_abnf_test +{ + using token = detail::route_token; + using token_type = detail::route_token_type; + + // Helper to check token type and value + static void + check_token( + token const& t, + token_type type, + std::string const& value) + { + BOOST_TEST_EQ(static_cast(t.type), static_cast(type)); + BOOST_TEST_EQ(t.value, value); + } + + void + testText() + { + // Simple text + { + auto rv = detail::parse_route_pattern("/users"); + BOOST_TEST(rv.has_value()); + BOOST_TEST_EQ(rv->tokens.size(), 1u); + check_token(rv->tokens[0], token_type::text, "/users"); + } + + // Text with multiple segments + { + auto rv = detail::parse_route_pattern("/api/v1/users"); + BOOST_TEST(rv.has_value()); + BOOST_TEST_EQ(rv->tokens.size(), 1u); + check_token(rv->tokens[0], token_type::text, "/api/v1/users"); + } + } + + void + testParam() + { + // Simple param + { + auto rv = detail::parse_route_pattern(":id"); + BOOST_TEST(rv.has_value()); + BOOST_TEST_EQ(rv->tokens.size(), 1u); + check_token(rv->tokens[0], token_type::param, "id"); + } + + // Param with text prefix + { + auto rv = detail::parse_route_pattern("/users/:id"); + BOOST_TEST(rv.has_value()); + BOOST_TEST_EQ(rv->tokens.size(), 2u); + check_token(rv->tokens[0], token_type::text, "/users/"); + check_token(rv->tokens[1], token_type::param, "id"); + } + + // Multiple params + { + auto rv = detail::parse_route_pattern("/users/:userId/posts/:postId"); + BOOST_TEST(rv.has_value()); + BOOST_TEST_EQ(rv->tokens.size(), 4u); + check_token(rv->tokens[0], token_type::text, "/users/"); + check_token(rv->tokens[1], token_type::param, "userId"); + check_token(rv->tokens[2], token_type::text, "/posts/"); + check_token(rv->tokens[3], token_type::param, "postId"); + } + + // Param with underscore + { + auto rv = detail::parse_route_pattern(":user_id"); + BOOST_TEST(rv.has_value()); + BOOST_TEST_EQ(rv->tokens.size(), 1u); + check_token(rv->tokens[0], token_type::param, "user_id"); + } + + // Param with dollar sign + { + auto rv = detail::parse_route_pattern(":$var"); + BOOST_TEST(rv.has_value()); + BOOST_TEST_EQ(rv->tokens.size(), 1u); + check_token(rv->tokens[0], token_type::param, "$var"); + } + } + + void + testWildcard() + { + // Simple wildcard + { + auto rv = detail::parse_route_pattern("*path"); + BOOST_TEST(rv.has_value()); + BOOST_TEST_EQ(rv->tokens.size(), 1u); + check_token(rv->tokens[0], token_type::wildcard, "path"); + } + + // Wildcard with prefix + { + auto rv = detail::parse_route_pattern("/files/*filepath"); + BOOST_TEST(rv.has_value()); + BOOST_TEST_EQ(rv->tokens.size(), 2u); + check_token(rv->tokens[0], token_type::text, "/files/"); + check_token(rv->tokens[1], token_type::wildcard, "filepath"); + } + } + + void + testGroup() + { + // Simple group + { + auto rv = detail::parse_route_pattern("{/optional}"); + BOOST_TEST(rv.has_value()); + BOOST_TEST_EQ(rv->tokens.size(), 1u); + BOOST_TEST_EQ( + static_cast(rv->tokens[0].type), + static_cast(token_type::group)); + BOOST_TEST_EQ(rv->tokens[0].children.size(), 1u); + check_token(rv->tokens[0].children[0], token_type::text, "/optional"); + } + + // Group with param + { + auto rv = detail::parse_route_pattern("/api{/v:version}"); + BOOST_TEST(rv.has_value()); + BOOST_TEST_EQ(rv->tokens.size(), 2u); + check_token(rv->tokens[0], token_type::text, "/api"); + BOOST_TEST_EQ( + static_cast(rv->tokens[1].type), + static_cast(token_type::group)); + BOOST_TEST_EQ(rv->tokens[1].children.size(), 2u); + check_token(rv->tokens[1].children[0], token_type::text, "/v"); + check_token(rv->tokens[1].children[1], token_type::param, "version"); + } + + // Empty group + { + auto rv = detail::parse_route_pattern("/path{}"); + BOOST_TEST(rv.has_value()); + BOOST_TEST_EQ(rv->tokens.size(), 2u); + check_token(rv->tokens[0], token_type::text, "/path"); + BOOST_TEST_EQ(rv->tokens[1].children.size(), 0u); + } + } + + void + testEscape() + { + // Escaped colon + { + auto rv = detail::parse_route_pattern("/path\\:literal"); + BOOST_TEST(rv.has_value()); + BOOST_TEST_EQ(rv->tokens.size(), 1u); + check_token(rv->tokens[0], token_type::text, "/path:literal"); + } + + // Escaped asterisk + { + auto rv = detail::parse_route_pattern("/path\\*star"); + BOOST_TEST(rv.has_value()); + BOOST_TEST_EQ(rv->tokens.size(), 1u); + check_token(rv->tokens[0], token_type::text, "/path*star"); + } + + // Escaped brace + { + auto rv = detail::parse_route_pattern("/path\\{brace\\}"); + BOOST_TEST(rv.has_value()); + BOOST_TEST_EQ(rv->tokens.size(), 1u); + check_token(rv->tokens[0], token_type::text, "/path{brace}"); + } + + // Escaped backslash + { + auto rv = detail::parse_route_pattern("/path\\\\slash"); + BOOST_TEST(rv.has_value()); + BOOST_TEST_EQ(rv->tokens.size(), 1u); + check_token(rv->tokens[0], token_type::text, "/path\\slash"); + } + } + + void + testQuotedName() + { + // Quoted param name + { + auto rv = detail::parse_route_pattern(":\"with spaces\""); + BOOST_TEST(rv.has_value()); + BOOST_TEST_EQ(rv->tokens.size(), 1u); + check_token(rv->tokens[0], token_type::param, "with spaces"); + } + + // Quoted wildcard name + { + auto rv = detail::parse_route_pattern("*\"file-path\""); + BOOST_TEST(rv.has_value()); + BOOST_TEST_EQ(rv->tokens.size(), 1u); + check_token(rv->tokens[0], token_type::wildcard, "file-path"); + } + + // Quoted name with escape + { + auto rv = detail::parse_route_pattern(":\"say \\\"hello\\\"\""); + BOOST_TEST(rv.has_value()); + BOOST_TEST_EQ(rv->tokens.size(), 1u); + check_token(rv->tokens[0], token_type::param, "say \"hello\""); + } + } + + void + testErrors() + { + // Missing param name + { + auto rv = detail::parse_route_pattern("/users/:"); + BOOST_TEST(rv.has_error()); + } + + // Missing wildcard name + { + auto rv = detail::parse_route_pattern("/files/*"); + BOOST_TEST(rv.has_error()); + } + + // Unclosed group + { + auto rv = detail::parse_route_pattern("/path{unclosed"); + BOOST_TEST(rv.has_error()); + } + + // Unexpected close brace + { + auto rv = detail::parse_route_pattern("/path}extra"); + BOOST_TEST(rv.has_error()); + } + + // Unterminated quote + { + auto rv = detail::parse_route_pattern(":\"unterminated"); + BOOST_TEST(rv.has_error()); + } + + // Empty quoted name + { + auto rv = detail::parse_route_pattern(":\"\""); + BOOST_TEST(rv.has_error()); + } + + // Reserved character ( + { + auto rv = detail::parse_route_pattern("/path(reserved)"); + BOOST_TEST(rv.has_error()); + } + + // Reserved character [ + { + auto rv = detail::parse_route_pattern("/path[reserved]"); + BOOST_TEST(rv.has_error()); + } + + // Reserved character + + { + auto rv = detail::parse_route_pattern("/path+"); + BOOST_TEST(rv.has_error()); + } + + // Reserved character ? + { + auto rv = detail::parse_route_pattern("/path?"); + BOOST_TEST(rv.has_error()); + } + + // Reserved character ! + { + auto rv = detail::parse_route_pattern("/path!"); + BOOST_TEST(rv.has_error()); + } + + // Trailing backslash + { + auto rv = detail::parse_route_pattern("/path\\"); + BOOST_TEST(rv.has_error()); + } + } + + void + testComplex() + { + // Express.js style route + { + auto rv = detail::parse_route_pattern( + "/api/v1/users/:userId/posts/:postId"); + BOOST_TEST(rv.has_value()); + BOOST_TEST_EQ(rv->tokens.size(), 4u); + } + + // Multiple consecutive params with separator + { + auto rv = detail::parse_route_pattern("/:foo-:bar"); + BOOST_TEST(rv.has_value()); + BOOST_TEST_EQ(rv->tokens.size(), 4u); + check_token(rv->tokens[0], token_type::text, "/"); + check_token(rv->tokens[1], token_type::param, "foo"); + check_token(rv->tokens[2], token_type::text, "-"); + check_token(rv->tokens[3], token_type::param, "bar"); + } + + // Nested groups not directly supported but works as single group + { + auto rv = detail::parse_route_pattern("/path{/opt1{/opt2}}"); + BOOST_TEST(rv.has_value()); + BOOST_TEST_EQ(rv->tokens.size(), 2u); + // The outer group contains text + nested group + BOOST_TEST_EQ(rv->tokens[1].children.size(), 2u); + } + } + + void + testOriginalPreserved() + { + auto rv = detail::parse_route_pattern("/users/:id"); + BOOST_TEST(rv.has_value()); + BOOST_TEST_EQ(rv->original, "/users/:id"); + } + + void + run() + { + testText(); + testParam(); + testWildcard(); + testGroup(); + testEscape(); + testQuotedName(); + testErrors(); + testComplex(); + testOriginalPreserved(); + } +}; + +TEST_SUITE( + route_abnf_test, + "boost.http.server.route_abnf"); + +} // http +} // boost From ca38071d3cb5ca7da8f68a51e4accd3ac3c787ab Mon Sep 17 00:00:00 2001 From: Vinnie Falco Date: Sun, 1 Feb 2026 18:51:33 -0800 Subject: [PATCH 3/6] Add match_route and tests --- src/server/route_abnf.cpp | 213 ++++++++++ src/server/route_abnf.hpp | 41 ++ test/unit/server/route_abnf.cpp | 685 ++++++++++++++++++++++++++++++++ 3 files changed, 939 insertions(+) diff --git a/src/server/route_abnf.cpp b/src/server/route_abnf.cpp index 2f1966f2..d4d4b276 100644 --- a/src/server/route_abnf.cpp +++ b/src/server/route_abnf.cpp @@ -352,6 +352,197 @@ class parser } }; +//------------------------------------------------ +// Case-insensitive comparison +//------------------------------------------------ + +bool +ci_equal(char a, char b) noexcept +{ + if(a >= 'A' && a <= 'Z') + a = static_cast(a + 32); + if(b >= 'A' && b <= 'Z') + b = static_cast(b + 32); + return a == b; +} + +bool +ci_starts_with( + core::string_view str, + core::string_view prefix) noexcept +{ + if(prefix.size() > str.size()) + return false; + for(std::size_t i = 0; i < prefix.size(); ++i) + { + if(!ci_equal(str[i], prefix[i])) + return false; + } + return true; +} + +//------------------------------------------------ +// Route matcher +//------------------------------------------------ + +class route_matcher +{ + core::string_view path_; + match_options const& opts_; + std::vector> params_; + std::size_t pos_ = 0; + +public: + route_matcher( + core::string_view path, + match_options const& opts) + : path_(path) + , opts_(opts) + { + } + + bool at_end() const noexcept + { + return pos_ >= path_.size(); + } + + std::size_t pos() const noexcept + { + return pos_; + } + + std::vector> const& + params() const noexcept + { + return params_; + } + + // Match text token + bool match_text(core::string_view text) + { + auto remaining = path_.substr(pos_); + if(opts_.case_sensitive) + { + if(!remaining.starts_with(text)) + return false; + } + else + { + if(!ci_starts_with(remaining, text)) + return false; + } + pos_ += text.size(); + return true; + } + + // Match param token - capture until '/' or end + bool match_param(std::string const& name) + { + if(at_end()) + return false; + + auto start = pos_; + while(pos_ < path_.size() && path_[pos_] != '/') + ++pos_; + + // Param must capture at least one character + if(pos_ == start) + return false; + + params_.emplace_back( + name, + std::string(path_.substr(start, pos_ - start))); + return true; + } + + // Match wildcard token - capture everything to end + bool match_wildcard(std::string const& name) + { + if(at_end()) + return false; + + auto start = pos_; + pos_ = path_.size(); + + // Wildcard must capture at least one character + if(pos_ == start) + return false; + + params_.emplace_back( + name, + std::string(path_.substr(start))); + return true; + } + + // Match a sequence of tokens + bool match_tokens(std::vector const& tokens) + { + for(auto const& token : tokens) + { + if(!match_token(token)) + return false; + } + return true; + } + + // Match a single token + bool match_token(route_token const& token) + { + switch(token.type) + { + case route_token_type::text: + return match_text(token.value); + + case route_token_type::param: + return match_param(token.value); + + case route_token_type::wildcard: + return match_wildcard(token.value); + + case route_token_type::group: + return match_group(token.children); + + default: + return false; + } + } + + // Match group - try with contents, then without + bool match_group(std::vector const& children) + { + // Save state before trying group + auto saved_pos = pos_; + auto saved_params_size = params_.size(); + + // Try matching with group contents + if(match_tokens(children)) + return true; + + // Restore state and try without group + pos_ = saved_pos; + params_.resize(saved_params_size); + return true; // Group is optional, always succeeds if skipped + } + + // Check if match is complete based on options + bool is_complete() const + { + if(!opts_.end) + return true; // Prefix match always succeeds + + if(opts_.strict) + return at_end(); + + // Non-strict: allow trailing slash + if(at_end()) + return true; + if(pos_ == path_.size() - 1 && path_[pos_] == '/') + return true; + + return false; + } +}; + } // anonymous namespace //------------------------------------------------ @@ -370,6 +561,28 @@ parse_route_pattern(core::string_view pattern) return result; } +//------------------------------------------------ + +system::result +match_route( + core::string_view path, + route_pattern const& pattern, + match_options const& opts) +{ + route_matcher m(path, opts); + + if(!m.match_tokens(pattern.tokens)) + return grammar::error::mismatch; + + if(!m.is_complete()) + return grammar::error::mismatch; + + match_params result; + result.params = m.params(); + result.matched_length = m.pos(); + return result; +} + } // detail } // http } // boost diff --git a/src/server/route_abnf.hpp b/src/server/route_abnf.hpp index 3bdfd9d0..18e17c1e 100644 --- a/src/server/route_abnf.hpp +++ b/src/server/route_abnf.hpp @@ -88,6 +88,47 @@ struct route_pattern system::result parse_route_pattern(core::string_view pattern); +//------------------------------------------------ + +/** Options for route matching +*/ +struct match_options +{ + bool case_sensitive; ///< Text comparison mode + bool strict; ///< Trailing slash matters + bool end; ///< true = full match, false = prefix match +}; + +//------------------------------------------------ + +/** Result of matching a path against a pattern +*/ +struct match_params +{ + std::vector> params; ///< Captured parameters + std::size_t matched_length; ///< Characters consumed from path +}; + +//------------------------------------------------ + +/** Match a decoded path against a route pattern + + Attempts to match the given path against the pattern, + extracting any captured parameters. + + @param path The decoded path to match (not URL-encoded) + @param pattern The parsed route pattern + @param opts Matching options + + @return The captured parameters and match length if successful, + or an error if the path doesn't match +*/ +system::result +match_route( + core::string_view path, + route_pattern const& pattern, + match_options const& opts); + } // detail } // http } // boost diff --git a/test/unit/server/route_abnf.cpp b/test/unit/server/route_abnf.cpp index af070ae8..c8faf2c6 100644 --- a/test/unit/server/route_abnf.cpp +++ b/test/unit/server/route_abnf.cpp @@ -358,5 +358,690 @@ TEST_SUITE( route_abnf_test, "boost.http.server.route_abnf"); +//------------------------------------------------ + +struct route_match_test +{ + using match_options = detail::match_options; + using match_params = detail::match_params; + + // Helper to parse pattern + static detail::route_pattern + parse(std::string_view pat) + { + auto rv = detail::parse_route_pattern(pat); + BOOST_TEST(rv.has_value()); + return std::move(rv.value()); + } + + // Helper to check param value + static void + check_param( + match_params const& mp, + std::string const& name, + std::string const& value) + { + for(auto const& p : mp.params) + { + if(p.first == name) + { + BOOST_TEST_EQ(p.second, value); + return; + } + } + BOOST_TEST(false); // param not found + } + + // Default options for convenience + static match_options + opts(bool case_sensitive = false, bool strict = false, bool end = true) + { + return { case_sensitive, strict, end }; + } + + //-------------------------------------------- + // Text Matching + //-------------------------------------------- + + void + testTextExact() + { + auto pat = parse("/users"); + + // Exact match + { + auto rv = detail::match_route("/users", pat, opts()); + BOOST_TEST(rv.has_value()); + BOOST_TEST_EQ(rv->matched_length, 6u); + BOOST_TEST_EQ(rv->params.size(), 0u); + } + + // No match - different text + { + auto rv = detail::match_route("/posts", pat, opts()); + BOOST_TEST(rv.has_error()); + } + + // No match - too short + { + auto rv = detail::match_route("/use", pat, opts()); + BOOST_TEST(rv.has_error()); + } + + // No match - too long (end=true) + { + auto rv = detail::match_route("/users/123", pat, opts()); + BOOST_TEST(rv.has_error()); + } + } + + void + testTextCaseSensitive() + { + auto pat = parse("/Users"); + + // Case insensitive (default) - should match + { + auto rv = detail::match_route("/users", pat, opts(false)); + BOOST_TEST(rv.has_value()); + } + + // Case sensitive - should not match + { + auto rv = detail::match_route("/users", pat, opts(true)); + BOOST_TEST(rv.has_error()); + } + + // Case sensitive - exact match + { + auto rv = detail::match_route("/Users", pat, opts(true)); + BOOST_TEST(rv.has_value()); + } + } + + void + testTextRoot() + { + auto pat = parse("/"); + + // Match root + { + auto rv = detail::match_route("/", pat, opts()); + BOOST_TEST(rv.has_value()); + BOOST_TEST_EQ(rv->matched_length, 1u); + } + } + + void + testTextEmpty() + { + // Empty pattern matches empty path + auto pat = parse(""); + auto rv = detail::match_route("", pat, opts()); + BOOST_TEST(rv.has_value()); + BOOST_TEST_EQ(rv->matched_length, 0u); + } + + //-------------------------------------------- + // Parameter Extraction + //-------------------------------------------- + + void + testParamSingle() + { + auto pat = parse("/users/:id"); + + // Match and extract + { + auto rv = detail::match_route("/users/123", pat, opts()); + BOOST_TEST(rv.has_value()); + BOOST_TEST_EQ(rv->params.size(), 1u); + check_param(*rv, "id", "123"); + BOOST_TEST_EQ(rv->matched_length, 10u); + } + + // Match with longer value + { + auto rv = detail::match_route("/users/abc-def", pat, opts()); + BOOST_TEST(rv.has_value()); + check_param(*rv, "id", "abc-def"); + } + } + + void + testParamMultiple() + { + auto pat = parse("/users/:userId/posts/:postId"); + + auto rv = detail::match_route("/users/42/posts/99", pat, opts()); + BOOST_TEST(rv.has_value()); + BOOST_TEST_EQ(rv->params.size(), 2u); + check_param(*rv, "userId", "42"); + check_param(*rv, "postId", "99"); + } + + void + testParamAdjacent() + { + // Adjacent params with slash separator + // Note: params match until '/' only, not arbitrary text + auto pat = parse("/:foo/:bar"); + + auto rv = detail::match_route("/hello/world", pat, opts()); + BOOST_TEST(rv.has_value()); + BOOST_TEST_EQ(rv->params.size(), 2u); + check_param(*rv, "foo", "hello"); + check_param(*rv, "bar", "world"); + } + + void + testParamAtStart() + { + auto pat = parse(":foo/bar"); + + auto rv = detail::match_route("hello/bar", pat, opts()); + BOOST_TEST(rv.has_value()); + check_param(*rv, "foo", "hello"); + } + + void + testParamAtEnd() + { + auto pat = parse("/foo/:bar"); + + auto rv = detail::match_route("/foo/baz", pat, opts()); + BOOST_TEST(rv.has_value()); + check_param(*rv, "bar", "baz"); + } + + void + testParamEmpty() + { + // Param must capture at least one char + auto pat = parse("/users/:id/posts"); + + auto rv = detail::match_route("/users//posts", pat, opts()); + BOOST_TEST(rv.has_error()); + } + + //-------------------------------------------- + // Wildcard Extraction + //-------------------------------------------- + + void + testWildcardSimple() + { + auto pat = parse("/files/*path"); + + auto rv = detail::match_route("/files/a/b/c.txt", pat, opts()); + BOOST_TEST(rv.has_value()); + check_param(*rv, "path", "a/b/c.txt"); + } + + void + testWildcardAtRoot() + { + auto pat = parse("/*path"); + + auto rv = detail::match_route("/anything/here", pat, opts()); + BOOST_TEST(rv.has_value()); + check_param(*rv, "path", "anything/here"); + } + + void + testWildcardEmpty() + { + // Wildcard must capture at least one char + auto pat = parse("/files/*path"); + + auto rv = detail::match_route("/files/", pat, opts()); + BOOST_TEST(rv.has_error()); + } + + //-------------------------------------------- + // Option Combinations (all 8) + //-------------------------------------------- + + void + testOptionsCombinations() + { + auto pat = parse("/Api"); + + // {case_sensitive: false, strict: false, end: true} + { + auto rv = detail::match_route("/api", pat, opts(false, false, true)); + BOOST_TEST(rv.has_value()); + } + { + auto rv = detail::match_route("/api/", pat, opts(false, false, true)); + BOOST_TEST(rv.has_value()); // trailing slash allowed + } + + // {case_sensitive: false, strict: false, end: false} + { + auto rv = detail::match_route("/api/extra", pat, opts(false, false, false)); + BOOST_TEST(rv.has_value()); + BOOST_TEST_EQ(rv->matched_length, 4u); + } + + // {case_sensitive: false, strict: true, end: true} + { + auto rv = detail::match_route("/api", pat, opts(false, true, true)); + BOOST_TEST(rv.has_value()); + } + { + auto rv = detail::match_route("/api/", pat, opts(false, true, true)); + BOOST_TEST(rv.has_error()); // strict - trailing slash not allowed + } + + // {case_sensitive: false, strict: true, end: false} + { + auto rv = detail::match_route("/api/extra", pat, opts(false, true, false)); + BOOST_TEST(rv.has_value()); + BOOST_TEST_EQ(rv->matched_length, 4u); + } + + // {case_sensitive: true, strict: false, end: true} + { + auto rv = detail::match_route("/Api", pat, opts(true, false, true)); + BOOST_TEST(rv.has_value()); + } + { + auto rv = detail::match_route("/api", pat, opts(true, false, true)); + BOOST_TEST(rv.has_error()); // case mismatch + } + + // {case_sensitive: true, strict: false, end: false} + { + auto rv = detail::match_route("/Api/extra", pat, opts(true, false, false)); + BOOST_TEST(rv.has_value()); + BOOST_TEST_EQ(rv->matched_length, 4u); + } + + // {case_sensitive: true, strict: true, end: true} + { + auto rv = detail::match_route("/Api", pat, opts(true, true, true)); + BOOST_TEST(rv.has_value()); + } + { + auto rv = detail::match_route("/Api/", pat, opts(true, true, true)); + BOOST_TEST(rv.has_error()); + } + + // {case_sensitive: true, strict: true, end: false} + { + auto rv = detail::match_route("/Api/extra", pat, opts(true, true, false)); + BOOST_TEST(rv.has_value()); + BOOST_TEST_EQ(rv->matched_length, 4u); + } + } + + //-------------------------------------------- + // Strict Mode + //-------------------------------------------- + + void + testStrictTrailingSlash() + { + auto pat = parse("/api/users"); + + // Non-strict: /api/users matches /api/users/ + { + auto rv = detail::match_route("/api/users/", pat, opts(false, false, true)); + BOOST_TEST(rv.has_value()); + } + + // Strict: /api/users does NOT match /api/users/ + { + auto rv = detail::match_route("/api/users/", pat, opts(false, true, true)); + BOOST_TEST(rv.has_error()); + } + } + + void + testStrictWithParam() + { + auto pat = parse("/users/:id"); + + // Non-strict + { + auto rv = detail::match_route("/users/123/", pat, opts(false, false, true)); + BOOST_TEST(rv.has_value()); + check_param(*rv, "id", "123"); + } + + // Strict - trailing slash after param not allowed + { + auto rv = detail::match_route("/users/123/", pat, opts(false, true, true)); + BOOST_TEST(rv.has_error()); + } + } + + //-------------------------------------------- + // End Mode (prefix vs full match) + //-------------------------------------------- + + void + testEndModeFull() + { + auto pat = parse("/api"); + + // end=true requires full match + { + auto rv = detail::match_route("/api", pat, opts(false, false, true)); + BOOST_TEST(rv.has_value()); + } + { + auto rv = detail::match_route("/api/users", pat, opts(false, false, true)); + BOOST_TEST(rv.has_error()); + } + } + + void + testEndModePrefix() + { + auto pat = parse("/api"); + + // end=false allows prefix match + { + auto rv = detail::match_route("/api/users", pat, opts(false, false, false)); + BOOST_TEST(rv.has_value()); + BOOST_TEST_EQ(rv->matched_length, 4u); + } + } + + void + testEndModeWithParams() + { + auto pat = parse("/users/:id"); + + // Prefix match with param + { + auto rv = detail::match_route("/users/123/extra", pat, opts(false, false, false)); + BOOST_TEST(rv.has_value()); + check_param(*rv, "id", "123"); + BOOST_TEST_EQ(rv->matched_length, 10u); + } + } + + //-------------------------------------------- + // Groups (Optional Sections) + //-------------------------------------------- + + void + testGroupMatches() + { + auto pat = parse("/api{/v:version}"); + + // With group + { + auto rv = detail::match_route("/api/v2", pat, opts()); + BOOST_TEST(rv.has_value()); + BOOST_TEST_EQ(rv->params.size(), 1u); + check_param(*rv, "version", "2"); + } + + // Without group + { + auto rv = detail::match_route("/api", pat, opts()); + BOOST_TEST(rv.has_value()); + BOOST_TEST_EQ(rv->params.size(), 0u); + } + } + + void + testGroupTextOnly() + { + auto pat = parse("/file{.json}"); + + // With extension + { + auto rv = detail::match_route("/file.json", pat, opts()); + BOOST_TEST(rv.has_value()); + } + + // Without extension + { + auto rv = detail::match_route("/file", pat, opts()); + BOOST_TEST(rv.has_value()); + } + } + + void + testGroupNested() + { + auto pat = parse("/a{/b{/c}}"); + + // All levels + { + auto rv = detail::match_route("/a/b/c", pat, opts()); + BOOST_TEST(rv.has_value()); + } + + // Two levels + { + auto rv = detail::match_route("/a/b", pat, opts()); + BOOST_TEST(rv.has_value()); + } + + // One level + { + auto rv = detail::match_route("/a", pat, opts()); + BOOST_TEST(rv.has_value()); + } + } + + void + testGroupMultiple() + { + auto pat = parse("{/a}{/b}"); + + // Both groups + { + auto rv = detail::match_route("/a/b", pat, opts()); + BOOST_TEST(rv.has_value()); + } + + // First only + { + auto rv = detail::match_route("/a", pat, opts()); + BOOST_TEST(rv.has_value()); + } + + // Second only + { + auto rv = detail::match_route("/b", pat, opts()); + BOOST_TEST(rv.has_value()); + } + + // Neither + { + auto rv = detail::match_route("", pat, opts()); + BOOST_TEST(rv.has_value()); + } + } + + void + testGroupEmpty() + { + auto pat = parse("/path{}"); + + auto rv = detail::match_route("/path", pat, opts()); + BOOST_TEST(rv.has_value()); + } + + void + testGroupAtEnd() + { + auto pat = parse("/required{/optional}"); + + // With optional + { + auto rv = detail::match_route("/required/optional", pat, opts()); + BOOST_TEST(rv.has_value()); + } + + // Without optional + { + auto rv = detail::match_route("/required", pat, opts()); + BOOST_TEST(rv.has_value()); + } + } + + //-------------------------------------------- + // Non-Matching Cases + //-------------------------------------------- + + void + testNonMatching() + { + auto pat = parse("/users/:id"); + + // Path too short + { + auto rv = detail::match_route("/users", pat, opts()); + BOOST_TEST(rv.has_error()); + } + + // Wrong literal + { + auto rv = detail::match_route("/posts/123", pat, opts()); + BOOST_TEST(rv.has_error()); + } + } + + //-------------------------------------------- + // Edge Cases + //-------------------------------------------- + + void + testManyParams() + { + auto pat = parse("/:a/:b/:c/:d/:e/:f/:g/:h/:i/:j"); + + auto rv = detail::match_route("/1/2/3/4/5/6/7/8/9/10", pat, opts()); + BOOST_TEST(rv.has_value()); + BOOST_TEST_EQ(rv->params.size(), 10u); + check_param(*rv, "a", "1"); + check_param(*rv, "j", "10"); + } + + void + testConsecutiveSlashes() + { + auto pat = parse("/a//b"); + + auto rv = detail::match_route("/a//b", pat, opts()); + BOOST_TEST(rv.has_value()); + } + + //-------------------------------------------- + // Integration / Real-world patterns + //-------------------------------------------- + + void + testExpressStyle() + { + auto pat = parse("/api/v1/users/:userId/posts/:postId"); + + auto rv = detail::match_route("/api/v1/users/42/posts/99", pat, opts()); + BOOST_TEST(rv.has_value()); + check_param(*rv, "userId", "42"); + check_param(*rv, "postId", "99"); + } + + void + testGitHubStyle() + { + auto pat = parse("/:owner/:repo/blob/:branch/*path"); + + auto rv = detail::match_route("/john/myrepo/blob/main/src/index.js", pat, opts()); + BOOST_TEST(rv.has_value()); + check_param(*rv, "owner", "john"); + check_param(*rv, "repo", "myrepo"); + check_param(*rv, "branch", "main"); + check_param(*rv, "path", "src/index.js"); + } + + void + testFileExtension() + { + auto pat = parse("/file{.:ext}"); + + // With extension + { + auto rv = detail::match_route("/file.txt", pat, opts()); + BOOST_TEST(rv.has_value()); + check_param(*rv, "ext", "txt"); + } + + // Without extension + { + auto rv = detail::match_route("/file", pat, opts()); + BOOST_TEST(rv.has_value()); + BOOST_TEST_EQ(rv->params.size(), 0u); + } + } + + void + run() + { + // Text matching + testTextExact(); + testTextCaseSensitive(); + testTextRoot(); + testTextEmpty(); + + // Parameter extraction + testParamSingle(); + testParamMultiple(); + testParamAdjacent(); + testParamAtStart(); + testParamAtEnd(); + testParamEmpty(); + + // Wildcard extraction + testWildcardSimple(); + testWildcardAtRoot(); + testWildcardEmpty(); + + // Option combinations + testOptionsCombinations(); + + // Strict mode + testStrictTrailingSlash(); + testStrictWithParam(); + + // End mode + testEndModeFull(); + testEndModePrefix(); + testEndModeWithParams(); + + // Groups + testGroupMatches(); + testGroupTextOnly(); + testGroupNested(); + testGroupMultiple(); + testGroupEmpty(); + testGroupAtEnd(); + + // Non-matching + testNonMatching(); + + // Edge cases + testManyParams(); + testConsecutiveSlashes(); + + // Integration + testExpressStyle(); + testGitHubStyle(); + testFileExtension(); + } +}; + +TEST_SUITE( + route_match_test, + "boost.http.server.route_match"); + } // http } // boost From aabeb9b29dc2085986662fa2b236fc6aca7ea0d8 Mon Sep 17 00:00:00 2001 From: Vinnie Falco Date: Sun, 1 Feb 2026 19:00:44 -0800 Subject: [PATCH 4/6] Use route pattern matching in router --- doc/modules/ROOT/nav.adoc | 1 + doc/modules/ROOT/pages/router.adoc | 5 +- .../ROOT/pages/server/route-patterns.adoc | 508 ++++++++ include/boost/http/server/basic_router.hpp | 37 + include/boost/http/server/router_types.hpp | 11 + src/server/detail/pct_decode.cpp | 6 +- src/server/detail/route_match.cpp | 66 +- src/server/detail/route_match.hpp | 8 +- src/server/detail/route_rule.hpp | 305 ----- src/server/detail/router_base.cpp | 1 - src/server/detail/router_base.hpp | 5 + src/server/flat_router.cpp | 27 +- src/server/route_abnf.cpp | 34 +- test/unit/server/flat_router.cpp | 1079 +++++++++++++++++ test/unit/server/route_abnf.cpp | 1047 ---------------- 15 files changed, 1726 insertions(+), 1414 deletions(-) create mode 100644 doc/modules/ROOT/pages/server/route-patterns.adoc delete mode 100644 src/server/detail/route_rule.hpp delete mode 100644 test/unit/server/route_abnf.cpp diff --git a/doc/modules/ROOT/nav.adoc b/doc/modules/ROOT/nav.adoc index 9b7c433d..24aa62a4 100644 --- a/doc/modules/ROOT/nav.adoc +++ b/doc/modules/ROOT/nav.adoc @@ -9,6 +9,7 @@ * xref:Message.adoc[] * Server ** xref:server/router.adoc[Router] +** xref:server/route-patterns.adoc[Route Patterns] ** xref:server/bcrypt.adoc[BCrypt Password Hashing] // ** xref:server/middleware.adoc[Middleware] // ** xref:server/errors.adoc[Error Handling] diff --git a/doc/modules/ROOT/pages/router.adoc b/doc/modules/ROOT/pages/router.adoc index 9ec2a83c..4539a1fb 100644 --- a/doc/modules/ROOT/pages/router.adoc +++ b/doc/modules/ROOT/pages/router.adoc @@ -135,11 +135,14 @@ Route paths support named parameters and wildcards: |`/users/42/posts/7` |Multiple parameters -|`/files/*` +|`/files/*path` |`/files/docs/readme.txt` |Wildcard captures remainder |=== +For the complete pattern syntax including optional groups, escaping, and +quoted names, see xref:server/route-patterns.adoc[Route Patterns]. + Access captured parameters in handlers: [source,cpp] diff --git a/doc/modules/ROOT/pages/server/route-patterns.adoc b/doc/modules/ROOT/pages/server/route-patterns.adoc new file mode 100644 index 00000000..c7675ce0 --- /dev/null +++ b/doc/modules/ROOT/pages/server/route-patterns.adoc @@ -0,0 +1,508 @@ +// +// Copyright (c) 2025 Vinnie Falco (vinnie.falco@gmail.com) +// +// Distributed under the Boost Software License, Version 1.0. (See accompanying +// file LICENSE_1_0.txt or copy at https://www.boost.org/LICENSE_1_0.txt) +// +// Official repository: https://github.com/cppalliance/http +// + += Route Patterns + +When a request arrives at your server, the router examines its path and +decides which handler should respond. Route patterns describe what paths +a handler accepts. A pattern like `/users` matches only that exact path, +but patterns can do much more. + +== Your First Pattern + +The simplest pattern is a literal path: + +[source,cpp] +---- +router.add(method::get, "/health", health_check); +---- + +This handler responds when a client requests `GET /health`. The pattern +`/health` matches the path `/health` exactly. + +Most applications need multiple routes: + +[source,cpp] +---- +router.add(method::get, "/", serve_homepage); +router.add(method::get, "/about", serve_about); +router.add(method::get, "/contact", serve_contact); +---- + +Literal patterns work well for static pages, but what about dynamic content? + +== Named Parameters + +Consider a user profile page. You could write a separate route for every +user, but that doesn't scale. Instead, use a named parameter: + +[source,cpp] +---- +router.add(method::get, "/users/:id", show_user); +---- + +The colon introduces a parameter named `id`. This pattern matches +`/users/alice`, `/users/42`, or `/users/john-doe`. Inside the handler, +retrieve the captured value: + +[source,cpp] +---- +router.add(method::get, "/users/:id", + [](route_params& p) + { + auto user_id = p.param("id"); // "alice", "42", etc. + // ... + return route_done; + }); +---- + +Parameters capture text until the next `/` or end of path. They must +capture at least one character, so `/users/` without an ID does not match. + +=== Multiple Parameters + +Patterns can include several parameters. A blog might organize posts by +author and slug: + +[source,cpp] +---- +router.add(method::get, "/blog/:author/:slug", show_post); +---- + +This matches `/blog/jane/my-first-post` with `author = "jane"` and +`slug = "my-first-post"`. + +REST APIs commonly nest resources this way: + +[source,cpp] +---- +router.add(method::get, "/users/:userId/orders/:orderId", show_order); +---- + +For the path `/users/1001/orders/5042`, the handler receives +`userId = "1001"` and `orderId = "5042"`. + +=== Parameters with Separators + +Parameters can appear anywhere in a pattern, separated by literal text. +An airline flight lookup might use: + +[source,cpp] +---- +router.add(method::get, "/flights/:from-:to", find_flights); +---- + +This matches `/flights/LAX-JFK` with `from = "LAX"` and `to = "JFK"`. +The hyphen acts as a delimiter, allowing both parameters to capture +their respective values. + +Similarly, a taxonomy browser might use dots: + +[source,cpp] +---- +router.add(method::get, "/species/:genus.:species", show_species); +---- + +For `/species/Canis.lupus`, this captures `genus = "Canis"` and +`species = "lupus"`. + +== Wildcards + +Parameters stop at `/` characters. When you need to capture a path +containing slashes, use a wildcard: + +[source,cpp] +---- +router.add(method::get, "/files/*filepath", serve_file); +---- + +The asterisk introduces a wildcard named `filepath`. Unlike parameters, +wildcards capture everything to the end of the path, including `/` +characters. This pattern matches: + +- `/files/readme.txt` with `filepath = "readme.txt"` +- `/files/docs/manual.pdf` with `filepath = "docs/manual.pdf"` +- `/files/src/lib/util.cpp` with `filepath = "src/lib/util.cpp"` + +A wildcard must capture at least one character, so `/files/` alone does +not match. + +=== GitHub-Style Routes + +A version control browser might use this pattern: + +[source,cpp] +---- +router.add(method::get, "/:owner/:repo/blob/:branch/*path", show_file); +---- + +For the path `/cppalliance/http/blob/develop/include/boost/http.hpp`: + +- `owner = "cppalliance"` +- `repo = "http"` +- `branch = "develop"` +- `path = "include/boost/http.hpp"` + +The named parameters capture individual segments while the wildcard +captures the remainder. + +== Optional Groups + +Some parts of a path may be optional. An API versioning scheme might +accept both `/api` and `/api/v2`: + +[source,cpp] +---- +router.add(method::get, "/api{/v:version}/users", list_users); +---- + +The braces define an optional group. This pattern matches: + +- `/api/users` (no version parameter captured) +- `/api/v1/users` with `version = "1"` +- `/api/v2/users` with `version = "2"` + +Groups follow all-or-nothing matching. The entire group content must +match, or the group is skipped entirely. + +=== Optional File Extensions + +A resource that supports multiple formats might use: + +[source,cpp] +---- +router.add(method::get, "/data/:id{.:format}", serve_data); +---- + +This matches: + +- `/data/report` with `id = "report"` (no format) +- `/data/report.json` with `id = "report"`, `format = "json"` +- `/data/report.xml` with `id = "report"`, `format = "xml"` + +The handler can check whether `format` was captured to determine the +response content type. + +=== Nested Groups + +Groups can contain other groups for multi-level optional paths: + +[source,cpp] +---- +router.add(method::get, "/archive{/:year{/:month{/:day}}}", list_archive); +---- + +This matches: + +- `/archive` (list all) +- `/archive/2025` (list year) +- `/archive/2025/02` (list month) +- `/archive/2025/02/01` (list day) + +Each level of specificity provides more parameters. + +== Escaping Special Characters + +The characters `:`, `*`, `{`, `}`, and `\` have special meaning in +patterns. To match them literally, prefix with backslash: + +[source,cpp] +---- +router.add(method::get, "/config\\:main", show_config); +---- + +This matches the literal path `/config:main`. Without the backslash, +`:main` would be interpreted as a parameter. + +Other escape examples: + +- `/path\\*star` matches `/path*star` +- `/path\\{brace\\}` matches `/path{brace}` +- `/path\\\\slash` matches `/path\slash` + +== Quoted Parameter Names + +Standard parameter names follow identifier rules: they start with a +letter, underscore, or dollar sign, followed by letters, digits, +underscores, or dollar signs. + +For parameter names containing spaces or other characters, use quotes: + +[source,cpp] +---- +router.add(method::get, "/query/:\"search term\"", search); +---- + +Quoted names can contain any characters except unescaped quotes. Escape +quotes inside the name with backslash: + +[source,cpp] +---- +router.add(method::get, "/say/:\"greeting \\\"message\\\"\"", greet); +---- + +== Grammar Reference + +The pattern syntax follows this grammar: + +[source,abnf] +---- +path = *token + +token = text / param / wildcard / group + +text = 1*( char / escaped ) ; literal characters + +param = ":" name ; named parameter + +wildcard = "*" name ; wildcard parameter + +group = "{" *token "}" ; optional group + +name = identifier / quoted-name + +identifier = id-start *id-continue +id-start = "$" / "_" / ALPHA +id-continue= "$" / "_" / ALNUM + +quoted-name= DQUOTE 1*quoted-char DQUOTE +quoted-char= escaped / %x20-21 / %x23-5B / %x5D-7E ; not " or \ + +escaped = "\" CHAR ; any escaped character + +char = +special = "{" / "}" / "(" / ")" / "[" / "]" / "+" / "?" / "!" / ":" / "*" / "\" +---- + +The characters `( ) [ ] + ? !` are reserved. Patterns containing these +characters unescaped produce a parse error. + +== Matching Behavior + +Understanding how the router matches paths helps write correct patterns. + +=== How Parameters Match + +A parameter captures characters until it encounters: + +1. The next literal character in the pattern +2. A `/` character +3. The end of the path + +Given the pattern `/:a-:b`, matching against `/hello-world`: + +1. `:a` starts capturing at `h` +2. `:a` captures until it sees `-` (the next literal) +3. `:a` captures `hello` +4. Literal `-` matches `-` +5. `:b` captures `world` to end of path + +=== How Wildcards Match + +A wildcard captures from its position to the end of the path, including +all `/` characters. There can be only one wildcard per pattern, and it +must appear at the end. + +=== How Groups Match + +Groups attempt to match their contents. If successful, any parameters +inside are captured. If unsuccessful, the group is skipped and matching +continues after the group. + +For `/api{/v:version}` matching `/api/v2/users`: + +1. `/api` matches `/api` +2. Group attempts: `/v` matches `/v`, `:version` captures `2` +3. Group succeeded +4. Pattern ends, but path has `/users` remaining + +This pattern requires `end = false` (prefix matching) or an additional +wildcard to match the full path. + +== Router Options + +Three options affect how patterns match paths. + +=== Case Sensitivity + +By default, matching is case-insensitive. The pattern `/Users` matches +`/users`, `/USERS`, and `/Users`. + +[source,cpp] +---- +router_options opts; +opts.case_sensitive(true); + +basic_router router(opts); +router.add(method::get, "/Users/:id", show_user); +---- + +With case-sensitive matching, `/Users/alice` matches but `/users/alice` +does not. + +=== Strict Mode + +By default, trailing slashes are ignored. The pattern `/api` matches +both `/api` and `/api/`. + +[source,cpp] +---- +router_options opts; +opts.strict(true); + +basic_router router(opts); +router.add(method::get, "/api", api_root); +---- + +With strict matching, `/api` matches but `/api/` does not. + +=== Prefix Matching + +Routes registered with `add()` require the pattern to match the entire +path. Routes registered with `use()` for middleware match path prefixes. + +Middleware on `/api` matches: + +- `/api` +- `/api/` +- `/api/users` +- `/api/users/123` + +Route handlers on `/api` match only `/api` (and `/api/` in non-strict mode). + +== Pattern Examples + +[cols="2,2,1,2"] +|=== +|Pattern |Path |Match |Captured Parameters + +|`/users` +|`/users` +|Yes +|(none) + +|`/users` +|`/users/` +|Yes +|(none, non-strict) + +|`/users` +|`/users/123` +|No +| + +|`/users/:id` +|`/users/42` +|Yes +|`id = "42"` + +|`/users/:id` +|`/users/` +|No +| + +|`/users/:id/posts/:pid` +|`/users/5/posts/99` +|Yes +|`id = "5"`, `pid = "99"` + +|`/files/*path` +|`/files/a/b.txt` +|Yes +|`path = "a/b.txt"` + +|`/files/*path` +|`/files/` +|No +| + +|`/api{/v:ver}` +|`/api` +|Yes +|(none) + +|`/api{/v:ver}` +|`/api/v2` +|Yes +|`ver = "2"` + +|`/:a-:b` +|`/foo-bar` +|Yes +|`a = "foo"`, `b = "bar"` + +|`/file{.:ext}` +|`/file` +|Yes +|(none) + +|`/file{.:ext}` +|`/file.json` +|Yes +|`ext = "json"` + +|`/path\:literal` +|`/path:literal` +|Yes +|(none) + +|`/:owner/:repo/*path` +|`/org/proj/src/main.cpp` +|Yes +|`owner = "org"`, `repo = "proj"`, `path = "src/main.cpp"` +|=== + +== Error Cases + +These patterns are invalid and produce parse errors: + +[cols="2,3"] +|=== +|Pattern |Error + +|`/users/:` +|Missing parameter name after `:` + +|`/files/*` +|Missing wildcard name after `*` + +|`/path{unclosed` +|Unclosed group (missing `}`) + +|`/path}extra` +|Unexpected `}` without opening `{` + +|`/path(group)` +|Reserved character `(` not allowed + +|`/path[bracket]` +|Reserved character `[` not allowed + +|`/path+` +|Reserved character `+` not allowed + +|`/path?` +|Reserved character `?` not allowed + +|`/path!` +|Reserved character `!` not allowed + +|`:""` (empty quotes) +|Empty quoted parameter name + +|`:"unterminated` +|Unterminated quoted string + +|`/path\` +|Trailing backslash with nothing to escape +|=== + +== See Also + +* xref:router.adoc[Router] - request dispatch and handler registration diff --git a/include/boost/http/server/basic_router.hpp b/include/boost/http/server/basic_router.hpp index 9a620c45..50ec1fce 100644 --- a/include/boost/http/server/basic_router.hpp +++ b/include/boost/http/server/basic_router.hpp @@ -161,6 +161,43 @@ struct router_options assignment do not create new instances; they all refer to the same underlying data. + @par Path Pattern Syntax + + Route patterns define which request paths match a route. Patterns + support literal text, named parameters, wildcards, and optional + groups. The syntax is inspired by Express.js path-to-regexp. + + @code + path = *token + token = text / param / wildcard / group + text = 1*( char / escaped ) ; literal characters + param = ":" name ; captures segment until '/' + wildcard = "*" name ; captures everything to end + group = "{" *token "}" ; optional section + name = identifier / quoted ; plain or quoted name + identifier = ( "$" / "_" / ALPHA ) *( "$" / "_" / ALNUM ) + quoted = DQUOTE 1*qchar DQUOTE ; allows spaces, punctuation + escaped = "\" CHAR ; literal special character + @endcode + + Named parameters capture path segments. A parameter matches any + characters except `/` and must capture at least one character: + + - `/users/:id` matches `/users/42`, capturing `id = "42"` + - `/users/:userId/posts/:postId` matches `/users/5/posts/99` + - `/:from-:to` matches `/LAX-JFK`, capturing `from = "LAX"`, `to = "JFK"` + + Wildcards capture everything from their position to the end of + the path, including `/` characters. Optional groups match + all-or-nothing: + + - `/api{/v:version}` matches both `/api` and `/api/v2` + - `/file{.:ext}` matches `/file` and `/file.json` + + Reserved characters `( ) [ ] + ? !` are not allowed in patterns. + For wildcards, escaping, and quoted names, see the Route Patterns + documentation. + @par Handlers Regular handlers are invoked for matching routes and have this diff --git a/include/boost/http/server/router_types.hpp b/include/boost/http/server/router_types.hpp index e0883823..db3ea6df 100644 --- a/include/boost/http/server/router_types.hpp +++ b/include/boost/http/server/router_types.hpp @@ -21,6 +21,8 @@ #include #include #include +#include +#include namespace boost { namespace http { @@ -373,6 +375,13 @@ class route_params_base : public route_params_base_privates */ core::string_view path; + /** Captured route parameters + + Contains name-value pairs extracted from the path + by matching :param and *wildcard tokens. + */ + std::vector> params; + struct match_result; private: @@ -387,6 +396,8 @@ class route_params_base : public route_params_base_privates struct route_params_base:: match_result { + std::vector> params_; + void adjust_path( route_params_base& p, std::size_t n) diff --git a/src/server/detail/pct_decode.cpp b/src/server/detail/pct_decode.cpp index 665ea3eb..33716d9e 100644 --- a/src/server/detail/pct_decode.cpp +++ b/src/server/detail/pct_decode.cpp @@ -93,7 +93,8 @@ pct_decode( #endif } -// decode all percent escapes except slashes '/' and '\' +// decode all percent escapes except forward slash '/' +// (backslash is decoded since it's not a path separator in URLs) std::string pct_decode_path( urls::pct_string_view s) @@ -132,8 +133,7 @@ pct_decode_path( goto invalid; #endif char c = d0 * 16 + d1; - if( c != '/' && - c != '\\') + if(c != '/') { result.push_back(c); continue; diff --git a/src/server/detail/route_match.cpp b/src/server/detail/route_match.cpp index 3f8f0a3f..0b1aed29 100644 --- a/src/server/detail/route_match.cpp +++ b/src/server/detail/route_match.cpp @@ -32,8 +32,13 @@ matcher( , slash_(pat == "/") { if(! slash_) - pv_ = grammar::parse( - decoded_pat_, detail::path_rule).value(); + { + auto rv = parse_route_pattern(decoded_pat_); + if(rv.has_error()) + ec_ = rv.error(); + else + pattern_ = std::move(rv.value()); + } } bool @@ -44,53 +49,28 @@ operator()( match_result& mr) const { BOOST_ASSERT(! p.path.empty()); - if( slash_ && ( - ! end_ || - p.path == "/")) + + // Root pattern special case + if(slash_ && (!end_ || p.path == "/")) { - // params = {}; mr.adjust_path(p, 0); return true; } - auto it = p.path.data(); - auto pit = pv_.segs.begin(); - auto const path_end = it + p.path.size(); - auto const pend = pv_.segs.end(); - while(it != path_end && pit != pend) - { - // prefix has to match - auto s = core::string_view(it, path_end); - if(! p.case_sensitive) - { - if(pit->prefix.size() > s.size()) - return false; - s = s.substr(0, pit->prefix.size()); - //if(! grammar::ci_is_equal(s, pit->prefix)) - if(! ci_is_equal(s, pit->prefix)) - return false; - } - else - { - if(! s.starts_with(pit->prefix)) - return false; - } - it += pit->prefix.size(); - ++pit; - } - if(end_) - { - // require full match - if( it != path_end || - pit != pend) - return false; - } - else if(pit != pend) - { + + // Convert bitflags to match_options + match_options opts{ + p.case_sensitive, + p.strict, + end_ + }; + + auto rv = match_route(p.path, pattern_, opts); + if(rv.has_error()) return false; - } - // number of matching characters - auto const n = it - p.path.data(); + + auto const n = rv->matched_length; mr.adjust_path(p, n); + mr.params_ = std::move(rv->params); return true; } diff --git a/src/server/detail/route_match.hpp b/src/server/detail/route_match.hpp index 7232d315..4d943f7b 100644 --- a/src/server/detail/route_match.hpp +++ b/src/server/detail/route_match.hpp @@ -12,7 +12,7 @@ #include #include -#include "src/server/detail/route_rule.hpp" +#include "src/server/route_abnf.hpp" #include "src/server/detail/stable_string.hpp" #include #include @@ -35,9 +35,13 @@ struct router_base::matcher route_params_base& p, match_result& mr) const; + // Returns error from pattern parsing, or empty if valid + system::error_code error() const noexcept { return ec_; } + private: + system::error_code ec_; std::string allow_header_; - path_rule_t::value_type pv_; + route_pattern pattern_; std::vector custom_verbs_; // 16 bytes (pointer + size) diff --git a/src/server/detail/route_rule.hpp b/src/server/detail/route_rule.hpp deleted file mode 100644 index 3b962896..00000000 --- a/src/server/detail/route_rule.hpp +++ /dev/null @@ -1,305 +0,0 @@ -// -// Copyright (c) 2025 Vinnie Falco (vinnie dot falco at gmail dot com) -// -// Distributed under the Boost Software License, Version 1.0. (See accompanying -// file LICENSE_1_0.txt or copy at http://www.boost.org/LICENSE_1_0.txt) -// -// Official repository: https://github.com/cppalliance/http -// - -#ifndef BOOST_HTTP_SERVER_DETAIL_ROUTE_RULE_HPP -#define BOOST_HTTP_SERVER_DETAIL_ROUTE_RULE_HPP - -#include -#include -#include -#include -#include -#include -#include - -#include "src/server/detail/stable_string.hpp" - -namespace boost { -namespace http { -namespace detail { - -namespace grammar = urls::grammar; - -/** Rule for parsing a non-empty token of chars - - @par Requires - @code - std::is_empty::value == true - @endcode -*/ -template -struct token_rule -{ - using value_type = core::string_view; - - auto - parse( - char const*& it, - char const* end) const noexcept -> - system::result - { - static_assert(std::is_empty::value, ""); - if(it == end) - return grammar::error::syntax; - auto it1 = grammar::find_if_not(it, end, CharSet{}); - if(it1 == it) - return grammar::error::mismatch; - auto s = core::string_view(it, it1); - it = it1; - return s; - } -}; - -//------------------------------------------------ - -/* -route-pattern = *( "/" segment ) [ "/" ] -segment = literal-segment / param-segment -literal-segment = 1*( unreserved-char ) -unreserved-char = %x21-2F / %x30-3B / %x3D-5A / %x5C-7E ; all printable except slash -param-segment = param-prefix param-name [ constraint ] [ modifier ] -param-prefix = ":" / "*" ; either named param ":" or named wildcard "*" -param-name = ident -constraint = "(" 1*( constraint-char ) ")" -modifier = "?" / "*" / "+" -ident = ALPHA *( ALPHA / DIGIT / "_" ) -constraint-char = %x20-7E except ( ")" ) -*/ - -//------------------------------------------------ - -struct unreserved_char -{ - constexpr - bool - operator()(char ch) const noexcept - { - return ch != '/' && ( - (ch >= 0x21 && ch <= 0x2F) || - (ch >= 0x30 && ch <= 0x3B) || - (ch >= 0x3D && ch <= 0x5A) || - (ch >= 0x5C && ch <= 0x7E)); - } -}; - -struct constraint_char -{ - constexpr - bool - operator()(char ch) const noexcept - { - return ch >= 0x20 && ch <= 0x7E && ch != ')'; - } -}; - -struct ident_char -{ - constexpr - bool - operator()(char ch) const noexcept - { - return - (ch >= 'a' && ch <= 'z') || - (ch >= '0' && ch <= '9') || - (ch >= 'A' && ch <= 'Z') || - (ch == '_'); - } -}; - -constexpr struct -{ - // empty for no constraint - using value_type = core::string_view; - - auto - parse( - char const*& it, - char const* end) const noexcept -> - system::result - { - if(it == end || *it != '(') - return ""; - if(it == end) - BOOST_HTTP_RETURN_EC( - grammar::error::syntax); - auto it0 = it; - it = grammar::find_if_not( - it, end, constraint_char{}); - if(it - it0 <= 1) - { - // too small - it = it0; - BOOST_HTTP_RETURN_EC( - grammar::error::syntax); - } - if(it == end) - { - it = it0; - BOOST_HTTP_RETURN_EC( - grammar::error::syntax); - } - if(*it != ')') - { - it0 = it; - BOOST_HTTP_RETURN_EC( - grammar::error::syntax); - } - return core::string_view(++it0, it++); - } -} constraint_rule{}; - -constexpr struct -{ - using value_type = core::string_view; - - auto - parse( - char const*& it, - char const* end) const noexcept -> - system::result - { - if(it == end) - BOOST_HTTP_RETURN_EC( - grammar::error::syntax); - if(! grammar::alpha_chars(*it)) - BOOST_HTTP_RETURN_EC( - grammar::error::syntax); - auto it0 = it++; - it = grammar::find_if_not( - it, end, ident_char{}); - return core::string_view(it0, it); - } -} param_name_rule{}; - -//------------------------------------------------ - -/** A unit of matching in a route pattern -*/ -struct route_seg -{ - // literal prefix which must match - core::string_view prefix; - core::string_view name; - core::string_view constraint; - char ptype = 0; // ':' | '?' | NULL - char modifier = 0; -}; - -struct param_segment_rule_t -{ - using value_type = route_seg; - - auto - parse( - char const*& it, - char const* end) const noexcept -> - system::result - { - if(it == end) - BOOST_HTTP_RETURN_EC( - grammar::error::syntax); - if(*it != ':' && *it != '*') - BOOST_HTTP_RETURN_EC( - grammar::error::mismatch); - value_type v; - v.ptype = *it++; - { - // param-name - auto rv = grammar::parse( - it, end, param_name_rule); - if(rv.has_error()) - return rv.error(); - v.name = rv.value(); - } - { - // constraint - auto rv = grammar::parse( - it, end, constraint_rule); - if( rv.has_error()) - return rv.error(); - v.constraint = rv.value(); - } - // modifier - if( it != end && ( - *it == '?' || *it == '*' || *it == '+')) - v.modifier = *it++; - return v; - } -}; - -constexpr param_segment_rule_t param_segment_rule{}; - -//------------------------------------------------ - -constexpr token_rule literal_segment_rule{}; - -//------------------------------------------------ - -struct path_rule_t -{ - struct value_type - { - std::vector segs; - }; - - auto - parse( - char const*& it0, - char const* const end) const -> - system::result - { - value_type rv; - auto it = it0; - auto it1 = it; - while(it != end) - { - if( *it == ':' || - *it == '*') - { - auto const it2 = it; - auto rv1 = urls::grammar::parse( - core::string_view(it, end), - param_segment_rule); - if(rv1.has_error()) - return rv1.error(); - route_seg rs = rv1.value(); - rs.prefix = { it2, it1 }; - rv.segs.push_back(rs); - it1 = it; - continue; - } - ++it; - } - if(it1 != it) - { - route_seg rs; - rs.prefix = core::string_view(it1, end); - rv.segs.push_back(rs); - } - it0 = it0 + (it - it1); - // gcc 7 bug workaround - return system::result(std::move(rv)); - } -}; - -constexpr path_rule_t path_rule{}; - -struct route_match -{ - using iterator = urls::segments_encoded_view::iterator; - - urls::segments_encoded_view base; - urls::segments_encoded_view path; -}; - -} // detail -} // http -} // boost - -#endif diff --git a/src/server/detail/router_base.cpp b/src/server/detail/router_base.cpp index 97325bcb..06ed470e 100644 --- a/src/server/detail/router_base.cpp +++ b/src/server/detail/router_base.cpp @@ -15,7 +15,6 @@ #include #include #include "src/server/detail/route_match.hpp" -#include "src/server/detail/route_rule.hpp" /* diff --git a/src/server/detail/router_base.hpp b/src/server/detail/router_base.hpp index 00d99f53..6bd17fce 100644 --- a/src/server/detail/router_base.hpp +++ b/src/server/detail/router_base.hpp @@ -11,6 +11,7 @@ #define BOOST_HTTP_SRC_SERVER_DETAIL_ROUTER_BASE_HPP #include +#include #include "src/server/detail/route_match.hpp" namespace boost { @@ -94,6 +95,8 @@ struct router_base::layer handlers hn) : match(pat, false) { + if(match.error()) + throw_invalid_argument(); entries.reserve(hn.n); for(std::size_t i = 0; i < hn.n; ++i) entries.emplace_back(std::move(hn.p[i])); @@ -104,6 +107,8 @@ struct router_base::layer std::string_view pat) : match(pat, true) { + if(match.error()) + throw_invalid_argument(); } }; diff --git a/src/server/flat_router.cpp b/src/server/flat_router.cpp index 296d495b..5f65297e 100644 --- a/src/server/flat_router.cpp +++ b/src/server/flat_router.cpp @@ -348,6 +348,13 @@ struct flat_router::impl break; } + // Copy captured params to route_params_base + if(!mr.params_.empty()) + { + for(auto& param : mr.params_) + p.params.push_back(std::move(param)); + } + // Mark this depth as matched if(cm.depth_ < detail::router_base::max_path_depth) matched_at_depth[cm.depth_] = check_idx; @@ -510,10 +517,9 @@ dispatch( p.verb_str_.clear(); p.ec_.clear(); p.ep_ = nullptr; + p.params.clear(); p.decoded_path_ = detail::pct_decode_path(url.encoded_path()); - p.base_path = { p.decoded_path_.data(), 0 }; - p.path = p.decoded_path_; - if(p.decoded_path_.back() != '/') + if(p.decoded_path_.empty() || p.decoded_path_.back() != '/') { p.decoded_path_.push_back('/'); p.addedSlash_ = true; @@ -522,6 +528,11 @@ dispatch( { p.addedSlash_ = false; } + // Set path views after potential reallocation from push_back + // Exclude added trailing slash from visible path, but keep "/" if empty + p.base_path = { p.decoded_path_.data(), 0 }; + auto const subtract = (p.addedSlash_ && p.decoded_path_.size() > 1) ? 1 : 0; + p.path = { p.decoded_path_.data(), p.decoded_path_.size() - subtract }; return impl_->dispatch_loop(p, verb == http::method::options); } @@ -559,10 +570,9 @@ dispatch( p.verb_str_.clear(); p.ec_.clear(); p.ep_ = nullptr; + p.params.clear(); p.decoded_path_ = detail::pct_decode_path(url.encoded_path()); - p.base_path = { p.decoded_path_.data(), 0 }; - p.path = p.decoded_path_; - if(p.decoded_path_.back() != '/') + if(p.decoded_path_.empty() || p.decoded_path_.back() != '/') { p.decoded_path_.push_back('/'); p.addedSlash_ = true; @@ -571,6 +581,11 @@ dispatch( { p.addedSlash_ = false; } + // Set path views after potential reallocation from push_back + // Exclude added trailing slash from visible path, but keep "/" if empty + p.base_path = { p.decoded_path_.data(), 0 }; + auto const subtract = (p.addedSlash_ && p.decoded_path_.size() > 1) ? 1 : 0; + p.path = { p.decoded_path_.data(), p.decoded_path_.size() - subtract }; return impl_->dispatch_loop(p, is_options); } diff --git a/src/server/route_abnf.cpp b/src/server/route_abnf.cpp index d4d4b276..f9855d61 100644 --- a/src/server/route_abnf.cpp +++ b/src/server/route_abnf.cpp @@ -435,15 +435,20 @@ class route_matcher return true; } - // Match param token - capture until '/' or end - bool match_param(std::string const& name) + // Match param token - capture until stop_char, '/' or end + bool match_param(std::string const& name, char stop_char = '\0') { if(at_end()) return false; auto start = pos_; while(pos_ < path_.size() && path_[pos_] != '/') + { + // Stop at delimiter if specified + if(stop_char != '\0' && path_[pos_] == stop_char) + break; ++pos_; + } // Param must capture at least one character if(pos_ == start) @@ -474,19 +479,36 @@ class route_matcher return true; } + // Get the first character of the next meaningful token + // Returns '\0' if none exists or next token is not text + static char + get_stop_char( + std::vector const& tokens, + std::size_t next_idx) + { + if(next_idx >= tokens.size()) + return '\0'; + + auto const& next = tokens[next_idx]; + if(next.type == route_token_type::text && !next.value.empty()) + return next.value[0]; + + return '\0'; + } + // Match a sequence of tokens bool match_tokens(std::vector const& tokens) { - for(auto const& token : tokens) + for(std::size_t i = 0; i < tokens.size(); ++i) { - if(!match_token(token)) + if(!match_token(tokens[i], get_stop_char(tokens, i + 1))) return false; } return true; } // Match a single token - bool match_token(route_token const& token) + bool match_token(route_token const& token, char stop_char = '\0') { switch(token.type) { @@ -494,7 +516,7 @@ class route_matcher return match_text(token.value); case route_token_type::param: - return match_param(token.value); + return match_param(token.value, stop_char); case route_token_type::wildcard: return match_wildcard(token.value); diff --git a/test/unit/server/flat_router.cpp b/test/unit/server/flat_router.cpp index 91ee96d0..2fa1c8be 100644 --- a/test/unit/server/flat_router.cpp +++ b/test/unit/server/flat_router.cpp @@ -224,6 +224,1029 @@ struct flat_router_test BOOST_TEST(captured_allow.find("PUT") != std::string::npos); } + //-------------------------------------------- + // Route Pattern Integration Tests + //-------------------------------------------- + + // Helper to find param value by name + static std::string + get_param(params const& p, std::string const& name) + { + for(auto const& kv : p.params) + if(kv.first == name) + return kv.second; + return ""; + } + + // No-op handler for pattern validation tests + static route_task noop(params&) { co_return route_done; } + + void testParamCapture() + { + bool handler_called = false; + std::string captured_id; + + test_router r; + r.add(http::method::get, "/users/:id", + [&](params& p) -> route_task + { + handler_called = true; + captured_id = get_param(p, "id"); + co_return route_done; + }); + + flat_router fr(std::move(r)); + + params req; + capy::test::run_blocking()(fr.dispatch( + http::method::get, urls::url_view("/users/123"), req)); + + BOOST_TEST(handler_called); + BOOST_TEST_EQ(captured_id, "123"); + BOOST_TEST_EQ(req.params.size(), 1u); + } + + void testMultipleParams() + { + std::string captured_user; + std::string captured_post; + + test_router r; + r.add(http::method::get, "/users/:userId/posts/:postId", + [&](params& p) -> route_task + { + captured_user = get_param(p, "userId"); + captured_post = get_param(p, "postId"); + co_return route_done; + }); + + flat_router fr(std::move(r)); + + params req; + capy::test::run_blocking()(fr.dispatch( + http::method::get, urls::url_view("/users/42/posts/99"), req)); + + BOOST_TEST_EQ(captured_user, "42"); + BOOST_TEST_EQ(captured_post, "99"); + BOOST_TEST_EQ(req.params.size(), 2u); + } + + void testWildcardCapture() + { + std::string captured_path; + + test_router r; + r.add(http::method::get, "/files/*filepath", + [&](params& p) -> route_task + { + captured_path = get_param(p, "filepath"); + co_return route_done; + }); + + flat_router fr(std::move(r)); + + params req; + capy::test::run_blocking()(fr.dispatch( + http::method::get, urls::url_view("/files/a/b/c.txt"), req)); + + BOOST_TEST_EQ(captured_path, "a/b/c.txt"); + } + + void testOptionalGroup() + { + std::string captured_version; + int call_count = 0; + + test_router r; + r.add(http::method::get, "/api{/v:version}", + [&](params& p) -> route_task + { + ++call_count; + captured_version = get_param(p, "version"); + co_return route_done; + }); + + flat_router fr(std::move(r)); + + // With optional group + { + params req; + capy::test::run_blocking()(fr.dispatch( + http::method::get, urls::url_view("/api/v2"), req)); + BOOST_TEST_EQ(call_count, 1); + BOOST_TEST_EQ(captured_version, "2"); + } + + // Without optional group + { + params req; + captured_version.clear(); + capy::test::run_blocking()(fr.dispatch( + http::method::get, urls::url_view("/api"), req)); + BOOST_TEST_EQ(call_count, 2); + BOOST_TEST(captured_version.empty()); + } + } + + void testParamWithDash() + { + // Param values can contain dashes + std::string captured_id; + + test_router r; + r.add(http::method::get, "/items/:id", + [&](params& p) -> route_task + { + captured_id = get_param(p, "id"); + co_return route_done; + }); + + flat_router fr(std::move(r)); + + params req; + capy::test::run_blocking()(fr.dispatch( + http::method::get, urls::url_view("/items/abc-def-123"), req)); + + BOOST_TEST_EQ(captured_id, "abc-def-123"); + } + + void testNoMatch() + { + bool handler_called = false; + + test_router r; + r.add(http::method::get, "/users/:id", + [&](params&) -> route_task + { + handler_called = true; + co_return route_done; + }); + + flat_router fr(std::move(r)); + + // Wrong path + { + params req; + capy::test::run_blocking()(fr.dispatch( + http::method::get, urls::url_view("/posts/123"), req)); + BOOST_TEST(!handler_called); + } + } + + void testGitHubStyleRoute() + { + std::string owner, repo, branch, filepath; + + test_router r; + r.add(http::method::get, "/:owner/:repo/blob/:branch/*path", + [&](params& p) -> route_task + { + owner = get_param(p, "owner"); + repo = get_param(p, "repo"); + branch = get_param(p, "branch"); + filepath = get_param(p, "path"); + co_return route_done; + }); + + flat_router fr(std::move(r)); + + params req; + capy::test::run_blocking()(fr.dispatch( + http::method::get, + urls::url_view("/john/myrepo/blob/main/src/index.js"), + req)); + + BOOST_TEST_EQ(owner, "john"); + BOOST_TEST_EQ(repo, "myrepo"); + BOOST_TEST_EQ(branch, "main"); + BOOST_TEST_EQ(filepath, "src/index.js"); + BOOST_TEST_EQ(req.params.size(), 4u); + } + + void testFileExtensionGroup() + { + std::string captured_ext; + int call_count = 0; + + test_router r; + r.add(http::method::get, "/file{.:ext}", + [&](params& p) -> route_task + { + ++call_count; + captured_ext = get_param(p, "ext"); + co_return route_done; + }); + + flat_router fr(std::move(r)); + + // With extension + { + params req; + capy::test::run_blocking()(fr.dispatch( + http::method::get, urls::url_view("/file.txt"), req)); + BOOST_TEST_EQ(call_count, 1); + BOOST_TEST_EQ(captured_ext, "txt"); + } + + // Without extension + { + params req; + captured_ext.clear(); + capy::test::run_blocking()(fr.dispatch( + http::method::get, urls::url_view("/file"), req)); + BOOST_TEST_EQ(call_count, 2); + BOOST_TEST(captured_ext.empty()); + } + } + + void testParamsClearedBetweenRequests() + { + test_router r; + r.add(http::method::get, "/a/:id", + [](params&) -> route_task { co_return route_done; }); + r.add(http::method::get, "/b", + [](params&) -> route_task { co_return route_done; }); + + flat_router fr(std::move(r)); + + params req; + + // First request captures param + capy::test::run_blocking()(fr.dispatch( + http::method::get, urls::url_view("/a/123"), req)); + BOOST_TEST_EQ(req.params.size(), 1u); + + // Second request has no params - should be cleared + capy::test::run_blocking()(fr.dispatch( + http::method::get, urls::url_view("/b"), req)); + BOOST_TEST_EQ(req.params.size(), 0u); + } + + void testUrlEncodedParam() + { + std::string captured_name; + + test_router r; + r.add(http::method::get, "/users/:name", + [&](params& p) -> route_task + { + captured_name = get_param(p, "name"); + co_return route_done; + }); + + flat_router fr(std::move(r)); + + params req; + // %20 = space, path is decoded before matching + capy::test::run_blocking()(fr.dispatch( + http::method::get, urls::url_view("/users/john%20doe"), req)); + + BOOST_TEST_EQ(captured_name, "john doe"); + } + + //-------------------------------------------- + // Path Adjustment Tests + //-------------------------------------------- + + void testPathAdjustmentSimple() + { + core::string_view captured_base; + core::string_view captured_path; + + test_router r; + r.use("/api", + [&](params& p) -> route_task + { + captured_base = p.base_path; + captured_path = p.path; + co_return route_done; + }); + + flat_router fr(std::move(r)); + + params req; + capy::test::run_blocking()(fr.dispatch( + http::method::get, urls::url_view("/api/v1/users"), req)); + + BOOST_TEST_EQ(captured_base, "/api"); + BOOST_TEST_EQ(captured_path, "/v1/users"); + } + + void testPathAdjustmentNested2Levels() + { + core::string_view captured_base; + core::string_view captured_path; + + test_router r; + r.use("/api", [&]{ + test_router r2; + r2.use("/v1", + [&](params& p) -> route_task + { + captured_base = p.base_path; + captured_path = p.path; + co_return route_done; + }); + return r2; + }()); + + flat_router fr(std::move(r)); + + params req; + capy::test::run_blocking()(fr.dispatch( + http::method::get, urls::url_view("/api/v1/users"), req)); + + BOOST_TEST_EQ(captured_base, "/api/v1"); + BOOST_TEST_EQ(captured_path, "/users"); + } + + void testPathAdjustmentNested3Levels() + { + core::string_view captured_base; + core::string_view captured_path; + + test_router r; + r.use("/api", [&]{ + test_router r2; + r2.use("/v1", [&]{ + test_router r3; + r3.use("/users", + [&](params& p) -> route_task + { + captured_base = p.base_path; + captured_path = p.path; + co_return route_done; + }); + return r3; + }()); + return r2; + }()); + + flat_router fr(std::move(r)); + + params req; + capy::test::run_blocking()(fr.dispatch( + http::method::get, urls::url_view("/api/v1/users/123"), req)); + + BOOST_TEST_EQ(captured_base, "/api/v1/users"); + BOOST_TEST_EQ(captured_path, "/123"); + } + + void testPathAdjustmentNested4Levels() + { + core::string_view captured_base; + core::string_view captured_path; + + test_router r; + r.use("/a", [&]{ + test_router r2; + r2.use("/b", [&]{ + test_router r3; + r3.use("/c", [&]{ + test_router r4; + r4.use("/d", + [&](params& p) -> route_task + { + captured_base = p.base_path; + captured_path = p.path; + co_return route_done; + }); + return r4; + }()); + return r3; + }()); + return r2; + }()); + + flat_router fr(std::move(r)); + + params req; + capy::test::run_blocking()(fr.dispatch( + http::method::get, urls::url_view("/a/b/c/d/e/f"), req)); + + BOOST_TEST_EQ(captured_base, "/a/b/c/d"); + BOOST_TEST_EQ(captured_path, "/e/f"); + } + + void testPathAdjustmentWithRoute() + { + core::string_view captured_base; + core::string_view captured_path; + + test_router r; + r.use("/api", [&]{ + test_router r2; + r2.use("/v1", [&]{ + test_router r3; + r3.add(http::method::get, "/users/:id", + [&](params& p) -> route_task + { + captured_base = p.base_path; + captured_path = p.path; + co_return route_done; + }); + return r3; + }()); + return r2; + }()); + + flat_router fr(std::move(r)); + + params req; + capy::test::run_blocking()(fr.dispatch( + http::method::get, urls::url_view("/api/v1/users/42"), req)); + + BOOST_TEST_EQ(captured_base, "/api/v1/users/42"); + BOOST_TEST_EQ(captured_path, "/"); + BOOST_TEST_EQ(get_param(req, "id"), "42"); + } + + void testPathAdjustmentLongUrl() + { + // Tests path adjustment with URL longer than SSO threshold + // to verify no dangling string_view after push_back reallocation + core::string_view captured_base; + core::string_view captured_path; + + test_router r; + r.use("/very/long/path/prefix", [&]{ + test_router r2; + r2.use("/that/exceeds", [&]{ + test_router r3; + r3.use("/small/string/optimization", + [&](params& p) -> route_task + { + captured_base = p.base_path; + captured_path = p.path; + co_return route_done; + }); + return r3; + }()); + return r2; + }()); + + flat_router fr(std::move(r)); + + params req; + capy::test::run_blocking()(fr.dispatch( + http::method::get, + urls::url_view("/very/long/path/prefix/that/exceeds/small/string/optimization/tail"), + req)); + + BOOST_TEST_EQ(captured_base, "/very/long/path/prefix/that/exceeds/small/string/optimization"); + BOOST_TEST_EQ(captured_path, "/tail"); + } + + //-------------------------------------------- + // Invalid Pattern Tests + //-------------------------------------------- + + void testInvalidPatternMissingParamName() + { + test_router r; + BOOST_TEST_THROWS( + r.add(http::method::get, "/users/:", noop), + std::exception); + } + + void testInvalidPatternMissingWildcardName() + { + test_router r; + BOOST_TEST_THROWS( + r.add(http::method::get, "/files/*", noop), + std::exception); + } + + void testInvalidPatternUnclosedGroup() + { + test_router r; + BOOST_TEST_THROWS( + r.add(http::method::get, "/path{unclosed", noop), + std::exception); + } + + void testInvalidPatternUnexpectedCloseBrace() + { + test_router r; + BOOST_TEST_THROWS( + r.add(http::method::get, "/path}extra", noop), + std::exception); + } + + void testInvalidPatternUnterminatedQuote() + { + test_router r; + BOOST_TEST_THROWS( + r.add(http::method::get, ":\"unterminated", noop), + std::exception); + } + + void testInvalidPatternEmptyQuotedName() + { + test_router r; + BOOST_TEST_THROWS( + r.add(http::method::get, ":\"\"", noop), + std::exception); + } + + void testInvalidPatternReservedChars() + { + test_router r; + BOOST_TEST_THROWS(r.add(http::method::get, "/path(x)", noop), std::exception); + BOOST_TEST_THROWS(r.add(http::method::get, "/path[x]", noop), std::exception); + BOOST_TEST_THROWS(r.add(http::method::get, "/path+", noop), std::exception); + BOOST_TEST_THROWS(r.add(http::method::get, "/path?", noop), std::exception); + BOOST_TEST_THROWS(r.add(http::method::get, "/path!", noop), std::exception); + } + + void testInvalidPatternTrailingBackslash() + { + test_router r; + BOOST_TEST_THROWS( + r.add(http::method::get, "/path\\", noop), + std::exception); + } + + //-------------------------------------------- + // Router Options Tests + //-------------------------------------------- + + void testCaseSensitiveMatch() + { + bool handler_called = false; + + test_router r(router_options().case_sensitive(true)); + r.add(http::method::get, "/Api", + [&](params&) -> route_task + { + handler_called = true; + co_return route_done; + }); + + flat_router fr(std::move(r)); + + // Exact case - should match + { + params req; + handler_called = false; + capy::test::run_blocking()(fr.dispatch( + http::method::get, urls::url_view("/Api"), req)); + BOOST_TEST(handler_called); + } + + // Wrong case - should not match + { + params req; + handler_called = false; + capy::test::run_blocking()(fr.dispatch( + http::method::get, urls::url_view("/api"), req)); + BOOST_TEST(!handler_called); + } + } + + void testCaseInsensitiveMatch() + { + bool handler_called = false; + + test_router r(router_options().case_sensitive(false)); + r.add(http::method::get, "/Api", + [&](params&) -> route_task + { + handler_called = true; + co_return route_done; + }); + + flat_router fr(std::move(r)); + + // Different case - should match + { + params req; + handler_called = false; + capy::test::run_blocking()(fr.dispatch( + http::method::get, urls::url_view("/api"), req)); + BOOST_TEST(handler_called); + } + + // Upper case - should match + { + params req; + handler_called = false; + capy::test::run_blocking()(fr.dispatch( + http::method::get, urls::url_view("/API"), req)); + BOOST_TEST(handler_called); + } + } + + void testStrictModeNoTrailingSlash() + { + bool handler_called = false; + + test_router r(router_options().strict(true)); + r.add(http::method::get, "/api/users", + [&](params&) -> route_task + { + handler_called = true; + co_return route_done; + }); + + flat_router fr(std::move(r)); + + // Without trailing slash - should match + { + params req; + handler_called = false; + capy::test::run_blocking()(fr.dispatch( + http::method::get, urls::url_view("/api/users"), req)); + BOOST_TEST(handler_called); + } + + // With trailing slash - should not match in strict mode + { + params req; + handler_called = false; + capy::test::run_blocking()(fr.dispatch( + http::method::get, urls::url_view("/api/users/"), req)); + BOOST_TEST(!handler_called); + } + } + + void testNonStrictModeTrailingSlash() + { + bool handler_called = false; + + test_router r(router_options().strict(false)); + r.add(http::method::get, "/api/users", + [&](params&) -> route_task + { + handler_called = true; + co_return route_done; + }); + + flat_router fr(std::move(r)); + + // Without trailing slash - should match + { + params req; + handler_called = false; + capy::test::run_blocking()(fr.dispatch( + http::method::get, urls::url_view("/api/users"), req)); + BOOST_TEST(handler_called); + } + + // With trailing slash - should also match in non-strict mode + { + params req; + handler_called = false; + capy::test::run_blocking()(fr.dispatch( + http::method::get, urls::url_view("/api/users/"), req)); + BOOST_TEST(handler_called); + } + } + + //-------------------------------------------- + // Escape Sequence Tests + //-------------------------------------------- + + void testEscapedColon() + { + bool handler_called = false; + + test_router r; + r.add(http::method::get, "/path\\:literal", + [&](params&) -> route_task + { + handler_called = true; + co_return route_done; + }); + + flat_router fr(std::move(r)); + + params req; + capy::test::run_blocking()(fr.dispatch( + http::method::get, urls::url_view("/path:literal"), req)); + BOOST_TEST(handler_called); + BOOST_TEST_EQ(req.params.size(), 0u); + } + + void testEscapedAsterisk() + { + bool handler_called = false; + + test_router r; + r.add(http::method::get, "/path\\*star", + [&](params&) -> route_task + { + handler_called = true; + co_return route_done; + }); + + flat_router fr(std::move(r)); + + params req; + capy::test::run_blocking()(fr.dispatch( + http::method::get, urls::url_view("/path*star"), req)); + BOOST_TEST(handler_called); + } + + void testEscapedBrace() + { + bool handler_called = false; + + test_router r; + r.add(http::method::get, "/path\\{brace\\}", + [&](params&) -> route_task + { + handler_called = true; + co_return route_done; + }); + + flat_router fr(std::move(r)); + + // Use percent-encoded braces: { = %7B, } = %7D + params req; + capy::test::run_blocking()(fr.dispatch( + http::method::get, urls::url_view("/path%7Bbrace%7D"), req)); + BOOST_TEST(handler_called); + } + + void testEscapedBackslash() + { + bool handler_called = false; + + test_router r; + r.add(http::method::get, "/path\\\\slash", + [&](params&) -> route_task + { + handler_called = true; + co_return route_done; + }); + + flat_router fr(std::move(r)); + + params req; + capy::test::run_blocking()(fr.dispatch( + http::method::get, urls::url_view("/path%5Cslash"), req)); + BOOST_TEST(handler_called); + } + + //-------------------------------------------- + // Quoted Name Tests + //-------------------------------------------- + + void testQuotedParamName() + { + std::string captured_value; + + test_router r; + r.add(http::method::get, "/items/:\"with spaces\"", + [&](params& p) -> route_task + { + captured_value = get_param(p, "with spaces"); + co_return route_done; + }); + + flat_router fr(std::move(r)); + + params req; + capy::test::run_blocking()(fr.dispatch( + http::method::get, urls::url_view("/items/123"), req)); + BOOST_TEST_EQ(captured_value, "123"); + } + + void testQuotedWildcardName() + { + std::string captured_value; + + test_router r; + r.add(http::method::get, "/files/*\"file-path\"", + [&](params& p) -> route_task + { + captured_value = get_param(p, "file-path"); + co_return route_done; + }); + + flat_router fr(std::move(r)); + + params req; + capy::test::run_blocking()(fr.dispatch( + http::method::get, urls::url_view("/files/a/b/c.txt"), req)); + BOOST_TEST_EQ(captured_value, "a/b/c.txt"); + } + + //-------------------------------------------- + // Edge Case Tests + //-------------------------------------------- + + void testAdjacentParamsWithSeparator() + { + std::string from_val, to_val; + + test_router r; + r.add(http::method::get, "/:from-:to", + [&](params& p) -> route_task + { + from_val = get_param(p, "from"); + to_val = get_param(p, "to"); + co_return route_done; + }); + + flat_router fr(std::move(r)); + + params req; + capy::test::run_blocking()(fr.dispatch( + http::method::get, urls::url_view("/LAX-JFK"), req)); + BOOST_TEST_EQ(from_val, "LAX"); + BOOST_TEST_EQ(to_val, "JFK"); + } + + void testManyParams() + { + test_router r; + r.add(http::method::get, "/:a/:b/:c/:d/:e/:f/:g/:h/:i/:j", + [](params& p) -> route_task + { + BOOST_TEST_EQ(p.params.size(), 10u); + co_return route_done; + }); + + flat_router fr(std::move(r)); + + params req; + capy::test::run_blocking()(fr.dispatch( + http::method::get, urls::url_view("/1/2/3/4/5/6/7/8/9/10"), req)); + BOOST_TEST_EQ(req.params.size(), 10u); + BOOST_TEST_EQ(get_param(req, "a"), "1"); + BOOST_TEST_EQ(get_param(req, "j"), "10"); + } + + void testConsecutiveSlashes() + { + bool handler_called = false; + + test_router r; + r.add(http::method::get, "/a//b", + [&](params&) -> route_task + { + handler_called = true; + co_return route_done; + }); + + flat_router fr(std::move(r)); + + params req; + capy::test::run_blocking()(fr.dispatch( + http::method::get, urls::url_view("/a//b"), req)); + BOOST_TEST(handler_called); + } + + void testRootPath() + { + bool handler_called = false; + + test_router r; + r.add(http::method::get, "/", + [&](params&) -> route_task + { + handler_called = true; + co_return route_done; + }); + + flat_router fr(std::move(r)); + + params req; + capy::test::run_blocking()(fr.dispatch( + http::method::get, urls::url_view("/"), req)); + BOOST_TEST(handler_called); + } + + void testNestedOptionalGroups() + { + int call_count = 0; + + test_router r; + r.add(http::method::get, "/a{/b{/c}}", + [&](params&) -> route_task + { + ++call_count; + co_return route_done; + }); + + flat_router fr(std::move(r)); + + // All levels + { + params req; + capy::test::run_blocking()(fr.dispatch( + http::method::get, urls::url_view("/a/b/c"), req)); + BOOST_TEST_EQ(call_count, 1); + } + + // Two levels + { + params req; + capy::test::run_blocking()(fr.dispatch( + http::method::get, urls::url_view("/a/b"), req)); + BOOST_TEST_EQ(call_count, 2); + } + + // One level + { + params req; + capy::test::run_blocking()(fr.dispatch( + http::method::get, urls::url_view("/a"), req)); + BOOST_TEST_EQ(call_count, 3); + } + } + + void testMultipleOptionalGroups() + { + int call_count = 0; + + test_router r; + r.add(http::method::get, "{/a}{/b}", + [&](params&) -> route_task + { + ++call_count; + co_return route_done; + }); + + flat_router fr(std::move(r)); + + // Both groups + { + params req; + capy::test::run_blocking()(fr.dispatch( + http::method::get, urls::url_view("/a/b"), req)); + BOOST_TEST_EQ(call_count, 1); + } + + // First only + { + params req; + capy::test::run_blocking()(fr.dispatch( + http::method::get, urls::url_view("/a"), req)); + BOOST_TEST_EQ(call_count, 2); + } + + // Second only + { + params req; + capy::test::run_blocking()(fr.dispatch( + http::method::get, urls::url_view("/b"), req)); + BOOST_TEST_EQ(call_count, 3); + } + + // Neither + { + params req; + capy::test::run_blocking()(fr.dispatch( + http::method::get, urls::url_view(""), req)); + BOOST_TEST_EQ(call_count, 4); + } + } + + void testParamMustNotBeEmpty() + { + bool handler_called = false; + + test_router r; + r.add(http::method::get, "/users/:id/posts", + [&](params&) -> route_task + { + handler_called = true; + co_return route_done; + }); + + flat_router fr(std::move(r)); + + // Empty param value - should not match + params req; + capy::test::run_blocking()(fr.dispatch( + http::method::get, urls::url_view("/users//posts"), req)); + BOOST_TEST(!handler_called); + } + + void testWildcardMustNotBeEmpty() + { + bool handler_called = false; + + test_router r; + r.add(http::method::get, "/files/*path", + [&](params&) -> route_task + { + handler_called = true; + co_return route_done; + }); + + flat_router fr(std::move(r)); + + // Empty wildcard value - should not match + params req; + capy::test::run_blocking()(fr.dispatch( + http::method::get, urls::url_view("/files/"), req)); + BOOST_TEST(!handler_called); + } + void run() { testCopyConstruction(); @@ -233,6 +1256,62 @@ struct flat_router_test testExplicitOptionsPriority(); testAllMethodsHandler(); testOptionsStarGlobal(); + + // Route pattern integration tests + testParamCapture(); + testMultipleParams(); + testWildcardCapture(); + testOptionalGroup(); + testParamWithDash(); + testNoMatch(); + testGitHubStyleRoute(); + testFileExtensionGroup(); + testParamsClearedBetweenRequests(); + testUrlEncodedParam(); + + // Path adjustment tests + testPathAdjustmentSimple(); + testPathAdjustmentNested2Levels(); + testPathAdjustmentNested3Levels(); + testPathAdjustmentNested4Levels(); + testPathAdjustmentWithRoute(); + testPathAdjustmentLongUrl(); + + // Invalid pattern tests + testInvalidPatternMissingParamName(); + testInvalidPatternMissingWildcardName(); + testInvalidPatternUnclosedGroup(); + testInvalidPatternUnexpectedCloseBrace(); + testInvalidPatternUnterminatedQuote(); + testInvalidPatternEmptyQuotedName(); + testInvalidPatternReservedChars(); + testInvalidPatternTrailingBackslash(); + + // Router options tests + testCaseSensitiveMatch(); + testCaseInsensitiveMatch(); + testStrictModeNoTrailingSlash(); + testNonStrictModeTrailingSlash(); + + // Escape sequence tests + testEscapedColon(); + testEscapedAsterisk(); + testEscapedBrace(); + testEscapedBackslash(); + + // Quoted name tests + testQuotedParamName(); + testQuotedWildcardName(); + + // Edge case tests + testAdjacentParamsWithSeparator(); + testManyParams(); + testConsecutiveSlashes(); + testRootPath(); + testNestedOptionalGroups(); + testMultipleOptionalGroups(); + testParamMustNotBeEmpty(); + testWildcardMustNotBeEmpty(); } }; diff --git a/test/unit/server/route_abnf.cpp b/test/unit/server/route_abnf.cpp deleted file mode 100644 index c8faf2c6..00000000 --- a/test/unit/server/route_abnf.cpp +++ /dev/null @@ -1,1047 +0,0 @@ -// -// Copyright (c) 2025 Vinnie Falco (vinnie dot falco at gmail dot com) -// -// Distributed under the Boost Software License, Version 1.0. (See accompanying -// file LICENSE_1_0.txt or copy at http://www.boost.org/LICENSE_1_0.txt) -// -// Official repository: https://github.com/cppalliance/http -// - -// Test that header file is self-contained -#include "src/server/route_abnf.hpp" - -#include "test_suite.hpp" - -namespace boost { -namespace http { - -struct route_abnf_test -{ - using token = detail::route_token; - using token_type = detail::route_token_type; - - // Helper to check token type and value - static void - check_token( - token const& t, - token_type type, - std::string const& value) - { - BOOST_TEST_EQ(static_cast(t.type), static_cast(type)); - BOOST_TEST_EQ(t.value, value); - } - - void - testText() - { - // Simple text - { - auto rv = detail::parse_route_pattern("/users"); - BOOST_TEST(rv.has_value()); - BOOST_TEST_EQ(rv->tokens.size(), 1u); - check_token(rv->tokens[0], token_type::text, "/users"); - } - - // Text with multiple segments - { - auto rv = detail::parse_route_pattern("/api/v1/users"); - BOOST_TEST(rv.has_value()); - BOOST_TEST_EQ(rv->tokens.size(), 1u); - check_token(rv->tokens[0], token_type::text, "/api/v1/users"); - } - } - - void - testParam() - { - // Simple param - { - auto rv = detail::parse_route_pattern(":id"); - BOOST_TEST(rv.has_value()); - BOOST_TEST_EQ(rv->tokens.size(), 1u); - check_token(rv->tokens[0], token_type::param, "id"); - } - - // Param with text prefix - { - auto rv = detail::parse_route_pattern("/users/:id"); - BOOST_TEST(rv.has_value()); - BOOST_TEST_EQ(rv->tokens.size(), 2u); - check_token(rv->tokens[0], token_type::text, "/users/"); - check_token(rv->tokens[1], token_type::param, "id"); - } - - // Multiple params - { - auto rv = detail::parse_route_pattern("/users/:userId/posts/:postId"); - BOOST_TEST(rv.has_value()); - BOOST_TEST_EQ(rv->tokens.size(), 4u); - check_token(rv->tokens[0], token_type::text, "/users/"); - check_token(rv->tokens[1], token_type::param, "userId"); - check_token(rv->tokens[2], token_type::text, "/posts/"); - check_token(rv->tokens[3], token_type::param, "postId"); - } - - // Param with underscore - { - auto rv = detail::parse_route_pattern(":user_id"); - BOOST_TEST(rv.has_value()); - BOOST_TEST_EQ(rv->tokens.size(), 1u); - check_token(rv->tokens[0], token_type::param, "user_id"); - } - - // Param with dollar sign - { - auto rv = detail::parse_route_pattern(":$var"); - BOOST_TEST(rv.has_value()); - BOOST_TEST_EQ(rv->tokens.size(), 1u); - check_token(rv->tokens[0], token_type::param, "$var"); - } - } - - void - testWildcard() - { - // Simple wildcard - { - auto rv = detail::parse_route_pattern("*path"); - BOOST_TEST(rv.has_value()); - BOOST_TEST_EQ(rv->tokens.size(), 1u); - check_token(rv->tokens[0], token_type::wildcard, "path"); - } - - // Wildcard with prefix - { - auto rv = detail::parse_route_pattern("/files/*filepath"); - BOOST_TEST(rv.has_value()); - BOOST_TEST_EQ(rv->tokens.size(), 2u); - check_token(rv->tokens[0], token_type::text, "/files/"); - check_token(rv->tokens[1], token_type::wildcard, "filepath"); - } - } - - void - testGroup() - { - // Simple group - { - auto rv = detail::parse_route_pattern("{/optional}"); - BOOST_TEST(rv.has_value()); - BOOST_TEST_EQ(rv->tokens.size(), 1u); - BOOST_TEST_EQ( - static_cast(rv->tokens[0].type), - static_cast(token_type::group)); - BOOST_TEST_EQ(rv->tokens[0].children.size(), 1u); - check_token(rv->tokens[0].children[0], token_type::text, "/optional"); - } - - // Group with param - { - auto rv = detail::parse_route_pattern("/api{/v:version}"); - BOOST_TEST(rv.has_value()); - BOOST_TEST_EQ(rv->tokens.size(), 2u); - check_token(rv->tokens[0], token_type::text, "/api"); - BOOST_TEST_EQ( - static_cast(rv->tokens[1].type), - static_cast(token_type::group)); - BOOST_TEST_EQ(rv->tokens[1].children.size(), 2u); - check_token(rv->tokens[1].children[0], token_type::text, "/v"); - check_token(rv->tokens[1].children[1], token_type::param, "version"); - } - - // Empty group - { - auto rv = detail::parse_route_pattern("/path{}"); - BOOST_TEST(rv.has_value()); - BOOST_TEST_EQ(rv->tokens.size(), 2u); - check_token(rv->tokens[0], token_type::text, "/path"); - BOOST_TEST_EQ(rv->tokens[1].children.size(), 0u); - } - } - - void - testEscape() - { - // Escaped colon - { - auto rv = detail::parse_route_pattern("/path\\:literal"); - BOOST_TEST(rv.has_value()); - BOOST_TEST_EQ(rv->tokens.size(), 1u); - check_token(rv->tokens[0], token_type::text, "/path:literal"); - } - - // Escaped asterisk - { - auto rv = detail::parse_route_pattern("/path\\*star"); - BOOST_TEST(rv.has_value()); - BOOST_TEST_EQ(rv->tokens.size(), 1u); - check_token(rv->tokens[0], token_type::text, "/path*star"); - } - - // Escaped brace - { - auto rv = detail::parse_route_pattern("/path\\{brace\\}"); - BOOST_TEST(rv.has_value()); - BOOST_TEST_EQ(rv->tokens.size(), 1u); - check_token(rv->tokens[0], token_type::text, "/path{brace}"); - } - - // Escaped backslash - { - auto rv = detail::parse_route_pattern("/path\\\\slash"); - BOOST_TEST(rv.has_value()); - BOOST_TEST_EQ(rv->tokens.size(), 1u); - check_token(rv->tokens[0], token_type::text, "/path\\slash"); - } - } - - void - testQuotedName() - { - // Quoted param name - { - auto rv = detail::parse_route_pattern(":\"with spaces\""); - BOOST_TEST(rv.has_value()); - BOOST_TEST_EQ(rv->tokens.size(), 1u); - check_token(rv->tokens[0], token_type::param, "with spaces"); - } - - // Quoted wildcard name - { - auto rv = detail::parse_route_pattern("*\"file-path\""); - BOOST_TEST(rv.has_value()); - BOOST_TEST_EQ(rv->tokens.size(), 1u); - check_token(rv->tokens[0], token_type::wildcard, "file-path"); - } - - // Quoted name with escape - { - auto rv = detail::parse_route_pattern(":\"say \\\"hello\\\"\""); - BOOST_TEST(rv.has_value()); - BOOST_TEST_EQ(rv->tokens.size(), 1u); - check_token(rv->tokens[0], token_type::param, "say \"hello\""); - } - } - - void - testErrors() - { - // Missing param name - { - auto rv = detail::parse_route_pattern("/users/:"); - BOOST_TEST(rv.has_error()); - } - - // Missing wildcard name - { - auto rv = detail::parse_route_pattern("/files/*"); - BOOST_TEST(rv.has_error()); - } - - // Unclosed group - { - auto rv = detail::parse_route_pattern("/path{unclosed"); - BOOST_TEST(rv.has_error()); - } - - // Unexpected close brace - { - auto rv = detail::parse_route_pattern("/path}extra"); - BOOST_TEST(rv.has_error()); - } - - // Unterminated quote - { - auto rv = detail::parse_route_pattern(":\"unterminated"); - BOOST_TEST(rv.has_error()); - } - - // Empty quoted name - { - auto rv = detail::parse_route_pattern(":\"\""); - BOOST_TEST(rv.has_error()); - } - - // Reserved character ( - { - auto rv = detail::parse_route_pattern("/path(reserved)"); - BOOST_TEST(rv.has_error()); - } - - // Reserved character [ - { - auto rv = detail::parse_route_pattern("/path[reserved]"); - BOOST_TEST(rv.has_error()); - } - - // Reserved character + - { - auto rv = detail::parse_route_pattern("/path+"); - BOOST_TEST(rv.has_error()); - } - - // Reserved character ? - { - auto rv = detail::parse_route_pattern("/path?"); - BOOST_TEST(rv.has_error()); - } - - // Reserved character ! - { - auto rv = detail::parse_route_pattern("/path!"); - BOOST_TEST(rv.has_error()); - } - - // Trailing backslash - { - auto rv = detail::parse_route_pattern("/path\\"); - BOOST_TEST(rv.has_error()); - } - } - - void - testComplex() - { - // Express.js style route - { - auto rv = detail::parse_route_pattern( - "/api/v1/users/:userId/posts/:postId"); - BOOST_TEST(rv.has_value()); - BOOST_TEST_EQ(rv->tokens.size(), 4u); - } - - // Multiple consecutive params with separator - { - auto rv = detail::parse_route_pattern("/:foo-:bar"); - BOOST_TEST(rv.has_value()); - BOOST_TEST_EQ(rv->tokens.size(), 4u); - check_token(rv->tokens[0], token_type::text, "/"); - check_token(rv->tokens[1], token_type::param, "foo"); - check_token(rv->tokens[2], token_type::text, "-"); - check_token(rv->tokens[3], token_type::param, "bar"); - } - - // Nested groups not directly supported but works as single group - { - auto rv = detail::parse_route_pattern("/path{/opt1{/opt2}}"); - BOOST_TEST(rv.has_value()); - BOOST_TEST_EQ(rv->tokens.size(), 2u); - // The outer group contains text + nested group - BOOST_TEST_EQ(rv->tokens[1].children.size(), 2u); - } - } - - void - testOriginalPreserved() - { - auto rv = detail::parse_route_pattern("/users/:id"); - BOOST_TEST(rv.has_value()); - BOOST_TEST_EQ(rv->original, "/users/:id"); - } - - void - run() - { - testText(); - testParam(); - testWildcard(); - testGroup(); - testEscape(); - testQuotedName(); - testErrors(); - testComplex(); - testOriginalPreserved(); - } -}; - -TEST_SUITE( - route_abnf_test, - "boost.http.server.route_abnf"); - -//------------------------------------------------ - -struct route_match_test -{ - using match_options = detail::match_options; - using match_params = detail::match_params; - - // Helper to parse pattern - static detail::route_pattern - parse(std::string_view pat) - { - auto rv = detail::parse_route_pattern(pat); - BOOST_TEST(rv.has_value()); - return std::move(rv.value()); - } - - // Helper to check param value - static void - check_param( - match_params const& mp, - std::string const& name, - std::string const& value) - { - for(auto const& p : mp.params) - { - if(p.first == name) - { - BOOST_TEST_EQ(p.second, value); - return; - } - } - BOOST_TEST(false); // param not found - } - - // Default options for convenience - static match_options - opts(bool case_sensitive = false, bool strict = false, bool end = true) - { - return { case_sensitive, strict, end }; - } - - //-------------------------------------------- - // Text Matching - //-------------------------------------------- - - void - testTextExact() - { - auto pat = parse("/users"); - - // Exact match - { - auto rv = detail::match_route("/users", pat, opts()); - BOOST_TEST(rv.has_value()); - BOOST_TEST_EQ(rv->matched_length, 6u); - BOOST_TEST_EQ(rv->params.size(), 0u); - } - - // No match - different text - { - auto rv = detail::match_route("/posts", pat, opts()); - BOOST_TEST(rv.has_error()); - } - - // No match - too short - { - auto rv = detail::match_route("/use", pat, opts()); - BOOST_TEST(rv.has_error()); - } - - // No match - too long (end=true) - { - auto rv = detail::match_route("/users/123", pat, opts()); - BOOST_TEST(rv.has_error()); - } - } - - void - testTextCaseSensitive() - { - auto pat = parse("/Users"); - - // Case insensitive (default) - should match - { - auto rv = detail::match_route("/users", pat, opts(false)); - BOOST_TEST(rv.has_value()); - } - - // Case sensitive - should not match - { - auto rv = detail::match_route("/users", pat, opts(true)); - BOOST_TEST(rv.has_error()); - } - - // Case sensitive - exact match - { - auto rv = detail::match_route("/Users", pat, opts(true)); - BOOST_TEST(rv.has_value()); - } - } - - void - testTextRoot() - { - auto pat = parse("/"); - - // Match root - { - auto rv = detail::match_route("/", pat, opts()); - BOOST_TEST(rv.has_value()); - BOOST_TEST_EQ(rv->matched_length, 1u); - } - } - - void - testTextEmpty() - { - // Empty pattern matches empty path - auto pat = parse(""); - auto rv = detail::match_route("", pat, opts()); - BOOST_TEST(rv.has_value()); - BOOST_TEST_EQ(rv->matched_length, 0u); - } - - //-------------------------------------------- - // Parameter Extraction - //-------------------------------------------- - - void - testParamSingle() - { - auto pat = parse("/users/:id"); - - // Match and extract - { - auto rv = detail::match_route("/users/123", pat, opts()); - BOOST_TEST(rv.has_value()); - BOOST_TEST_EQ(rv->params.size(), 1u); - check_param(*rv, "id", "123"); - BOOST_TEST_EQ(rv->matched_length, 10u); - } - - // Match with longer value - { - auto rv = detail::match_route("/users/abc-def", pat, opts()); - BOOST_TEST(rv.has_value()); - check_param(*rv, "id", "abc-def"); - } - } - - void - testParamMultiple() - { - auto pat = parse("/users/:userId/posts/:postId"); - - auto rv = detail::match_route("/users/42/posts/99", pat, opts()); - BOOST_TEST(rv.has_value()); - BOOST_TEST_EQ(rv->params.size(), 2u); - check_param(*rv, "userId", "42"); - check_param(*rv, "postId", "99"); - } - - void - testParamAdjacent() - { - // Adjacent params with slash separator - // Note: params match until '/' only, not arbitrary text - auto pat = parse("/:foo/:bar"); - - auto rv = detail::match_route("/hello/world", pat, opts()); - BOOST_TEST(rv.has_value()); - BOOST_TEST_EQ(rv->params.size(), 2u); - check_param(*rv, "foo", "hello"); - check_param(*rv, "bar", "world"); - } - - void - testParamAtStart() - { - auto pat = parse(":foo/bar"); - - auto rv = detail::match_route("hello/bar", pat, opts()); - BOOST_TEST(rv.has_value()); - check_param(*rv, "foo", "hello"); - } - - void - testParamAtEnd() - { - auto pat = parse("/foo/:bar"); - - auto rv = detail::match_route("/foo/baz", pat, opts()); - BOOST_TEST(rv.has_value()); - check_param(*rv, "bar", "baz"); - } - - void - testParamEmpty() - { - // Param must capture at least one char - auto pat = parse("/users/:id/posts"); - - auto rv = detail::match_route("/users//posts", pat, opts()); - BOOST_TEST(rv.has_error()); - } - - //-------------------------------------------- - // Wildcard Extraction - //-------------------------------------------- - - void - testWildcardSimple() - { - auto pat = parse("/files/*path"); - - auto rv = detail::match_route("/files/a/b/c.txt", pat, opts()); - BOOST_TEST(rv.has_value()); - check_param(*rv, "path", "a/b/c.txt"); - } - - void - testWildcardAtRoot() - { - auto pat = parse("/*path"); - - auto rv = detail::match_route("/anything/here", pat, opts()); - BOOST_TEST(rv.has_value()); - check_param(*rv, "path", "anything/here"); - } - - void - testWildcardEmpty() - { - // Wildcard must capture at least one char - auto pat = parse("/files/*path"); - - auto rv = detail::match_route("/files/", pat, opts()); - BOOST_TEST(rv.has_error()); - } - - //-------------------------------------------- - // Option Combinations (all 8) - //-------------------------------------------- - - void - testOptionsCombinations() - { - auto pat = parse("/Api"); - - // {case_sensitive: false, strict: false, end: true} - { - auto rv = detail::match_route("/api", pat, opts(false, false, true)); - BOOST_TEST(rv.has_value()); - } - { - auto rv = detail::match_route("/api/", pat, opts(false, false, true)); - BOOST_TEST(rv.has_value()); // trailing slash allowed - } - - // {case_sensitive: false, strict: false, end: false} - { - auto rv = detail::match_route("/api/extra", pat, opts(false, false, false)); - BOOST_TEST(rv.has_value()); - BOOST_TEST_EQ(rv->matched_length, 4u); - } - - // {case_sensitive: false, strict: true, end: true} - { - auto rv = detail::match_route("/api", pat, opts(false, true, true)); - BOOST_TEST(rv.has_value()); - } - { - auto rv = detail::match_route("/api/", pat, opts(false, true, true)); - BOOST_TEST(rv.has_error()); // strict - trailing slash not allowed - } - - // {case_sensitive: false, strict: true, end: false} - { - auto rv = detail::match_route("/api/extra", pat, opts(false, true, false)); - BOOST_TEST(rv.has_value()); - BOOST_TEST_EQ(rv->matched_length, 4u); - } - - // {case_sensitive: true, strict: false, end: true} - { - auto rv = detail::match_route("/Api", pat, opts(true, false, true)); - BOOST_TEST(rv.has_value()); - } - { - auto rv = detail::match_route("/api", pat, opts(true, false, true)); - BOOST_TEST(rv.has_error()); // case mismatch - } - - // {case_sensitive: true, strict: false, end: false} - { - auto rv = detail::match_route("/Api/extra", pat, opts(true, false, false)); - BOOST_TEST(rv.has_value()); - BOOST_TEST_EQ(rv->matched_length, 4u); - } - - // {case_sensitive: true, strict: true, end: true} - { - auto rv = detail::match_route("/Api", pat, opts(true, true, true)); - BOOST_TEST(rv.has_value()); - } - { - auto rv = detail::match_route("/Api/", pat, opts(true, true, true)); - BOOST_TEST(rv.has_error()); - } - - // {case_sensitive: true, strict: true, end: false} - { - auto rv = detail::match_route("/Api/extra", pat, opts(true, true, false)); - BOOST_TEST(rv.has_value()); - BOOST_TEST_EQ(rv->matched_length, 4u); - } - } - - //-------------------------------------------- - // Strict Mode - //-------------------------------------------- - - void - testStrictTrailingSlash() - { - auto pat = parse("/api/users"); - - // Non-strict: /api/users matches /api/users/ - { - auto rv = detail::match_route("/api/users/", pat, opts(false, false, true)); - BOOST_TEST(rv.has_value()); - } - - // Strict: /api/users does NOT match /api/users/ - { - auto rv = detail::match_route("/api/users/", pat, opts(false, true, true)); - BOOST_TEST(rv.has_error()); - } - } - - void - testStrictWithParam() - { - auto pat = parse("/users/:id"); - - // Non-strict - { - auto rv = detail::match_route("/users/123/", pat, opts(false, false, true)); - BOOST_TEST(rv.has_value()); - check_param(*rv, "id", "123"); - } - - // Strict - trailing slash after param not allowed - { - auto rv = detail::match_route("/users/123/", pat, opts(false, true, true)); - BOOST_TEST(rv.has_error()); - } - } - - //-------------------------------------------- - // End Mode (prefix vs full match) - //-------------------------------------------- - - void - testEndModeFull() - { - auto pat = parse("/api"); - - // end=true requires full match - { - auto rv = detail::match_route("/api", pat, opts(false, false, true)); - BOOST_TEST(rv.has_value()); - } - { - auto rv = detail::match_route("/api/users", pat, opts(false, false, true)); - BOOST_TEST(rv.has_error()); - } - } - - void - testEndModePrefix() - { - auto pat = parse("/api"); - - // end=false allows prefix match - { - auto rv = detail::match_route("/api/users", pat, opts(false, false, false)); - BOOST_TEST(rv.has_value()); - BOOST_TEST_EQ(rv->matched_length, 4u); - } - } - - void - testEndModeWithParams() - { - auto pat = parse("/users/:id"); - - // Prefix match with param - { - auto rv = detail::match_route("/users/123/extra", pat, opts(false, false, false)); - BOOST_TEST(rv.has_value()); - check_param(*rv, "id", "123"); - BOOST_TEST_EQ(rv->matched_length, 10u); - } - } - - //-------------------------------------------- - // Groups (Optional Sections) - //-------------------------------------------- - - void - testGroupMatches() - { - auto pat = parse("/api{/v:version}"); - - // With group - { - auto rv = detail::match_route("/api/v2", pat, opts()); - BOOST_TEST(rv.has_value()); - BOOST_TEST_EQ(rv->params.size(), 1u); - check_param(*rv, "version", "2"); - } - - // Without group - { - auto rv = detail::match_route("/api", pat, opts()); - BOOST_TEST(rv.has_value()); - BOOST_TEST_EQ(rv->params.size(), 0u); - } - } - - void - testGroupTextOnly() - { - auto pat = parse("/file{.json}"); - - // With extension - { - auto rv = detail::match_route("/file.json", pat, opts()); - BOOST_TEST(rv.has_value()); - } - - // Without extension - { - auto rv = detail::match_route("/file", pat, opts()); - BOOST_TEST(rv.has_value()); - } - } - - void - testGroupNested() - { - auto pat = parse("/a{/b{/c}}"); - - // All levels - { - auto rv = detail::match_route("/a/b/c", pat, opts()); - BOOST_TEST(rv.has_value()); - } - - // Two levels - { - auto rv = detail::match_route("/a/b", pat, opts()); - BOOST_TEST(rv.has_value()); - } - - // One level - { - auto rv = detail::match_route("/a", pat, opts()); - BOOST_TEST(rv.has_value()); - } - } - - void - testGroupMultiple() - { - auto pat = parse("{/a}{/b}"); - - // Both groups - { - auto rv = detail::match_route("/a/b", pat, opts()); - BOOST_TEST(rv.has_value()); - } - - // First only - { - auto rv = detail::match_route("/a", pat, opts()); - BOOST_TEST(rv.has_value()); - } - - // Second only - { - auto rv = detail::match_route("/b", pat, opts()); - BOOST_TEST(rv.has_value()); - } - - // Neither - { - auto rv = detail::match_route("", pat, opts()); - BOOST_TEST(rv.has_value()); - } - } - - void - testGroupEmpty() - { - auto pat = parse("/path{}"); - - auto rv = detail::match_route("/path", pat, opts()); - BOOST_TEST(rv.has_value()); - } - - void - testGroupAtEnd() - { - auto pat = parse("/required{/optional}"); - - // With optional - { - auto rv = detail::match_route("/required/optional", pat, opts()); - BOOST_TEST(rv.has_value()); - } - - // Without optional - { - auto rv = detail::match_route("/required", pat, opts()); - BOOST_TEST(rv.has_value()); - } - } - - //-------------------------------------------- - // Non-Matching Cases - //-------------------------------------------- - - void - testNonMatching() - { - auto pat = parse("/users/:id"); - - // Path too short - { - auto rv = detail::match_route("/users", pat, opts()); - BOOST_TEST(rv.has_error()); - } - - // Wrong literal - { - auto rv = detail::match_route("/posts/123", pat, opts()); - BOOST_TEST(rv.has_error()); - } - } - - //-------------------------------------------- - // Edge Cases - //-------------------------------------------- - - void - testManyParams() - { - auto pat = parse("/:a/:b/:c/:d/:e/:f/:g/:h/:i/:j"); - - auto rv = detail::match_route("/1/2/3/4/5/6/7/8/9/10", pat, opts()); - BOOST_TEST(rv.has_value()); - BOOST_TEST_EQ(rv->params.size(), 10u); - check_param(*rv, "a", "1"); - check_param(*rv, "j", "10"); - } - - void - testConsecutiveSlashes() - { - auto pat = parse("/a//b"); - - auto rv = detail::match_route("/a//b", pat, opts()); - BOOST_TEST(rv.has_value()); - } - - //-------------------------------------------- - // Integration / Real-world patterns - //-------------------------------------------- - - void - testExpressStyle() - { - auto pat = parse("/api/v1/users/:userId/posts/:postId"); - - auto rv = detail::match_route("/api/v1/users/42/posts/99", pat, opts()); - BOOST_TEST(rv.has_value()); - check_param(*rv, "userId", "42"); - check_param(*rv, "postId", "99"); - } - - void - testGitHubStyle() - { - auto pat = parse("/:owner/:repo/blob/:branch/*path"); - - auto rv = detail::match_route("/john/myrepo/blob/main/src/index.js", pat, opts()); - BOOST_TEST(rv.has_value()); - check_param(*rv, "owner", "john"); - check_param(*rv, "repo", "myrepo"); - check_param(*rv, "branch", "main"); - check_param(*rv, "path", "src/index.js"); - } - - void - testFileExtension() - { - auto pat = parse("/file{.:ext}"); - - // With extension - { - auto rv = detail::match_route("/file.txt", pat, opts()); - BOOST_TEST(rv.has_value()); - check_param(*rv, "ext", "txt"); - } - - // Without extension - { - auto rv = detail::match_route("/file", pat, opts()); - BOOST_TEST(rv.has_value()); - BOOST_TEST_EQ(rv->params.size(), 0u); - } - } - - void - run() - { - // Text matching - testTextExact(); - testTextCaseSensitive(); - testTextRoot(); - testTextEmpty(); - - // Parameter extraction - testParamSingle(); - testParamMultiple(); - testParamAdjacent(); - testParamAtStart(); - testParamAtEnd(); - testParamEmpty(); - - // Wildcard extraction - testWildcardSimple(); - testWildcardAtRoot(); - testWildcardEmpty(); - - // Option combinations - testOptionsCombinations(); - - // Strict mode - testStrictTrailingSlash(); - testStrictWithParam(); - - // End mode - testEndModeFull(); - testEndModePrefix(); - testEndModeWithParams(); - - // Groups - testGroupMatches(); - testGroupTextOnly(); - testGroupNested(); - testGroupMultiple(); - testGroupEmpty(); - testGroupAtEnd(); - - // Non-matching - testNonMatching(); - - // Edge cases - testManyParams(); - testConsecutiveSlashes(); - - // Integration - testExpressStyle(); - testGitHubStyle(); - testFileExtension(); - } -}; - -TEST_SUITE( - route_match_test, - "boost.http.server.route_match"); - -} // http -} // boost From 16a7af01fe817887d214e067df35432ff747f01e Mon Sep 17 00:00:00 2001 From: Vinnie Falco Date: Mon, 2 Feb 2026 05:46:21 -0800 Subject: [PATCH 5/6] Update reference page --- doc/modules/ROOT/pages/reference.adoc | 244 ++++++++++++++++++++++++-- 1 file changed, 231 insertions(+), 13 deletions(-) diff --git a/doc/modules/ROOT/pages/reference.adoc b/doc/modules/ROOT/pages/reference.adoc index 2aa70f0d..43d45e54 100644 --- a/doc/modules/ROOT/pages/reference.adoc +++ b/doc/modules/ROOT/pages/reference.adoc @@ -4,7 +4,7 @@ // Distributed under the Boost Software License, Version 1.0. (See accompanying // file LICENSE_1_0.txt or copy at https://www.boost.org/LICENSE_1_0.txt) // -// Official repository: https://github.com/boostorg/url +// Official repository: https://github.com/boostorg/http // @@ -17,14 +17,16 @@ | *Types (1/2)* +cpp:boost::http::application[application] + +cpp:boost::http::datastore[datastore] + cpp:boost::http::fields[fields] cpp:boost::http::fields_base[fields_base] cpp:boost::http::file[file] -cpp:boost::http::file_source[file_source] - cpp:boost::http::header_limits[header_limits] cpp:boost::http::message_base[message_base] @@ -33,6 +35,8 @@ cpp:boost::http::metadata[metadata] cpp:boost::http::parser[parser] +cpp:boost::http::polystore[polystore] + cpp:boost::http::request[request] cpp:boost::http::request_base[request_base] @@ -45,11 +49,17 @@ cpp:boost::http::response_base[response_base] cpp:boost::http::response_parser[response_parser] +| **Types (2/2)** + +cpp:boost::http::parser_config[parser_config] + +cpp:boost::http::parser_config_impl[parser_config_impl] + cpp:boost::http::serializer[serializer] -| **Types (2/2)** +cpp:boost::http::serializer_config[serializer_config] -cpp:boost::http::source[source] +cpp:boost::http::serializer_config_impl[serializer_config_impl] cpp:boost::http::static_request[static_request] @@ -57,6 +67,12 @@ cpp:boost::http::static_response[static_response] cpp:boost::http::string_body[string_body] +**Type Aliases** + +cpp:boost::http::shared_parser_config[shared_parser_config] + +cpp:boost::http::shared_serializer_config[shared_serializer_config] + **Functions** cpp:boost::http::combine_field_values[combine_field_values] @@ -67,6 +83,12 @@ cpp:boost::http::install_serializer_service[install_serializer_service] cpp:boost::http::int_to_status[int_to_status] +cpp:boost::http::invoke[invoke] + +cpp:boost::http::make_parser_config[make_parser_config] + +cpp:boost::http::make_serializer_config[make_serializer_config] + cpp:boost::http::obsolete_reason[obsolete_reason] cpp:boost::http::string_to_field[string_to_field] @@ -77,8 +99,6 @@ cpp:boost::http::to_status_class[to_status_class] cpp:boost::http::to_string[to_string] -// cpp:boost::http::operator<<[operator<<] - | **Constants** cpp:boost::http::condition[condition] @@ -101,11 +121,11 @@ cpp:boost::http::status_class[status_class] cpp:boost::http::version[version] -**Type Traits** +| **Grammar Rules** -cpp:boost::http::is_source[is_source] +cpp:boost::http::list_rule[list_rule] -| **Grammar** +cpp:boost::http::media_type_rule[media_type_rule] cpp:boost::http::parameter_rule[parameter_rule] @@ -119,18 +139,216 @@ cpp:boost::http::upgrade_rule[upgrade_rule] **Types** -cpp:boost::http::upgrade_protocol[upgrade_protocol] +cpp:boost::http::media_type[media_type] + +cpp:boost::http::mime_type[mime_type] cpp:boost::http::parameter[parameter] cpp:boost::http::quoted_token_view[quoted_token_view] +cpp:boost::http::upgrade_protocol[upgrade_protocol] + +**Constants** + +cpp:boost::http::tchars[tchars] + +|=== + +[width=100%] +|=== +2+| *Server* + +| **Types (1/2)** + +cpp:boost::http::basic_router[basic_router] + +cpp:boost::http::byte_range[byte_range] + +cpp:boost::http::cors[cors] + +cpp:boost::http::cors_options[cors_options] + +cpp:boost::http::dotfiles_policy[dotfiles_policy] + +cpp:boost::http::etag_options[etag_options] + +cpp:boost::http::flat_router[flat_router] + +cpp:boost::http::range_result[range_result] + +cpp:boost::http::range_result_type[range_result_type] + +cpp:boost::http::route_params[route_params] + +cpp:boost::http::route_params_base[route_params_base] + +cpp:boost::http::route_result[route_result] + +cpp:boost::http::route_task[route_task] + +cpp:boost::http::route_what[route_what] + +cpp:boost::http::router[router] + +cpp:boost::http::router_options[router_options] + +| **Types (2/2)** + +cpp:boost::http::send_file_info[send_file_info] + +cpp:boost::http::send_file_options[send_file_options] + +cpp:boost::http::send_file_result[send_file_result] + +cpp:boost::http::serve_static[serve_static] + +cpp:boost::http::serve_static_options[serve_static_options] + **Functions** -cpp:boost::http::list_rule[list_rule] +cpp:boost::http::encode_url[encode_url] + +cpp:boost::http::escape_html[escape_html] + +cpp:boost::http::etag[etag] + +cpp:boost::http::format_http_date[format_http_date] + +cpp:boost::http::is_fresh[is_fresh] + +cpp:boost::http::parse_range[parse_range] + +cpp:boost::http::route_error[route_error] + +cpp:boost::http::send_file_init[send_file_init] **Constants** -cpp:boost::http::tchars[tchars] +cpp:boost::http::route_close[route_close] + +cpp:boost::http::route_done[route_done] + +cpp:boost::http::route_next[route_next] + +cpp:boost::http::route_next_route[route_next_route] + +**Namespace `mime_types`** + +cpp:boost::http::mime_types::charset[charset] + +cpp:boost::http::mime_types::content_type[content_type] + +cpp:boost::http::mime_types::extension[extension] + +cpp:boost::http::mime_types::lookup[lookup] + +**Namespace `statuses`** + +cpp:boost::http::statuses::is_empty[is_empty] + +cpp:boost::http::statuses::is_redirect[is_redirect] + +cpp:boost::http::statuses::is_retry[is_retry] + +|=== + +[width=100%] +|=== +3+| *ZLib* | *Brotli* + +| **Types** + +cpp:boost::http::zlib::deflate_service[deflate_service] + +cpp:boost::http::zlib::inflate_service[inflate_service] + +cpp:boost::http::zlib::stream[stream] + +**Functions** + +cpp:boost::http::zlib::install_deflate_service[install_deflate_service] + +cpp:boost::http::zlib::install_inflate_service[install_inflate_service] + +cpp:boost::http::zlib::install_zlib_service[install_zlib_service] + +| **Constants** + +cpp:boost::http::zlib::compression_level[compression_level] + +cpp:boost::http::zlib::compression_method[compression_method] + +cpp:boost::http::zlib::compression_strategy[compression_strategy] + +cpp:boost::http::zlib::data_type[data_type] + +cpp:boost::http::zlib::error[error] + +cpp:boost::http::zlib::flush[flush] + +| **Types** + +cpp:boost::http::brotli::decode_service[decode_service] + +cpp:boost::http::brotli::decoder_state[decoder_state] + +cpp:boost::http::brotli::encode_service[encode_service] + +cpp:boost::http::brotli::encoder_state[encoder_state] + +**Functions** + +cpp:boost::http::brotli::install_decode_service[install_decode_service] + +cpp:boost::http::brotli::install_encode_service[install_encode_service] + +cpp:boost::http::brotli::install_brotli_service[install_brotli_service] + +| **Constants** + +cpp:boost::http::brotli::constants[constants] + +cpp:boost::http::brotli::decoder_param[decoder_param] + +cpp:boost::http::brotli::decoder_result[decoder_result] + +cpp:boost::http::brotli::encoder_mode[encoder_mode] + +cpp:boost::http::brotli::encoder_operation[encoder_operation] + +cpp:boost::http::brotli::encoder_parameter[encoder_parameter] + +cpp:boost::http::brotli::error[error] + +|=== + +[width=100%] +|=== +2+| *bcrypt* | *JSON* + +| **Types** + +cpp:boost::http::bcrypt::result[result] + +**Functions** + +cpp:boost::http::bcrypt::compare[compare] + +cpp:boost::http::bcrypt::gen_salt[gen_salt] + +cpp:boost::http::bcrypt::get_rounds[get_rounds] + +cpp:boost::http::bcrypt::hash[hash] + +| **Constants** + +cpp:boost::http::bcrypt::error[error] + +cpp:boost::http::bcrypt::version[version] + +| **Types** + +cpp:boost::http::json_sink[json_sink] |=== From 2cef3ad323f15b4ac48f24d51d75c1d88c448bc5 Mon Sep 17 00:00:00 2001 From: Vinnie Falco Date: Mon, 2 Feb 2026 07:49:59 -0800 Subject: [PATCH 6/6] Use Capy test_suite --- test/unit/CMakeLists.txt | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/test/unit/CMakeLists.txt b/test/unit/CMakeLists.txt index 8de1aeb4..9a180a19 100644 --- a/test/unit/CMakeLists.txt +++ b/test/unit/CMakeLists.txt @@ -7,8 +7,8 @@ # Official repository: https://github.com/cppalliance/http # -if (NOT TARGET boost_url_test_suite) - add_subdirectory(../../../url/extra/test_suite test_suite) +if (NOT TARGET boost_capy_test_suite) + add_subdirectory(../../../capy/extra/test_suite test_suite) endif() file(GLOB_RECURSE PFILES CONFIGURE_DEPENDS *.cpp *.hpp) @@ -22,7 +22,7 @@ add_executable(boost_http_tests ${PFILES}) target_include_directories(boost_http_tests PRIVATE . ../../) target_link_libraries( boost_http_tests PRIVATE - boost_url_test_suite_with_main + boost_capy_test_suite_main Boost::http Boost::filesystem) @@ -35,7 +35,7 @@ if (TARGET Boost::http_brotli) endif () # Register individual tests with CTest -boost_url_test_suite_discover_tests(boost_http_tests) +boost_capy_test_suite_discover_tests(boost_http_tests) # Add the main test target to Boost's test suite add_dependencies(tests boost_http_tests)