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/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] |=== 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/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/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..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 @@ -334,6 +371,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 +793,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/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 03aa154b..4d943f7b 100644 --- a/src/server/detail/route_match.hpp +++ b/src/server/detail/route_match.hpp @@ -12,8 +12,11 @@ #include #include -#include "src/server/detail/route_rule.hpp" +#include "src/server/route_abnf.hpp" #include "src/server/detail/stable_string.hpp" +#include +#include +#include namespace boost { namespace http { @@ -32,9 +35,14 @@ 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: - // 24 bytes (vector) - path_rule_t::value_type pv_; + system::error_code ec_; + std::string allow_header_; + route_pattern pattern_; + std::vector custom_verbs_; // 16 bytes (pointer + size) stable_string decoded_pat_; @@ -42,6 +50,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/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 e1705f10..5f65297e 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. @@ -230,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; @@ -245,6 +370,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 +466,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 +486,7 @@ flat_router( : impl_(std::make_shared()) { impl_->flatten(*src.impl_); + impl_->options_handler_ = std::move(src.options_handler_); } route_task @@ -357,16 +499,27 @@ 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; 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; @@ -375,8 +528,13 @@ 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); + return impl_->dispatch_loop(p, verb == http::method::options); } route_task @@ -389,19 +547,32 @@ 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 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; @@ -410,8 +581,13 @@ 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); + return impl_->dispatch_loop(p, is_options); } } // http diff --git a/src/server/route_abnf.cpp b/src/server/route_abnf.cpp new file mode 100644 index 00000000..f9855d61 --- /dev/null +++ b/src/server/route_abnf.cpp @@ -0,0 +1,610 @@ +// +// 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; + } +}; + +//------------------------------------------------ +// 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 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) + 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; + } + + // 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(std::size_t i = 0; i < tokens.size(); ++i) + { + 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, char stop_char = '\0') + { + switch(token.type) + { + case route_token_type::text: + return match_text(token.value); + + case route_token_type::param: + return match_param(token.value, stop_char); + + 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 + +//------------------------------------------------ + +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; +} + +//------------------------------------------------ + +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 new file mode 100644 index 00000000..18e17c1e --- /dev/null +++ b/src/server/route_abnf.hpp @@ -0,0 +1,136 @@ +// +// 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); + +//------------------------------------------------ + +/** 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 + +#endif 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) diff --git a/test/unit/server/flat_router.cpp b/test/unit/server/flat_router.cpp index 060eab90..2fa1c8be 100644 --- a/test/unit/server/flat_router.cpp +++ b/test/unit/server/flat_router.cpp @@ -100,11 +100,1218 @@ 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); + } + + //-------------------------------------------- + // 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(); testCopyAssignment(); testDefaultConstruction(); + testOptionsHandler(); + 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(); } };