diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 182acd2b..0fb30cc6 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -154,19 +154,6 @@ jobs: ubsan: true build-type: "RelWithDebInfo" - - compiler: "gcc" - version: "12" - cxxstd: "20" - latest-cxxstd: "20" - cxx: "g++-12" - cc: "gcc-12" - runs-on: "ubuntu-latest" - container: "ubuntu:22.04" - b2-toolset: "gcc" - name: "GCC 12: C++20" - shared: true - build-type: "Release" - - compiler: "gcc" version: "13" cxxstd: "20" diff --git a/doc/modules/ROOT/nav.adoc b/doc/modules/ROOT/nav.adoc index 24aa62a4..c1275d75 100644 --- a/doc/modules/ROOT/nav.adoc +++ b/doc/modules/ROOT/nav.adoc @@ -10,6 +10,8 @@ * Server ** xref:server/router.adoc[Router] ** xref:server/route-patterns.adoc[Route Patterns] +** xref:server/serve-static.adoc[Serving Static Files] +** xref:server/serve-index.adoc[Directory Listings] ** 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/design_requirements/serializer.adoc b/doc/modules/ROOT/pages/design_requirements/serializer.adoc index 58afe4e7..e1302f89 100644 --- a/doc/modules/ROOT/pages/design_requirements/serializer.adoc +++ b/doc/modules/ROOT/pages/design_requirements/serializer.adoc @@ -27,135 +27,72 @@ handle_request( { res.set_start_line(status::not_found, req.version()); res.set_keep_alive(req.keep_alive()); - sr.start(res); + sr.set_message(res); + sr.start(); return {}; } ---- -=== Source Body +=== WriteSink Body -A `source`-like body allows for algorithms that can generate body contents directly in the `serializer` 's internal buffer in one or multiple steps, for example for sending contents of a file. The `serializer` takes ownership of the `source` object and is responsible for driving the algorithm and offering a `MutableBufferSequence` (by calling the related virtual interfaces on the `source` object). +When the caller already has the body data in memory, the WriteSink interface writes caller-owned buffers through the serializer to the stream. The sink handles framing (chunked encoding, compression) automatically. [source,cpp] ---- -system::result +capy::task<> handle_request( serializer& sr, response& res, - request_view req) -{ - res.set_start_line(status::ok, req.version()); - res.set_keep_alive(req.keep_alive()); - res.set_chunked(true); - - http::file file; - system::error_code ec; - file.open("./index.html", file_mode::scan, ec); - if (ec.failed()) - return ec; - - sr.start(res, std::move(file)); - return {}; -} ----- - - -=== ConstBufferSequence Body - -The `serializer` can use body contents passed as a `ConstBufferSequence` without copying it into its internal buffer. - -[source,cpp] ----- -system::result -handle_request( - serializer& sr, - response& res, - request_view req) + request_view req, + capy::WriteStream auto& socket) { res.set_start_line(status::not_found, req.version()); res.set_keep_alive(req.keep_alive()); - // Assume caller has an stable reference to static_pages - sr.start(res, buffers::make_buffer(static_pages.not_found)); - return {}; -} ----- - - -=== ConstBufferSequence Body with Ownership Transfer - -This is useful when the caller wants to create a `ConstBufferSequence` from an object which lives on the caller's stack or it is inconvenient for the caller to keep the object alive until the send operation is complete. - -[source,cpp] ----- -system::result -handle_request( - serializer& sr, - response& res, - request_view req) -{ - res.set_start_line(status::ok, req.version()); - res.set_keep_alive(req.keep_alive()); - - std::string body{ "HOWDY!" }; - - sr.start(res, [body = std::move(body)]{return buffers::make_buffer(body);}); - return {}; + // Assume caller has a stable reference to static_pages + sr.set_message(res); + auto sink = sr.sink_for(socket); + co_await sink.write_eof( + capy::make_buffer(static_pages.not_found)); } ---- -=== Streaming Body Contents - -Sometimes it is desirable to read the body contents asynchronously, such as when reading from a socket, file, or a pipe. In such scenarios, it is possible to borrow buffers from the `serializer` and use them for the asynchronous read operation. As a result, the contents would be read directly into the `serializer` 's internal buffer without needing to make an extra copy. +=== BufferSink Body (Zero-Copy Streaming) -The following snippet demonstrates a usage example (using synchronous read/write APIs to keep the code simple): +Sometimes it is desirable to read the body contents asynchronously, such as when reading from a socket, file, or a pipe. The BufferSink interface lets the caller write directly into the serializer's internal buffer, avoiding an extra copy. [source,cpp] ---- -template -void relay_body_contents( +capy::task<> +relay_body_contents( serializer& sr, response& res, request_view req, - ReadStream& src, - WriteStream& client_session) + capy::ReadStream auto& src, + capy::WriteStream auto& client_session) { res.set_start_line(status::ok, req.version()); res.set_keep_alive(req.keep_alive()); res.set_chunked(true); - sr.start_stream(res); - - auto write_some = [&] - { - auto bs = sr.prepare().value(); - system::error_code ec; - auto length = client_session.write_some(bs, ec); - // if (ec.failed()) handle the error... - sr.consume(length); - }; + sr.set_message(res); + auto sink = sr.sink_for(client_session); for (;;) { - auto bs = sr.stream_prepare(); - system::error_code ec; - auto length = src.read_some(bs, ec); - sr.stream_commit(length); + capy::mutable_buffer arr[16]; + auto bufs = sink.prepare(arr); - if (ec == asio::error::eof) + auto [ec, n] = co_await src.read_some(bufs); + if (ec == capy::error::eof) + { + co_await sink.commit_eof(n); break; + } - // if (ec.failed()) handle the error... - - write_some(); + co_await sink.commit(n); } - - // Closing stream to signal serializer end of body contents - sr.stream_close(); - - while (!sr.is_done()) - write_some(); } ----- \ No newline at end of file +---- diff --git a/doc/modules/ROOT/pages/reference.adoc b/doc/modules/ROOT/pages/reference.adoc index 43d45e54..c39b9d6c 100644 --- a/doc/modules/ROOT/pages/reference.adoc +++ b/doc/modules/ROOT/pages/reference.adoc @@ -161,8 +161,6 @@ cpp:boost::http::tchars[tchars] | **Types (1/2)** -cpp:boost::http::basic_router[basic_router] - cpp:boost::http::byte_range[byte_range] cpp:boost::http::cors[cors] @@ -173,16 +171,12 @@ 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] diff --git a/doc/modules/ROOT/pages/router.adoc b/doc/modules/ROOT/pages/router.adoc index 4539a1fb..b1f220c0 100644 --- a/doc/modules/ROOT/pages/router.adoc +++ b/doc/modules/ROOT/pages/router.adoc @@ -47,13 +47,10 @@ int main() return route::send; }); - // Flatten for dispatch - flat_router fr(std::move(r)); - // Dispatch a request route_params params; // ... populate params from parsed request ... - auto result = co_await fr.dispatch(method::get, url, params); + auto result = co_await r.dispatch(method::get, url, params); } ---- @@ -354,7 +351,7 @@ matches `show_stats`. == Dispatching Requests -Convert the router to a `flat_router` for efficient dispatch: +Dispatch requests directly on the router: [source,cpp] ---- @@ -362,15 +359,12 @@ Convert the router to a `flat_router` for efficient dispatch: router r; // ... add routes ... -// Flatten for dispatch (do this once) -flat_router fr(std::move(r)); - // Dispatch requests route_params p; p.url = parsed_url; p.req = parsed_request; -auto result = co_await fr.dispatch( +auto result = co_await r.dispatch( p.req.method(), p.url, p); @@ -393,8 +387,8 @@ case route::close: } ---- -The `flat_router` pre-processes routes into a structure optimized for -dispatch performance. Create it once after all routes are registered. +The router internally flattens routes for efficient dispatch. The first +call to `dispatch()` finalizes the routing table automatically. == The route_params Object @@ -402,7 +396,7 @@ The standard `route_params` type contains everything handlers need: [source,cpp] ---- -struct route_params : route_params_base +class route_params { urls::url_view url; // Parsed request target http::request req; // Request headers @@ -538,9 +532,6 @@ int main() return route::send; }); - // Flatten and dispatch - flat_router fr(std::move(r)); - // ... integrate with your I/O layer ... } ---- diff --git a/doc/modules/ROOT/pages/serializing.adoc b/doc/modules/ROOT/pages/serializing.adoc index 7e58efd2..7072b346 100644 --- a/doc/modules/ROOT/pages/serializing.adoc +++ b/doc/modules/ROOT/pages/serializing.adoc @@ -15,42 +15,41 @@ automatically. == Basic Usage -Serialization follows a push model. You provide a message, then pull output -buffers until complete: +Serialization follows a two-step model: associate a message with `set_message`, +then choose a body mode. For messages without a body, call `start` and pull +output buffers until complete: [source,cpp] ---- -// 1. Install serializer service with configuration -serializer::config cfg; -install_serializer_service(cfg); +// 1. Create serializer with configuration +auto cfg = make_serializer_config(serializer_config{}); +serializer sr(cfg); -// 2. Create serializer -serializer sr; - -// 3. Start with a message +// 2. Associate a message response res(status::ok); -res.set(field::content_type, "text/plain"); -sr.start(res, "Hello, world!"); +res.set_payload_size(0); +sr.set_message(res); +sr.start(); -// 4. Pull output and write to socket +// 3. Pull output and write to socket while (!sr.is_done()) { auto result = sr.prepare(); if (!result) throw system::system_error(result.error()); - - socket.write(*result); + + co_await socket.write(*result); sr.consume(capy::buffer_size(*result)); } ---- == Configuration -Serializer behavior is controlled through configuration installed globally: +Serializer behavior is controlled through configuration: [source,cpp] ---- -serializer::config cfg; +serializer_config cfg; // Content encoding (compression) cfg.apply_gzip_encoder = true; // Enable gzip compression @@ -68,117 +67,107 @@ cfg.brotli_comp_window = 18; // 10-24 // Buffer settings cfg.payload_buffer = 8192; // Internal buffer size -cfg.max_type_erase = 1024; // Space for source storage -install_serializer_service(cfg); +auto shared_cfg = make_serializer_config(cfg); +serializer sr(shared_cfg); ---- -== Body Sources - -The serializer supports several ways to provide message body content. - -=== No Body +== Writing Body Data -For messages without a body (HEAD responses, 204 No Content, etc.): +The serializer provides two interfaces for writing body data through a +`sink` object. Both are accessed through `sink_for`: [source,cpp] ---- -response res(status::no_content); -sr.start(res); // No body argument +auto sink = sr.sink_for(socket); ---- -=== Buffer Sequence Body +=== WriteSink (Caller-Owned Buffers) -Provide the body as in-memory buffers: +Write body data from your own buffers. The sink copies the data through +the serializer automatically. This is the simplest approach when you +already have the body data in memory: [source,cpp] ---- response res(status::ok); res.set(field::content_type, "text/plain"); -res.set_content_length(13); +res.set_payload_size(13); +sr.set_message(res); -std::string body = "Hello, world!"; -sr.start(res, capy::buffer(body)); +auto sink = sr.sink_for(socket); +co_await sink.write_eof( + capy::make_buffer(std::string_view("Hello, world!"))); ---- -Multiple buffers are supported: +For large bodies or incremental generation, use multiple writes: [source,cpp] ---- -std::string part1 = "Hello, "; -std::string part2 = "world!"; -std::array buffers = { - capy::buffer(part1), - capy::buffer(part2) -}; - -sr.start(res, buffers); +response res(status::ok); +res.set_chunked(true); +sr.set_message(res); + +auto sink = sr.sink_for(socket); +co_await sink.write(capy::make_buffer(part1)); +co_await sink.write(capy::make_buffer(part2)); +co_await sink.write_eof(); ---- -=== Source Body +=== BufferSink (Zero-Copy) -For large or dynamic bodies, use a source: +Write directly into the serializer's internal buffer for zero-copy +body generation. This avoids copying when the body is produced +incrementally (reading from a file, generating on-the-fly): [source,cpp] ---- -// From file -capy::file f("large_file.bin", capy::file_mode::scan); -res.set_payload_size(f.size()); -sr.start(res, std::move(f)); +response res(status::ok); +res.set_chunked(true); +sr.set_message(res); -// Pull output -while (!sr.is_done()) -{ - auto result = sr.prepare(); - if (!result) - throw system::system_error(result.error()); - - socket.write(*result); - sr.consume(capy::buffer_size(*result)); -} ----- +auto sink = sr.sink_for(socket); -=== Stream Body +// Get writable buffers +capy::mutable_buffer arr[16]; +auto bufs = sink.prepare(arr); -For maximum flexibility, push body data incrementally: +// Write directly into serializer memory +auto n = read_from_file(file, bufs); +co_await sink.commit(n); -[source,cpp] +// More data... +bufs = sink.prepare(arr); +n = read_from_file(file, bufs); +co_await sink.commit_eof(n); ---- -response res(status::ok); -res.set(field::content_type, "application/octet-stream"); -// No content-length - will use chunked encoding -sr.start_stream(res); +=== Lazy Start -// Push body data as it becomes available -while (has_more_data()) -{ - // Get buffer to write into - auto buf = sr.stream_prepare(); - std::size_t n = generate_data(buf); - sr.stream_commit(n); - - // Output is available - auto result = sr.prepare(); - if (result) - { - socket.write(*result); - sr.consume(capy::buffer_size(*result)); - } -} +The sink starts the serializer automatically on first use. You do not +need to call `start_writes` or `start_buffers` explicitly when using +the sink: -// Signal end of body -sr.stream_close(); +* `prepare` / `commit` / `commit_eof` trigger `start_writes` +* `write` / `write_eof` trigger `start_buffers` -// Flush remaining output -while (!sr.is_done()) +== Sink Lifetime + +The sink is a lightweight handle that can be created once and reused +across multiple messages. The serializer must outlive the sink: + +[source,cpp] +---- +serializer sr(cfg); +auto sink = sr.sink_for(socket); + +for (auto& req : requests) { - auto result = sr.prepare(); - if (result) - { - socket.write(*result); - sr.consume(capy::buffer_size(*result)); - } + response res = handle(req); + sr.set_message(res); + co_await sink.write_eof( + capy::make_buffer(body)); + sr.reset(); } ---- @@ -193,24 +182,16 @@ The serializer uses chunked transfer encoding automatically when: ---- response res(status::ok); res.set(field::content_type, "text/event-stream"); -// No Content-Length - chunked encoding will be used +sr.set_message(res); -sr.start_stream(res); +auto sink = sr.sink_for(socket); // Send chunks as events occur for (auto& event : events) -{ - auto buf = sr.stream_prepare(); - auto n = format_event(event, buf); - sr.stream_commit(n); - - // Flush to client - auto result = sr.prepare(); - socket.write(*result); - sr.consume(capy::buffer_size(*result)); -} + co_await sink.write( + capy::make_buffer(format_event(event))); -sr.stream_close(); +co_await sink.write_eof(); ---- == Content Encoding @@ -221,9 +202,9 @@ compresses the body automatically: [source,cpp] ---- // Enable in config -serializer::config cfg; +serializer_config cfg; cfg.apply_gzip_encoder = true; -cfg.apply_brotli_encoder = true; // Requires brotli encode service +cfg.apply_brotli_encoder = true; // Check Accept-Encoding from request if (request_accepts_gzip(req)) @@ -232,7 +213,10 @@ if (request_accepts_gzip(req)) // Body will be compressed } -sr.start(res, large_body); +sr.set_message(res); +auto sink = sr.sink_for(socket); +co_await sink.write_eof( + capy::make_buffer(large_body)); ---- For detailed information on compression services, see: @@ -242,37 +226,47 @@ For detailed information on compression services, see: == Expect: 100-continue -The serializer handles the 100-continue handshake: +The serializer handles the 100-continue handshake. When `prepare` +returns `error::expect_100_continue`, the header has been sent and +the serializer is waiting for the caller to decide whether to +continue sending the body: [source,cpp] ---- -response res(status::ok); -// ... set headers ... -sr.start(res, body_source); +sr.set_message(res); + +auto sink = sr.sink_for(socket); +// Write body data +co_await sink.write_eof( + capy::make_buffer(body)); +---- + +For low-level control over the handshake: +[source,cpp] +---- +sr.set_message(res); +sr.start_writes(); + +// Pull header while (!sr.is_done()) { auto result = sr.prepare(); - + if (result.error() == error::expect_100_continue) { - // Client wants confirmation before sending body - // Send 100 Continue response - response cont(status::continue_); - serializer sr100; - sr100.start(cont); - // ... write sr100 output ... - - // Continue with original response - continue; + // Send 100 Continue interim response + break; } - + if (!result) throw system::system_error(result.error()); - - socket.write(*result); + + co_await socket.write(*result); sr.consume(capy::buffer_size(*result)); } + +// Continue with body... ---- == Error Handling @@ -286,7 +280,7 @@ auto result = sr.prepare(); if (!result) { auto ec = result.error(); - + if (ec == error::expect_100_continue) { // Not an error - handle 100-continue @@ -297,94 +291,30 @@ if (!result) } else { - // Real error (e.g., source read failure) - std::cerr << "Serialization error: " << ec.message() << "\n"; + // Real error + std::cerr << "Serialization error: " + << ec.message() << "\n"; } } ---- -== Custom Sources - -Implement the `source` interface for custom body generation: - -[source,cpp] ----- -class my_source : public source -{ - std::function generator_; - std::string current_; - std::size_t pos_ = 0; - bool done_ = false; - -public: - explicit my_source(std::function gen) - : generator_(std::move(gen)) - { - } - -protected: - results on_read(capy::mutable_buffer b) override - { - results rv; - - while (b.size() > 0 && !done_) - { - // Refill current buffer if empty - if (pos_ >= current_.size()) - { - current_ = generator_(); - pos_ = 0; - if (current_.empty()) - { - done_ = true; - rv.finished = true; - break; - } - } - - // Copy to output - auto avail = current_.size() - pos_; - auto n = std::min(b.size(), avail); - std::memcpy(b.data(), current_.data() + pos_, n); - pos_ += n; - rv.bytes += n; - b = capy::mutable_buffer( - static_cast(b.data()) + n, - b.size() - n); - } - - return rv; - } -}; ----- - == Multiple Messages Reuse the serializer for multiple messages on the same connection: [source,cpp] ---- -serializer sr; +serializer sr(cfg); +auto sink = sr.sink_for(socket); for (auto& request : requests) { - // Process request, build response response res = handle(request); - - // Serialize response - sr.start(res, response_body); - - while (!sr.is_done()) - { - auto result = sr.prepare(); - if (result) - { - socket.write(*result); - sr.consume(capy::buffer_size(*result)); - } - } - - // Reset for next message + sr.set_message(res); + + co_await sink.write_eof( + capy::make_buffer(response_body)); + sr.reset(); } ---- diff --git a/doc/modules/ROOT/pages/server/serve-index.adoc b/doc/modules/ROOT/pages/server/serve-index.adoc new file mode 100644 index 00000000..debf38ab --- /dev/null +++ b/doc/modules/ROOT/pages/server/serve-index.adoc @@ -0,0 +1,260 @@ +// +// 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 +// + += Directory Listings + +When a user navigates to a directory on your server and there's no +`index.html` waiting for them, what should happen? A blank page? A +404? The `serve_index` middleware answers with a browsable directory +listing — a table of files and folders the visitor can click through, +the way web servers have done it since the early days of the web. + +NOTE: Code snippets assume `using namespace boost::http;` is in effect. + +== Quick Start + +Point `serve_index` at a document root and register it as middleware: + +[source,cpp] +---- +#include + +router r; +r.use( serve_index("/var/www/public") ); +---- + +Requests that map to a directory on disk now receive a listing of that +directory's contents. Files get a clickable link, a size column, and a +last-modified timestamp. Directories appear with a trailing slash and +sort to the top so they're easy to find. + +If the path isn't a directory, the request passes through to the next +handler. + +== Pairing with serve_static + +`serve_index` is designed to complement `serve_static`. Used together, +they cover every case: + +[source,cpp] +---- +router r; +r.use( serve_static(root) ); +r.use( serve_index(root) ); +---- + +Here's what happens when a request arrives for a directory: + +1. `serve_static` checks for `index.html` in that directory. +2. If found, it serves the index page. Done. +3. If not found (and `fallthrough` is enabled), the request reaches + `serve_index`. +4. `serve_index` verifies the path is a directory and generates a + listing. + +Directories with an `index.html` get a proper landing page. Directories +without one get a browsable file listing. No request falls through the +cracks. + +== Content Negotiation + +Not every client wants HTML. A command-line tool piping output through +`jq` would prefer JSON. A script might want plain text it can parse +with `awk`. The `serve_index` middleware reads the `Accept` header and +responds in the format the client prefers. + +Three formats are supported: + +[cols="1,2,3"] +|=== +| Format | Content-Type | When Selected + +| HTML +| `text/html; charset=utf-8` +| Default, or when `Accept` prefers `text/html` + +| JSON +| `application/json; charset=utf-8` +| When `Accept` prefers `application/json` + +| Plain text +| `text/plain; charset=utf-8` +| When `Accept` prefers `text/plain` +|=== + +=== HTML Response + +Browsers receive a styled HTML page with a table of names, sizes, and +modification times. The styling is minimal and modern — no external +dependencies, no JavaScript, just clean semantic HTML and a few lines +of CSS. + +=== JSON Response + +API clients receive a JSON array of entry objects: + +[source,json] +---- +[ + {"name":"docs","type":"directory","size":0,"mtime":1738886400}, + {"name":"readme.txt","type":"file","size":2048,"mtime":1738800000} +] +---- + +Each object includes: + +* `name` — the file or directory name +* `type` — either `"file"` or `"directory"` +* `size` — size in bytes (0 for directories) +* `mtime` — modification time as a Unix epoch + +=== Plain Text Response + +Scripts and minimal clients receive one filename per line, with a +trailing `/` on directories: + +---- +docs/ +readme.txt +---- + +You can test content negotiation from the command line: + +[source,bash] +---- +# HTML (default) +curl http://localhost:8080/files/ + +# JSON +curl -H "Accept: application/json" http://localhost:8080/files/ + +# Plain text +curl -H "Accept: text/plain" http://localhost:8080/files/ +---- + +== Configuration + +Pass a `serve_index::options` struct to customize behavior: + +[source,cpp] +---- +serve_index::options opts; +opts.hidden = true; // show dotfiles +opts.show_parent = false; // hide ".." link +opts.fallthrough = false; // return 405 for non-GET/HEAD + +router r; +r.use( serve_index("/var/www/public", opts) ); +---- + +=== Options Reference + +[cols="2,1,4"] +|=== +| Option | Default | Description + +| `hidden` +| `false` +| Show hidden files (names starting with `.`). When `false`, dotfiles + are silently omitted from the listing. + +| `show_parent` +| `true` +| Include a `..` link to the parent directory. Automatically suppressed + when the listed directory is the document root itself. + +| `fallthrough` +| `true` +| When a non-GET/HEAD request arrives, pass it to the next handler + instead of returning `405 Method Not Allowed`. +|=== + +== Sorting + +Entries are sorted with directories first, then files, both in +case-insensitive alphabetical order. This keeps the listing +predictable across platforms — a directory tree looks the same +whether the server runs on Linux, macOS, or Windows. + +== Trailing Slash Redirect + +When a request arrives for a directory path without a trailing slash, +`serve_index` responds with a `301 Moved Permanently` redirect to +the same path with a slash appended. This ensures that relative links +within the HTML listing resolve correctly. + +For example, a request for `/files` redirects to `/files/`, and the +listing is then served at the canonical URL. + +== Hidden Files + +By default, files and directories whose names start with a dot are +excluded from listings. Configuration files like `.env`, `.git`, and +`.htaccess` don't appear. + +Enable them when you need full visibility: + +[source,cpp] +---- +serve_index::options opts; +opts.hidden = true; + +router r; +r.use( serve_index(root, opts) ); +---- + +This only controls whether dotfiles appear in listings. It does not +affect whether they can be downloaded — that's the job of +`serve_static` and its `dotfiles_policy`. + +== The Parent Directory Link + +The `..` entry at the top of a listing lets visitors navigate up to +the parent directory. It appears by default, but `serve_index` +automatically hides it when the current directory is the document +root. There's nowhere "up" to go from root. + +You can also disable it entirely: + +[source,cpp] +---- +serve_index::options opts; +opts.show_parent = false; +---- + +This is useful when the listing is embedded in a larger page layout +where separate navigation handles the hierarchy. + +== Example: Development File Browser + +A development server that exposes a project tree with full visibility: + +[source,cpp] +---- +serve_index::options idx_opts; +idx_opts.hidden = true; +idx_opts.show_parent = true; + +serve_static_options static_opts; +static_opts.dotfiles = dotfiles_policy::allow; + +router r; +r.use( serve_static(root, static_opts) ); +r.use( serve_index(root, idx_opts) ); +---- + +Every file is visible and downloadable — dotfiles included. This is +appropriate for local development but not for production. + +== See Also + +* xref:server/serve-static.adoc[Serving Static Files] — file serving + middleware that pairs with `serve_index` +* xref:server/route-patterns.adoc[Route Patterns] — how request paths + are matched to handlers diff --git a/doc/modules/ROOT/pages/server/serve-static.adoc b/doc/modules/ROOT/pages/server/serve-static.adoc new file mode 100644 index 00000000..a21a2674 --- /dev/null +++ b/doc/modules/ROOT/pages/server/serve-static.adoc @@ -0,0 +1,312 @@ +// +// 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 +// + += Serving Static Files + +Every web application eventually needs to deliver files — stylesheets, +images, scripts, HTML pages. You could open files manually and stream +bytes into the response, but that means handling content types, caching +headers, conditional requests, range requests, directory traversal +attacks, and a dozen other details that are easy to get wrong. The +`serve_static` middleware handles all of this in a single line. + +NOTE: Code snippets assume `using namespace boost::http;` is in effect. + +== Quick Start + +Point `serve_static` at a directory on disk and register it as +middleware: + +[source,cpp] +---- +#include + +router r; +r.use( serve_static("/var/www/public") ); +---- + +That's it. Requests are now mapped to files under `/var/www/public`. +A request for `/css/style.css` serves the file +`/var/www/public/css/style.css`. The middleware automatically: + +* Detects `Content-Type` from the file extension +* Generates `ETag` and `Last-Modified` headers +* Responds to conditional requests with `304 Not Modified` +* Handles `Range` requests for partial content +* Redirects directory URLs that lack a trailing slash +* Serves `index.html` when a directory is requested + +If a file is not found, the request passes through to the next handler +in the chain — exactly as you would expect from middleware. + +== How Requests Map to Files + +The mapping is straightforward. The request path is appended to the +document root: + +[cols="2,2,3"] +|=== +| Document Root | Request Path | File Served + +| `/var/www/public` +| `/` +| `/var/www/public/index.html` + +| `/var/www/public` +| `/logo.png` +| `/var/www/public/logo.png` + +| `/var/www/public` +| `/js/app.js` +| `/var/www/public/js/app.js` + +| `/var/www/public` +| `/docs` +| redirect to `/docs/`, then serve `/var/www/public/docs/index.html` +|=== + +Only `GET` and `HEAD` methods are served. Other methods pass through +to the next handler (or return `405 Method Not Allowed` when +`fallthrough` is disabled). + +== Configuration + +Pass a `serve_static_options` struct to customize behavior: + +[source,cpp] +---- +serve_static_options opts; +opts.max_age = 86400; // cache for one day +opts.immutable = true; // assets never change at the same URL +opts.dotfiles = dotfiles_policy::deny; + +router r; +r.use( serve_static("/var/www/public", opts) ); +---- + +Every option has a sensible default. Override only what you need. + +=== Options Reference + +[cols="2,1,4"] +|=== +| Option | Default | Description + +| `dotfiles` +| `ignore` +| How to handle dotfiles (`.hidden`, `.env`). See <>. + +| `max_age` +| `0` +| Seconds for the `Cache-Control: max-age` directive. Zero means no + cache header is added. + +| `accept_ranges` +| `true` +| Advertise support for range requests with `Accept-Ranges: bytes`. + +| `etag` +| `true` +| Generate an `ETag` header from file metadata. + +| `fallthrough` +| `true` +| When a file is not found, pass the request to the next handler + instead of returning 404. + +| `last_modified` +| `true` +| Set the `Last-Modified` header from the file's modification time. + +| `redirect` +| `true` +| Redirect directory requests that are missing a trailing slash + (e.g. `/docs` -> `/docs/`). + +| `immutable` +| `false` +| Append `immutable` to the `Cache-Control` header. Only takes effect + when `max_age` is non-zero. + +| `index` +| `true` +| Serve `index.html` when a directory is requested. +|=== + +== Caching + +Browsers and proxies rely on HTTP caching headers to avoid +re-downloading files that haven't changed. `serve_static` supports +three caching mechanisms that work together. + +=== ETags and Conditional Requests + +When `etag` is enabled, every response includes an `ETag` header +derived from the file's size and modification time. On subsequent +requests the browser sends `If-None-Match` with the stored ETag. +If the file hasn't changed, the server responds with +`304 Not Modified` — no body, no wasted bandwidth. + +The same principle applies to `Last-Modified` and `If-Modified-Since`. +Both mechanisms are enabled by default. + +=== Cache-Control + +Set `max_age` to tell browsers how long a response is fresh: + +[source,cpp] +---- +opts.max_age = 3600; // one hour +---- + +For assets with content-hashed filenames (like `app.a1b2c3.js`), +the URL changes whenever the content changes. These files can be +cached aggressively: + +[source,cpp] +---- +opts.max_age = 31536000; // one year +opts.immutable = true; // never revalidate +---- + +The `immutable` directive tells modern browsers they don't need to +send conditional requests at all — the file at this URL will never +change. + +== Dotfile Handling + +Files and directories whose names begin with a dot are often +sensitive: `.env`, `.git`, `.htaccess`. The `dotfiles` option +controls how `serve_static` treats them. + +[cols="1,4"] +|=== +| Policy | Behavior + +| `dotfiles_policy::ignore` +| Pretend the file doesn't exist. If `fallthrough` is enabled, the + request passes to the next handler. Otherwise, 404. + +| `dotfiles_policy::deny` +| Return `403 Forbidden`. The client knows the file exists but cannot + access it. + +| `dotfiles_policy::allow` +| Serve dotfiles like any other file. +|=== + +The default is `ignore`, which is the safest choice. Use `deny` when +you want to actively reject requests for dotfiles. Use `allow` only +when you have a specific reason to expose them. + +[source,cpp] +---- +opts.dotfiles = dotfiles_policy::deny; +---- + +== Range Requests + +Large files benefit from range requests. A video player can seek to +any position without downloading the entire file. A download manager +can resume an interrupted transfer. When `accept_ranges` is enabled, +`serve_static` advertises `Accept-Ranges: bytes` and honors `Range` +headers by responding with `206 Partial Content` and the requested +byte range. + +This is enabled by default. No configuration is needed. + +== Fallthrough + +The `fallthrough` option determines what happens when `serve_static` +cannot serve a request — either because the file doesn't exist or the +HTTP method isn't `GET`/`HEAD`. + +When `fallthrough` is `true` (the default), unmatched requests pass +to the next handler. This is how middleware is supposed to work: each +handler tries to do its job, and if it can't, it steps aside. + +[source,cpp] +---- +router r; +r.use( serve_static("/var/www/public") ); // try files first +r.use( serve_index("/var/www/public") ); // then directory listings +r.add( method::get, "/api/status", // then API routes + [](route_params& rp) -> route_task { + co_await rp.send("OK"); + co_return route_done; + }); +---- + +When `fallthrough` is `false`, the middleware returns an error response +directly (404 for missing files, 405 for wrong methods) instead of +passing through. + +== Combining with serve_index + +A common pattern pairs `serve_static` with `serve_index` so that +directories without an `index.html` get a browsable file listing: + +[source,cpp] +---- +router r; +r.use( serve_static(root) ); +r.use( serve_index(root) ); +---- + +When a request arrives for a directory: + +1. `serve_static` looks for `index.html` in that directory. +2. If found, it serves the index page. +3. If not found and `fallthrough` is true, the request reaches + `serve_index`, which generates a directory listing. + +== Mounting on a Subpath + +Register `serve_static` under a specific route prefix to serve files +from a namespace: + +[source,cpp] +---- +router r; +r.use( "/assets", serve_static("/var/www/assets") ); +---- + +Now `/assets/logo.png` maps to `/var/www/assets/logo.png`, while +requests outside `/assets/` are unaffected. + +== Example: Production Configuration + +A production-ready setup might look like this: + +[source,cpp] +---- +serve_static_options opts; +opts.max_age = 86400; +opts.etag = true; +opts.last_modified = true; +opts.dotfiles = dotfiles_policy::deny; +opts.redirect = true; + +router r; +r.use( serve_static("/var/www/public", opts) ); +r.use( serve_index("/var/www/public") ); +---- + +This configuration: + +* Caches files for 24 hours +* Uses ETags and Last-Modified for revalidation +* Blocks access to dotfiles +* Redirects `/docs` to `/docs/` +* Falls back to directory listings when no index file exists + +== See Also + +* xref:server/route-patterns.adoc[Route Patterns] — how request paths + are matched to handlers diff --git a/include/boost/http.hpp b/include/boost/http.hpp index 66784315..ad2358d0 100644 --- a/include/boost/http.hpp +++ b/include/boost/http.hpp @@ -44,8 +44,7 @@ #include #include +#include #include -#include -#include #endif diff --git a/include/boost/http/application.hpp b/include/boost/http/application.hpp index aff664a5..47ab9c15 100644 --- a/include/boost/http/application.hpp +++ b/include/boost/http/application.hpp @@ -1,5 +1,5 @@ // -// Copyright (c) 2022 Vinnie Falco (vinnie dot falco at gmail dot com) +// Copyright (c) 2022 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 http://www.boost.org/LICENSE_1_0.txt) diff --git a/include/boost/http/bcrypt.hpp b/include/boost/http/bcrypt.hpp index 6129c428..f6081665 100644 --- a/include/boost/http/bcrypt.hpp +++ b/include/boost/http/bcrypt.hpp @@ -10,30 +10,24 @@ /** @file bcrypt password hashing library. - This header includes all bcrypt-related functionality including - password hashing, verification, and salt generation. + This header provides bcrypt password hashing with three API tiers: - bcrypt is a password-hashing function designed by Niels Provos - and David Mazières based on the Blowfish cipher. It incorporates - a salt to protect against rainbow table attacks and an adaptive - cost parameter that can be increased as hardware improves. + **Tier 1 -- Synchronous** (low-level, no capy dependency): + @code + bcrypt::result r = bcrypt::hash("password", 12); + system::error_code ec; + bool ok = bcrypt::compare("password", r.str(), ec); + @endcode + **Tier 2 -- Capy Task** (lazy coroutine, caller controls executor): @code - #include + auto r = co_await bcrypt::hash_task("password", 12); + @endcode - // Hash a password - http::bcrypt::result r; - http::bcrypt::hash(r, "my_password", 12); - - // Store r.str() in database... - - // Verify later - system::error_code ec; - bool ok = boost::http::bcrypt::compare("my_password", stored_hash, ec); - if (ec) - handle_malformed_hash(); - else if (ok) - grant_access(); + **Tier 3 -- Friendly Async** (auto-offloads to system thread pool): + @code + auto r = co_await bcrypt::hash_async("password", 12); + bool ok = co_await bcrypt::compare_async("password", r.str()); @endcode */ @@ -41,9 +35,651 @@ #define BOOST_HTTP_BCRYPT_HPP #include -#include -#include -#include -#include +#include +#include +#include +#include +#include + +#include +#include +#include +#include +#include + +#include +#include +#include +#include +#include +#include + +namespace boost { +namespace http { +namespace bcrypt { + +//------------------------------------------------ + +/** bcrypt hash version prefix. + + The version determines which variant of bcrypt is used. + All versions produce compatible hashes. +*/ +enum class version +{ + /// $2a$ - Original specification + v2a, + + /// $2b$ - Fixed handling of passwords > 255 chars (recommended) + v2b +}; + +//------------------------------------------------ + +/** Error codes for bcrypt operations. + + These errors indicate malformed input from untrusted sources. +*/ +enum class error +{ + /// Success + ok = 0, + + /// Salt string is malformed + invalid_salt, + + /// Hash string is malformed + invalid_hash +}; + +} // bcrypt +} // http + +namespace system { +template<> +struct is_error_code_enum< + ::boost::http::bcrypt::error> +{ + static bool const value = true; +}; +} // system +} // boost + +namespace std { +template<> +struct is_error_code_enum< + ::boost::http::bcrypt::error> + : std::true_type {}; +} // std + +namespace boost { +namespace http { +namespace bcrypt { + +namespace detail { + +struct BOOST_SYMBOL_VISIBLE + error_cat_type + : system::error_category +{ + BOOST_HTTP_DECL const char* name( + ) const noexcept override; + BOOST_HTTP_DECL std::string message( + int) const override; + BOOST_HTTP_DECL char const* message( + int, char*, std::size_t + ) const noexcept override; + BOOST_SYSTEM_CONSTEXPR error_cat_type() + : error_category(0xbc8f2a4e7c193d56) + { + } +}; + +BOOST_HTTP_DECL extern + error_cat_type error_cat; + +} // detail + +inline +BOOST_SYSTEM_CONSTEXPR +system::error_code +make_error_code( + error ev) noexcept +{ + return system::error_code{ + static_cast::type>(ev), + detail::error_cat}; +} + +//------------------------------------------------ + +/** Fixed-size buffer for bcrypt hash output. + + Stores a bcrypt hash string (max 60 chars) in an + inline buffer with no heap allocation. + + @par Example + @code + bcrypt::result r = bcrypt::hash("password", 10); + core::string_view sv = r; // or r.str() + std::cout << r.c_str(); // null-terminated + @endcode +*/ +class result +{ + char buf_[61]; + unsigned char size_; + +public: + /** Default constructor. + + Constructs an empty result. + */ + result() noexcept + : size_(0) + { + buf_[0] = '\0'; + } + + /** Return the hash as a string_view. + */ + core::string_view + str() const noexcept + { + return core::string_view(buf_, size_); + } + + /** Implicit conversion to string_view. + */ + operator core::string_view() const noexcept + { + return str(); + } + + /** Return null-terminated C string. + */ + char const* + c_str() const noexcept + { + return buf_; + } + + /** Return pointer to data. + */ + char const* + data() const noexcept + { + return buf_; + } + + /** Return size in bytes (excludes null terminator). + */ + std::size_t + size() const noexcept + { + return size_; + } + + /** Check if result is empty. + */ + bool + empty() const noexcept + { + return size_ == 0; + } + + /** Check if result contains valid data. + */ + explicit + operator bool() const noexcept + { + return size_ != 0; + } + +private: + friend BOOST_HTTP_DECL result gen_salt(unsigned, version); + friend BOOST_HTTP_DECL result hash(core::string_view, unsigned, version); + friend BOOST_HTTP_DECL result hash(core::string_view, core::string_view, system::error_code&); + + char* buf() noexcept { return buf_; } + void set_size(unsigned char n) noexcept + { + size_ = n; + buf_[n] = '\0'; + } +}; + +//------------------------------------------------ + +/** Generate a random salt. + + Creates a bcrypt salt string suitable for use with + the hash() function. + + @par Preconditions + @code + rounds >= 4 && rounds <= 31 + @endcode + + @par Exception Safety + Strong guarantee. + + @par Complexity + Constant. + + @param rounds Cost factor. Each increment doubles the work. + Default is 10, which takes approximately 100ms on modern hardware. + + @param ver Hash version to use. + + @return A 29-character salt string. + + @throws std::invalid_argument if rounds is out of range. + @throws system_error on RNG failure. +*/ +BOOST_HTTP_DECL +result +gen_salt( + unsigned rounds = 10, + version ver = version::v2b); + +/** Hash a password with auto-generated salt. + + Generates a random salt and hashes the password. + + @par Preconditions + @code + rounds >= 4 && rounds <= 31 + @endcode + + @par Exception Safety + Strong guarantee. + + @par Complexity + O(2^rounds). + + @param password The password to hash. Only the first 72 bytes + are used (bcrypt limitation). + + @param rounds Cost factor. Each increment doubles the work. + + @param ver Hash version to use. + + @return A 60-character hash string. + + @throws std::invalid_argument if rounds is out of range. + @throws system_error on RNG failure. +*/ +BOOST_HTTP_DECL +result +hash( + core::string_view password, + unsigned rounds = 10, + version ver = version::v2b); + +/** Hash a password using a provided salt. + + Uses the given salt to hash the password. The salt should + be a string previously returned by gen_salt() or extracted + from a hash string. + + @par Exception Safety + Strong guarantee. + + @par Complexity + O(2^rounds). + + @param password The password to hash. + + @param salt The salt string (29 characters). + + @param ec Set to bcrypt::error::invalid_salt if the salt + is malformed. + + @return A 60-character hash string, or empty result on error. +*/ +BOOST_HTTP_DECL +result +hash( + core::string_view password, + core::string_view salt, + system::error_code& ec); + +/** Compare a password against a hash. + + Extracts the salt from the hash, re-hashes the password, + and compares the result. + + @par Exception Safety + Strong guarantee. + + @par Complexity + O(2^rounds). + + @param password The plaintext password to check. + + @param hash The hash string to compare against. + + @param ec Set to bcrypt::error::invalid_hash if the hash + is malformed. + + @return true if the password matches the hash, false if + it does not match OR if an error occurred. Always check + ec to distinguish between a mismatch and an error. +*/ +BOOST_HTTP_DECL +bool +compare( + core::string_view password, + core::string_view hash, + system::error_code& ec); + +/** Extract the cost factor from a hash string. + + @par Exception Safety + Strong guarantee. + + @par Complexity + Constant. + + @param hash The hash string to parse. + + @param ec Set to bcrypt::error::invalid_hash if the hash + is malformed. + + @return The cost factor (4-31) on success, or 0 if an + error occurred. +*/ +BOOST_HTTP_DECL +unsigned +get_rounds( + core::string_view hash, + system::error_code& ec); + +namespace detail { + +// bcrypt truncates passwords to 72 bytes +struct password_buf +{ + char data_[72]; + unsigned char size_; + + explicit password_buf( + core::string_view s) noexcept + : size_(static_cast( + (std::min)(s.size(), std::size_t{72}))) + { + std::memcpy(data_, s.data(), size_); + } + + operator core::string_view() const noexcept + { + return {data_, size_}; + } +}; + +// bcrypt hashes are always 60 characters +struct hash_buf +{ + char data_[61]; + unsigned char size_; + + explicit hash_buf( + core::string_view s) noexcept + : size_(static_cast( + (std::min)(s.size(), std::size_t{60}))) + { + std::memcpy(data_, s.data(), size_); + data_[size_] = '\0'; + } + + operator core::string_view() const noexcept + { + return {data_, size_}; + } +}; + +} // detail + +//------------------------------------------------ + +/** Hash a password, returning a lazy task. + + Returns a @ref capy::task that wraps the synchronous + hash() call. The caller can co_await this task directly + or launch it on a specific executor via run_async(). + + @par Example + @code + // co_await in current context + bcrypt::result r = co_await bcrypt::hash_task("password", 12); + + // or launch on a specific executor + run_async(my_executor)(bcrypt::hash_task("password", 12)); + @endcode + + @param password The password to hash. + + @param rounds Cost factor. Each increment doubles the work. + + @param ver Hash version to use. + + @return A lazy task yielding `result`. + + @throws std::invalid_argument if rounds is out of range. + @throws system_error on RNG failure. +*/ +inline +capy::task +hash_task( + core::string_view password, + unsigned rounds = 10, + version ver = version::v2b) +{ + detail::password_buf pw(password); + co_return hash(pw, rounds, ver); +} + +/** Compare a password against a hash, returning a lazy task. + + Returns a @ref capy::task that wraps the synchronous + compare() call. Errors are translated to exceptions. + + @par Example + @code + bool ok = co_await bcrypt::compare_task("password", stored_hash); + @endcode + + @param password The plaintext password to check. + + @param hash_str The hash string to compare against. + + @return A lazy task yielding `bool`. + + @throws system_error if the hash is malformed. +*/ +inline +capy::task +compare_task( + core::string_view password, + core::string_view hash_str) +{ + detail::password_buf pw(password); + detail::hash_buf hs(hash_str); + system::error_code ec; + bool ok = compare(pw, hs, ec); + if(ec.failed()) + http::detail::throw_system_error(ec); + co_return ok; +} + +//------------------------------------------------ + +namespace detail { + +struct hash_async_op +{ + password_buf password_; + unsigned rounds_; + version ver_; + result result_; + std::exception_ptr ep_; + + bool await_ready() const noexcept + { + return false; + } + + void await_suspend( + capy::coro cont, + capy::executor_ref caller_ex, + std::stop_token) + { + auto& pool = capy::get_system_context(); + auto sys_ex = pool.get_executor(); + capy::run_async(sys_ex, + [this, cont, caller_ex] + (result r) mutable + { + result_ = r; + caller_ex.dispatch(cont); + }, + [this, cont, caller_ex] + (std::exception_ptr ep) mutable + { + ep_ = ep; + caller_ex.dispatch(cont); + } + )(hash_task(password_, rounds_, ver_)); + } + + result await_resume() + { + if(ep_) + std::rethrow_exception(ep_); + return result_; + } +}; + +struct compare_async_op +{ + password_buf password_; + hash_buf hash_str_; + bool result_ = false; + std::exception_ptr ep_; + + bool await_ready() const noexcept + { + return false; + } + + void await_suspend( + capy::coro cont, + capy::executor_ref caller_ex, + std::stop_token) + { + auto& pool = capy::get_system_context(); + auto sys_ex = pool.get_executor(); + capy::run_async(sys_ex, + [this, cont, caller_ex] + (bool ok) mutable + { + result_ = ok; + caller_ex.dispatch(cont); + }, + [this, cont, caller_ex] + (std::exception_ptr ep) mutable + { + ep_ = ep; + caller_ex.dispatch(cont); + } + )(compare_task(password_, hash_str_)); + } + + bool await_resume() + { + if(ep_) + std::rethrow_exception(ep_); + return result_; + } +}; + +} // detail + +/** Hash a password asynchronously on the system thread pool. + + Returns an awaitable that offloads the CPU-intensive + bcrypt work to the system thread pool, then resumes + the caller on their original executor. Modeled after + Express.js: `await bcrypt.hash(password, 12)`. + + @par Example + @code + bcrypt::result r = co_await bcrypt::hash_async("my_password", 12); + @endcode + + @param password The password to hash. + + @param rounds Cost factor. Each increment doubles the work. + + @param ver Hash version to use. + + @return An awaitable yielding `result`. + + @throws std::invalid_argument if rounds is out of range. + @throws system_error on RNG failure. +*/ +inline +detail::hash_async_op +hash_async( + core::string_view password, + unsigned rounds = 10, + version ver = version::v2b) +{ + return detail::hash_async_op{ + detail::password_buf(password), + rounds, + ver, + {}, + {}}; +} + +/** Compare a password against a hash asynchronously. + + Returns an awaitable that offloads the CPU-intensive + bcrypt work to the system thread pool, then resumes + the caller on their original executor. Modeled after + Express.js: `await bcrypt.compare(password, hash)`. + + @par Example + @code + bool ok = co_await bcrypt::compare_async("my_password", stored_hash); + @endcode + + @param password The plaintext password to check. + + @param hash_str The hash string to compare against. + + @return An awaitable yielding `bool`. + + @throws system_error if the hash is malformed. +*/ +inline +detail::compare_async_op +compare_async( + core::string_view password, + core::string_view hash_str) +{ + return detail::compare_async_op{ + detail::password_buf(password), + detail::hash_buf(hash_str), + false, + {}}; +} + +} // bcrypt +} // http +} // boost #endif diff --git a/include/boost/http/bcrypt/error.hpp b/include/boost/http/bcrypt/error.hpp deleted file mode 100644 index 593cbee0..00000000 --- a/include/boost/http/bcrypt/error.hpp +++ /dev/null @@ -1,102 +0,0 @@ -// -// 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 http://www.boost.org/LICENSE_1_0.txt) -// -// Official repository: https://github.com/cppalliance/http -// - -#ifndef BOOST_HTTP_BCRYPT_ERROR_HPP -#define BOOST_HTTP_BCRYPT_ERROR_HPP - -#include -#include -#include -#include - -namespace boost { -namespace http { -namespace bcrypt { - -/** Error codes for bcrypt operations. - - These errors indicate malformed input from untrusted sources. -*/ -enum class error -{ - /// Success - ok = 0, - - /// Salt string is malformed - invalid_salt, - - /// Hash string is malformed - invalid_hash -}; - -} // bcrypt -} // http - -namespace system { -template<> -struct is_error_code_enum< - ::boost::http::bcrypt::error> -{ - static bool const value = true; -}; -} // system -} // boost - -namespace std { -template<> -struct is_error_code_enum< - ::boost::http::bcrypt::error> - : std::true_type {}; -} // std - -namespace boost { -namespace http { -namespace bcrypt { - -namespace detail { - -struct BOOST_SYMBOL_VISIBLE - error_cat_type - : system::error_category -{ - BOOST_HTTP_DECL const char* name( - ) const noexcept override; - BOOST_HTTP_DECL std::string message( - int) const override; - BOOST_HTTP_DECL char const* message( - int, char*, std::size_t - ) const noexcept override; - BOOST_SYSTEM_CONSTEXPR error_cat_type() - : error_category(0xbc8f2a4e7c193d56) - { - } -}; - -BOOST_HTTP_DECL extern - error_cat_type error_cat; - -} // detail - -inline -BOOST_SYSTEM_CONSTEXPR -system::error_code -make_error_code( - error ev) noexcept -{ - return system::error_code{ - static_cast::type>(ev), - detail::error_cat}; -} - -} // bcrypt -} // http -} // boost - -#endif diff --git a/include/boost/http/bcrypt/hash.hpp b/include/boost/http/bcrypt/hash.hpp deleted file mode 100644 index 07375e08..00000000 --- a/include/boost/http/bcrypt/hash.hpp +++ /dev/null @@ -1,173 +0,0 @@ -// -// 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 http://www.boost.org/LICENSE_1_0.txt) -// -// Official repository: https://github.com/cppalliance/http -// - -#ifndef BOOST_HTTP_BCRYPT_HASH_HPP -#define BOOST_HTTP_BCRYPT_HASH_HPP - -#include -#include -#include -#include -#include -#include - -namespace boost { -namespace http { -namespace bcrypt { - -/** Generate a random salt. - - Creates a bcrypt salt string suitable for use with - the hash() function. - - @par Preconditions - @code - rounds >= 4 && rounds <= 31 - @endcode - - @par Exception Safety - Strong guarantee. - - @par Complexity - Constant. - - @param rounds Cost factor. Each increment doubles the work. - Default is 10, which takes approximately 100ms on modern hardware. - - @param ver Hash version to use. - - @return A 29-character salt string. - - @throws std::invalid_argument if rounds is out of range. - @throws system_error on RNG failure. -*/ -BOOST_HTTP_DECL -result -gen_salt( - unsigned rounds = 10, - version ver = version::v2b); - -/** Hash a password with auto-generated salt. - - Generates a random salt and hashes the password. - - @par Preconditions - @code - rounds >= 4 && rounds <= 31 - @endcode - - @par Exception Safety - Strong guarantee. - - @par Complexity - O(2^rounds). - - @param password The password to hash. Only the first 72 bytes - are used (bcrypt limitation). - - @param rounds Cost factor. Each increment doubles the work. - - @param ver Hash version to use. - - @return A 60-character hash string. - - @throws std::invalid_argument if rounds is out of range. - @throws system_error on RNG failure. -*/ -BOOST_HTTP_DECL -result -hash( - core::string_view password, - unsigned rounds = 10, - version ver = version::v2b); - -/** Hash a password using a provided salt. - - Uses the given salt to hash the password. The salt should - be a string previously returned by gen_salt() or extracted - from a hash string. - - @par Exception Safety - Strong guarantee. - - @par Complexity - O(2^rounds). - - @param password The password to hash. - - @param salt The salt string (29 characters). - - @param ec Set to bcrypt::error::invalid_salt if the salt - is malformed. - - @return A 60-character hash string, or empty result on error. -*/ -BOOST_HTTP_DECL -result -hash( - core::string_view password, - core::string_view salt, - system::error_code& ec); - -/** Compare a password against a hash. - - Extracts the salt from the hash, re-hashes the password, - and compares the result. - - @par Exception Safety - Strong guarantee. - - @par Complexity - O(2^rounds). - - @param password The plaintext password to check. - - @param hash The hash string to compare against. - - @param ec Set to bcrypt::error::invalid_hash if the hash - is malformed. - - @return true if the password matches the hash, false if - it does not match OR if an error occurred. Always check - ec to distinguish between a mismatch and an error. -*/ -BOOST_HTTP_DECL -bool -compare( - core::string_view password, - core::string_view hash, - system::error_code& ec); - -/** Extract the cost factor from a hash string. - - @par Exception Safety - Strong guarantee. - - @par Complexity - Constant. - - @param hash The hash string to parse. - - @param ec Set to bcrypt::error::invalid_hash if the hash - is malformed. - - @return The cost factor (4-31) on success, or 0 if an - error occurred. -*/ -BOOST_HTTP_DECL -unsigned -get_rounds( - core::string_view hash, - system::error_code& ec); - -} // bcrypt -} // http -} // boost - -#endif diff --git a/include/boost/http/bcrypt/result.hpp b/include/boost/http/bcrypt/result.hpp deleted file mode 100644 index 01188f8a..00000000 --- a/include/boost/http/bcrypt/result.hpp +++ /dev/null @@ -1,123 +0,0 @@ -// -// 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 http://www.boost.org/LICENSE_1_0.txt) -// -// Official repository: https://github.com/cppalliance/http -// - -#ifndef BOOST_HTTP_BCRYPT_RESULT_HPP -#define BOOST_HTTP_BCRYPT_RESULT_HPP - -#include -#include -#include -#include -#include - -namespace boost { -namespace http { -namespace bcrypt { - -/** Fixed-size buffer for bcrypt hash output. - - Stores a bcrypt hash string (max 60 chars) in an - inline buffer with no heap allocation. - - @par Example - @code - bcrypt::result r = bcrypt::hash("password", 10); - core::string_view sv = r; // or r.str() - std::cout << r.c_str(); // null-terminated - @endcode -*/ -class result -{ - char buf_[61]; // 60 chars + null terminator - unsigned char size_; - -public: - /** Default constructor. - - Constructs an empty result. - */ - result() noexcept - : size_(0) - { - buf_[0] = '\0'; - } - - /** Return the hash as a string_view. - */ - core::string_view - str() const noexcept - { - return core::string_view(buf_, size_); - } - - /** Implicit conversion to string_view. - */ - operator core::string_view() const noexcept - { - return str(); - } - - /** Return null-terminated C string. - */ - char const* - c_str() const noexcept - { - return buf_; - } - - /** Return pointer to data. - */ - char const* - data() const noexcept - { - return buf_; - } - - /** Return size in bytes (excludes null terminator). - */ - std::size_t - size() const noexcept - { - return size_; - } - - /** Check if result is empty. - */ - bool - empty() const noexcept - { - return size_ == 0; - } - - /** Check if result contains valid data. - */ - explicit - operator bool() const noexcept - { - return size_ != 0; - } - -private: - friend BOOST_HTTP_DECL result gen_salt(unsigned, version); - friend BOOST_HTTP_DECL result hash(core::string_view, unsigned, version); - friend BOOST_HTTP_DECL result hash(core::string_view, core::string_view, system::error_code&); - - char* buf() noexcept { return buf_; } - void set_size(unsigned char n) noexcept - { - size_ = n; - buf_[n] = '\0'; - } -}; - -} // bcrypt -} // http -} // boost - -#endif diff --git a/include/boost/http/bcrypt/version.hpp b/include/boost/http/bcrypt/version.hpp deleted file mode 100644 index 814af28b..00000000 --- a/include/boost/http/bcrypt/version.hpp +++ /dev/null @@ -1,37 +0,0 @@ -// -// 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 http://www.boost.org/LICENSE_1_0.txt) -// -// Official repository: https://github.com/cppalliance/http -// - -#ifndef BOOST_HTTP_BCRYPT_VERSION_HPP -#define BOOST_HTTP_BCRYPT_VERSION_HPP - -#include - -namespace boost { -namespace http { -namespace bcrypt { - -/** bcrypt hash version prefix. - - The version determines which variant of bcrypt is used. - All versions produce compatible hashes. -*/ -enum class version -{ - /// $2a$ - Original specification - v2a, - - /// $2b$ - Fixed handling of passwords > 255 chars (recommended) - v2b -}; - -} // bcrypt -} // http -} // boost - -#endif diff --git a/include/boost/http/config.hpp b/include/boost/http/config.hpp index 050fce60..d24df7d7 100644 --- a/include/boost/http/config.hpp +++ b/include/boost/http/config.hpp @@ -76,10 +76,6 @@ struct parser_config */ std::size_t max_prepare = std::size_t(-1); - /** Space reserved for type-erased @ref sink objects. - */ - std::size_t max_type_erase = 1024; - /** Constructor. @param server True for server mode (parsing requests, @@ -171,9 +167,6 @@ struct serializer_config */ std::size_t payload_buffer = 8192; - /** Reserved space for type-erasure storage. - */ - std::size_t max_type_erase = 1024; }; //------------------------------------------------ diff --git a/include/boost/http/core/polystore.hpp b/include/boost/http/core/polystore.hpp index 99e1cec1..e5aa6fbb 100644 --- a/include/boost/http/core/polystore.hpp +++ b/include/boost/http/core/polystore.hpp @@ -1,5 +1,5 @@ // -// Copyright (c) 2025 Vinnie Falco (vinnie dot falco at gmail dot com) +// 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 http://www.boost.org/LICENSE_1_0.txt) diff --git a/include/boost/http/datastore.hpp b/include/boost/http/datastore.hpp index a3d98102..793a8657 100644 --- a/include/boost/http/datastore.hpp +++ b/include/boost/http/datastore.hpp @@ -1,5 +1,5 @@ // -// Copyright (c) 2025 Vinnie Falco (vinnie dot falco at gmail dot com) +// 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 http://www.boost.org/LICENSE_1_0.txt) diff --git a/include/boost/http/db/schema.hpp b/include/boost/http/db/schema.hpp new file mode 100644 index 00000000..622f9945 --- /dev/null +++ b/include/boost/http/db/schema.hpp @@ -0,0 +1,443 @@ +// +// Copyright (c) 2026 Vinnie Falco (vinnie.falco@gmail.com) +// +// Distributed under the Boost Software License, Version 1.0. +// https://www.boost.org/LICENSE_1_0.txt +// + +#ifndef BOOST_HTTP_DB_SCHEMA_HPP +#define BOOST_HTTP_DB_SCHEMA_HPP + +#include +#include +#include +#include + +namespace boost { +namespace http { +namespace db { + +/** Bitwise flags describing column properties. + + Accumulated on a @ref field_t descriptor via + builder-style member functions. +*/ +enum field_flags : unsigned +{ + flag_none = 0, + flag_primary_key = 1 << 0, + flag_auto_increment = 1 << 1, + flag_not_null = 1 << 2, + flag_unique = 1 << 3, + flag_indexed = 1 << 4 +}; + +/** Describe a single column mapped to a struct member. + + The member pointer is stored as a data member so + that the natural syntax works without requiring + angle brackets. The entire schema description is + constexpr and available at compile time. + + @par Example + @code + field("id", &user::id).primary_key().auto_increment() + field("email", &user::email).not_null().unique() + @endcode + + @tparam T Member value type (e.g. `std::string`). + @tparam C Containing class type (e.g. `user`). + + @see field, embed_t, has_one_t +*/ +template +struct field_t +{ + using value_type = T; + using class_type = C; + + std::string_view name; + T C::* pointer; + unsigned flags = flag_none; + + /// Mark this column as the primary key. + constexpr field_t& primary_key() { flags |= flag_primary_key; return *this; } + + /// Mark this column as auto-incrementing. + constexpr field_t& auto_increment() { flags |= flag_auto_increment; return *this; } + + /// Mark this column as NOT NULL. + constexpr field_t& not_null() { flags |= flag_not_null; return *this; } + + /// Mark this column as UNIQUE. + constexpr field_t& unique() { flags |= flag_unique; return *this; } + + /// Mark this column as indexed. + constexpr field_t& indexed() { flags |= flag_indexed; return *this; } + + /// Return true if this column is a primary key. + constexpr bool is_primary_key() const { return flags & flag_primary_key; } + + /// Return true if this column is auto-incrementing. + constexpr bool is_auto_increment() const { return flags & flag_auto_increment; } + + /// Return true if this column is NOT NULL. + constexpr bool is_not_null() const { return flags & flag_not_null; } + + /// Return true if this column is UNIQUE. + constexpr bool is_unique() const { return flags & flag_unique; } + + /// Return true if this column is indexed. + constexpr bool is_indexed() const { return flags & flag_indexed; } + + /// Return the member value from an object. + constexpr T const& get(C const& obj) const + { + return obj.*pointer; + } + + /// Set the member value on an object. + constexpr void set(C& obj, T const& value) const + { + obj.*pointer = value; + } + + /// Set the member value on an object by move. + constexpr void set(C& obj, T&& value) const + { + obj.*pointer = static_cast(value); + } +}; + +/** Create a field descriptor for a member pointer. + + Deduces `T` and `C` from the member pointer so + template arguments are never needed at the call site. + + @par Example + @code + field("email", &user::email) + field("id", &user::id).primary_key().auto_increment() + @endcode + + @param name Column name in the database table. + @param ptr Pointer to the mapped data member. + + @return A @ref field_t descriptor for the member. + + @see field_t +*/ +template +constexpr auto field(std::string_view name, T C::* ptr) + -> field_t +{ + return { name, ptr }; +} + +/** Describe a nested struct flattened into the parent table. + + The nested type must provide its own `tag_invoke` + overloads for @ref fields_t. A prefix is prepended + to each nested column name to avoid collisions. + + @par Example + @code + // If user::addr is an address with field "street", + // the resulting column is "addr_street". + embed("addr_", &user::addr) + @endcode + + @tparam T Nested struct type. + @tparam C Containing class type. + + @see embed, field_t +*/ +template +struct embed_t +{ + using value_type = T; + using class_type = C; + + std::string_view prefix; + T C::* pointer; + + /// Return the nested struct from an object. + constexpr T const& get(C const& obj) const + { + return obj.*pointer; + } + + /// Set the nested struct on an object. + constexpr void set(C& obj, T const& value) const + { + obj.*pointer = value; + } + + /// Set the nested struct on an object by move. + constexpr void set(C& obj, T&& value) const + { + obj.*pointer = static_cast(value); + } +}; + +/** Create an embedded field descriptor for a nested struct. + + @param prefix String prepended to nested column names. + @param ptr Pointer to the nested data member. + + @return An @ref embed_t descriptor. + + @see embed_t +*/ +template +constexpr auto embed(std::string_view prefix, T C::* ptr) + -> embed_t +{ + return { prefix, ptr }; +} + +/** Describe a one-to-one relationship to another table. + + The referenced type must provide its own + `tag_invoke` overloads for @ref table_name_t + and @ref fields_t. + + @par Example + @code + has_one("address_id", &user::addr) + @endcode + + @tparam T Referenced struct type. + @tparam C Containing class type. + + @see has_one, has_many_t +*/ +template +struct has_one_t +{ + using value_type = T; + using class_type = C; + + std::string_view foreign_key; + T C::* pointer; + + /// Return the related object from the parent. + constexpr T const& get(C const& obj) const + { + return obj.*pointer; + } + + /// Set the related object on the parent. + constexpr void set(C& obj, T const& value) const + { + obj.*pointer = value; + } + + /// Set the related object on the parent by move. + constexpr void set(C& obj, T&& value) const + { + obj.*pointer = static_cast(value); + } +}; + +/** Create a one-to-one relationship descriptor. + + @param foreign_key Column name of the foreign key. + @param ptr Pointer to the related data member. + + @return A @ref has_one_t descriptor. + + @see has_one_t +*/ +template +constexpr auto has_one(std::string_view foreign_key, T C::* ptr) + -> has_one_t +{ + return { foreign_key, ptr }; +} + +/** Describe a one-to-many relationship. + + The child type has a member acting as the foreign + key pointing back to the parent's primary key. + The child's foreign key member pointer is stored + so the library can build the appropriate JOIN or + subquery. + + @par Example + @code + has_many(&user::posts, &post::user_id) + @endcode + + @tparam Collection Container type in the parent + (e.g. `std::vector< post >`). + @tparam Parent Parent class type. + @tparam FK Foreign key value type in the child. + @tparam Child Child class type. + + @see has_many, has_one_t +*/ +template +struct has_many_t +{ + using collection_type = Collection; + using parent_type = Parent; + using child_type = Child; + using fk_value_type = FK; + + Collection Parent::* pointer; + FK Child::* foreign_key; +}; + +/** Create a one-to-many relationship descriptor. + + @param ptr Pointer to the collection member in the parent. + @param fk Pointer to the foreign key member in the child. + + @return A @ref has_many_t descriptor. + + @see has_many_t +*/ +template < + typename Collection, typename Parent, + typename FK, typename Child> +constexpr auto has_many( + Collection Parent::* ptr, + FK Child::* fk) + -> has_many_t +{ + return { ptr, fk }; +} + +/** Tag type for retrieving the table name of a mapped type. + + Customize via `tag_invoke`: + + @par Example + @code + constexpr auto tag_invoke( + db::table_name_t, user const&) + { + return "users"; + } + @endcode + + @see fields_t, HasMapping +*/ +struct table_name_t +{ + template + constexpr auto operator()(T const& v) const + { + return tag_invoke(*this, v); + } +}; + +/** Tag type for retrieving the field descriptors of a mapped type. + + Customize via `tag_invoke`: + + @par Example + @code + constexpr auto tag_invoke( + db::fields_t, user const&) + { + return std::tuple( + db::field("id", &user::id) + .primary_key().auto_increment(), + db::field("email", &user::email) + .not_null().unique(), + db::field("name", &user::name)); + } + @endcode + + @see table_name_t, HasMapping +*/ +struct fields_t +{ + template + constexpr auto operator()(T const& v) const + { + return tag_invoke(*this, v); + } +}; + +/// Customization point object for @ref table_name_t. +inline constexpr table_name_t table_name{}; + +/// Customization point object for @ref fields_t. +inline constexpr fields_t fields{}; + +/** Concept for types with a complete schema mapping. + + A conforming type must provide `tag_invoke` + overloads for both @ref table_name_t and + @ref fields_t. + + @par Syntactic Requirements + @li `tag_invoke( table_name, v )` is convertible + to `std::string_view`. + @li `tag_invoke( fields, v )` is a valid expression. + + @tparam T The type to check for a schema mapping. + + @see table_name_t, fields_t +*/ +template +concept HasMapping = requires(T const& v) +{ + { tag_invoke(table_name, v) } -> std::convertible_to; + { tag_invoke(fields, v) }; +}; + +namespace detail { + +template +constexpr void for_each_impl( + Tuple const& t, F&& f, std::index_sequence) +{ + (f(std::get(t)), ...); +} + +} // namespace detail + +/** Invoke a callable for each field in a mapped type. + + @param f Callable invoked as `f( field_descriptor )` + for every field in `T`'s mapping. + + @tparam T A type satisfying @ref HasMapping. + + @see field_count +*/ +template +constexpr void for_each_field(F&& f) +{ + constexpr auto fs = tag_invoke(fields, T{}); + detail::for_each_impl( + fs, + static_cast(f), + std::make_index_sequence< + std::tuple_size_v>{}); +} + +/** Return the number of fields in a mapped type. + + @tparam T A type satisfying @ref HasMapping. + + @return The compile-time field count. + + @see for_each_field +*/ +template +constexpr std::size_t field_count() +{ + constexpr auto fs = tag_invoke(fields, T{}); + return std::tuple_size_v; +} + +} // namespace db +} // namespace http +} // namespace boost + +#endif \ No newline at end of file diff --git a/include/boost/http/impl/serializer.hpp b/include/boost/http/impl/serializer.hpp deleted file mode 100644 index 83acf046..00000000 --- a/include/boost/http/impl/serializer.hpp +++ /dev/null @@ -1,138 +0,0 @@ -// -// Copyright (c) 2023 Vinnie Falco (vinnie.falco@gmail.com) -// Copyright (c) 2025 Mohammad Nejati -// -// 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_IMPL_SERIALIZER_HPP -#define BOOST_HTTP_IMPL_SERIALIZER_HPP - -#include - -namespace boost { -namespace http { - -class serializer::cbs_gen -{ -public: - struct stats_t - { - std::size_t size = 0; - std::size_t count = 0; - }; - - // Return the next non-empty buffer or an - // empty buffer if none remain. - virtual - capy::const_buffer - next() = 0; - - // Return the total size and count of - // remaining non-empty buffers. - virtual - stats_t - stats() const = 0; - - // Return true if there are no remaining - // non-empty buffers. - virtual - bool - is_empty() const = 0; -}; - -template -class serializer::cbs_gen_impl - : public cbs_gen -{ - using it_t = decltype(capy::begin( - std::declval())); - - ConstBufferSequence cbs_; - it_t curr_; - -public: - using const_buffer = - capy::const_buffer; - - explicit - cbs_gen_impl(ConstBufferSequence cbs) - : cbs_(std::move(cbs)) - , curr_(capy::begin(cbs_)) - { - } - - const_buffer - next() override - { - while(curr_ != capy::end(cbs_)) - { - // triggers conversion operator - const_buffer buf = *curr_++; - if(buf.size() != 0) - return buf; - } - return {}; - } - - stats_t - stats() const override - { - stats_t r; - for(auto it = curr_; it != capy::end(cbs_); ++it) - { - // triggers conversion operator - const_buffer buf = *it; - if(buf.size() != 0) - { - r.size += buf.size(); - r.count += 1; - } - } - return r; - } - - bool - is_empty() const override - { - for(auto it = curr_; it != capy::end(cbs_); ++it) - { - // triggers conversion operator - const_buffer buf = *it; - if(buf.size() != 0) - return false; - } - return true; - } -}; - -//--------------------------------------------------------- - -template< - class ConstBufferSequence, - class> -void -serializer:: -start( - message_base const& m, - ConstBufferSequence&& cbs) -{ - static_assert( - capy::ConstBufferSequence, - "ConstBufferSequence type requirements not met"); - - start_init(m); - start_buffers( - m, - ws().emplace::type>>( - std::forward(cbs))); -} - -} // http -} // boost - -#endif diff --git a/include/boost/http/json/json_body.hpp b/include/boost/http/json/json_body.hpp new file mode 100644 index 00000000..247e6c8b --- /dev/null +++ b/include/boost/http/json/json_body.hpp @@ -0,0 +1,81 @@ +// +// 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 http://www.boost.org/LICENSE_1_0.txt) +// +// Official repository: https://github.com/cppalliance/http +// + +#ifndef BOOST_HTTP_JSON_JSON_BODY_HPP +#define BOOST_HTTP_JSON_JSON_BODY_HPP + +#include +#include +#include +#include + +namespace boost { +namespace http { + +/** Options for the JSON body middleware. +*/ +struct json_body_options +{ + /// Storage used for parsed values. + json::storage_ptr storage; + + /// Options controlling JSON parsing behavior. + json::parse_options parse_opts; +}; + +/** Route handler middleware that parses a JSON request body. + + This middleware checks that the request is a POST with + Content-Type `application/json`, reads the entire request + body into a @ref json_sink, and stores the parsed + `json::value` in the route's @ref datastore under the + key `json::value`. If the request does not match, the + middleware yields to the next handler. + + @par Example + @code + router r; + r.use( json_body() ); + r.post( "/api/data", + []( route_params& p ) -> route_task + { + auto& jv = p.route_data.get(); + // use jv... + co_return route_done; + } ); + @endcode + + @see json_body_options +*/ +class BOOST_HTTP_DECL json_body +{ + json_body_options options_; + +public: + /** Construct with default options. + */ + explicit json_body(json_body_options options = {}) noexcept; + + /** Handle a request. + + Parses the JSON request body and stores the result + in @ref route_params::route_data. + + @param p The route parameters for the current request. + + @return A @ref route_task that completes with + @ref route_next on success, or an error on parse failure. + */ + route_task operator()(route_params& p) const; +}; + +} // namespace http +} // namespace boost + +#endif diff --git a/include/boost/http/json/json_sink.hpp b/include/boost/http/json/json_sink.hpp index 6d6e49c6..52844b24 100644 --- a/include/boost/http/json/json_sink.hpp +++ b/include/boost/http/json/json_sink.hpp @@ -56,6 +56,33 @@ class json_sink { json::stream_parser parser_; + template + capy::immediate> + write_impl(CB const& buffers, bool eof) + { + system::error_code ec; + std::size_t total = 0; + auto const end = capy::end(buffers); + for(auto it = capy::begin(buffers); it != end; ++it) + { + capy::const_buffer buf(*it); + auto n = parser_.write( + static_cast(buf.data()), + buf.size(), + ec); + total += n; + if(ec.failed()) + return {ec, total}; + } + if(eof) + { + parser_.finish(ec); + if(ec.failed()) + return {ec, total}; + } + return capy::ready(total); + } + public: /** Default constructor. @@ -85,6 +112,22 @@ class json_sink { } + /** Write some data to the JSON parser. + + Writes bytes from the buffer sequence to the stream parser. + + @param buffers Buffer sequence containing JSON data. + + @return An awaitable yielding `(error_code,std::size_t)`. + On success, returns the total bytes written. + */ + template + capy::immediate> + write_some(CB const& buffers) + { + return write_impl(buffers, false); + } + /** Write data to the JSON parser. Writes all bytes from the buffer sequence to the stream parser. @@ -98,7 +141,7 @@ class json_sink capy::immediate> write(CB const& buffers) { - return write(buffers, false); + return write_impl(buffers, false); } /** Write data with optional end-of-stream. @@ -116,29 +159,24 @@ class json_sink capy::immediate> write(CB const& buffers, bool eof) { - system::error_code ec; - std::size_t total = 0; - auto const end = capy::end(buffers); - for(auto it = capy::begin(buffers); it != end; ++it) - { - capy::const_buffer buf(*it); - auto n = parser_.write( - static_cast(buf.data()), - buf.size(), - ec); - total += n; - if(ec.failed()) - return {ec, total}; - } + return write_impl(buffers, eof); + } - if(eof) - { - parser_.finish(ec); - if(ec.failed()) - return {ec, total}; - } + /** Write final data and signal end of JSON data. - return capy::ready(total); + Writes all bytes from the buffer sequence to the stream + parser, then finishes parsing. + + @param buffers Buffer sequence containing JSON data. + + @return An awaitable yielding `(error_code,std::size_t)`. + On success, returns the total bytes written. + */ + template + capy::immediate> + write_eof(CB const& buffers) + { + return write_impl(buffers, true); } /** Signal end of JSON data. diff --git a/include/boost/http/parser.hpp b/include/boost/http/parser.hpp index 5ec55307..dba3a064 100644 --- a/include/boost/http/parser.hpp +++ b/include/boost/http/parser.hpp @@ -607,7 +607,7 @@ pull(std::span dest) } if(pr_->is_complete()) - co_return {{}, {}}; + co_return {capy::error::eof, {}}; if(ec == condition::need_more_input) { diff --git a/include/boost/http/serializer.hpp b/include/boost/http/serializer.hpp index d19d6603..be02163d 100644 --- a/include/boost/http/serializer.hpp +++ b/include/boost/http/serializer.hpp @@ -24,6 +24,7 @@ #include #include +#include #include #include @@ -35,29 +36,41 @@ class message_base; //------------------------------------------------ -/** A serializer for HTTP/1 messages - - This is used to serialize one or more complete - HTTP/1 messages. Each message consists of a - required header followed by an optional body. - - Objects of this type operate using an "input area" and an - "output area". Callers provide data to the input area - using one of the @ref start or @ref start_stream member - functions. After input is provided, serialized data - becomes available in the serializer's output area in the - form of a constant buffer sequence. - - Callers alternate between filling the input area and - consuming the output area until all the input has been - provided and all the output data has been consumed, or - an error occurs. - - After calling @ref start, the caller must ensure that the - contents of the associated message are not changed or - destroyed until @ref is_done returns true, @ref reset is - called, or the serializer is destroyed, otherwise the - behavior is undefined. +/** A serializer for HTTP/1 messages. + + Transforms one or more HTTP/1 messages into bytes for + transmission. Each message consists of a required header + followed by an optional body. + + Use @ref set_message to associate a message, then choose + a body mode: + + @li @ref start — empty body (header only) + @li @ref start_writes — body via internal buffer + (BufferSink path) + @li @ref start_buffers — body via caller-owned buffers + (WriteSink path) + + Alternatively, obtain a @ref sink via @ref sink_for and + let it start the serializer lazily on first use. + + The caller must ensure that the associated message is not + changed or destroyed until @ref is_done returns true, + @ref reset is called, or the serializer is destroyed. + + @par Example + @code + http::serializer sr(cfg); + http::response res; + res.set_payload_size(5); + sr.set_message(res); + + auto sink = sr.sink_for(socket); + co_await sink.write_eof( + capy::make_buffer(std::string_view("hello"))); + @endcode + + @see @ref sink, @ref set_message. */ class serializer { @@ -112,29 +125,6 @@ class serializer serializer( std::shared_ptr cfg); - /** Constructor with associated message. - - Constructs a serializer with the provided configuration - and associates a message for the serializer's lifetime. - The message must remain valid until the serializer is - destroyed. - - @par Postconditions - @code - this->is_done() == true - @endcode - - @param cfg Shared pointer to serializer configuration. - - @param m The message to associate. - - @see @ref make_serializer_config, @ref serializer_config. - */ - BOOST_HTTP_DECL - serializer( - std::shared_ptr cfg, - message_base const& m); - /** Constructor. The states of `other` are transferred @@ -201,57 +191,36 @@ class serializer void set_message(message_base const& m) noexcept; - /** Start serializing a message with an empty body + /** Start serializing the associated message with an empty body. - This function prepares the serializer to create a message which - has an empty body. - Ownership of the specified message is not transferred; the caller is - responsible for ensuring the lifetime of the object extends until the - serializer is done. + The message must be set beforehand using @ref set_message. + Use the prepare/consume loop to pull output bytes. @par Preconditions - @code - this->is_done() == true - @endcode - - @par Postconditions - @code - this->is_done() == false - @endcode + A message was associated via @ref set_message. @par Exception Safety Strong guarantee. - Exceptions thrown if there is insufficient internal buffer space - to start the operation. - @throw std::logic_error `this->is_done() == true`. + @throw std::logic_error if no message is associated or + `this->is_done() == false`. @throw std::length_error if there is insufficient internal buffer space to start the operation. - @param m The request or response headers to serialize. - - @see - @ref message_base. + @see @ref set_message, @ref prepare, @ref consume. */ void BOOST_HTTP_DECL - start(message_base const& m); + start(); - /** Start serializing the associated message with an empty body. + /** Start streaming the associated message. - Uses the message associated at construction time. + Low-level entry point equivalent to @ref start_writes. + Prefer using a @ref sink which starts lazily. @par Preconditions - A message was associated at construction. - @code - this->is_done() == true - @endcode - - @par Postconditions - @code - this->is_done() == false - @endcode + A message was associated via @ref set_message. @par Exception Safety Strong guarantee. @@ -261,186 +230,92 @@ class serializer @throw std::length_error if there is insufficient internal buffer space to start the operation. + + @see @ref start_writes, @ref sink. */ - void BOOST_HTTP_DECL - start(); - - /** Start serializing a message with a buffer sequence body - - Initializes the serializer with the HTTP start-line and headers from `m`, - and the provided `buffers` for reading the message body from. + void + start_stream(); - Changing the contents of the message after calling this function and - before @ref is_done returns `true` results in undefined behavior. + /** Start the serializer in write mode. - At least one copy of the specified buffer sequence is maintained until - the serializer is done, gets reset, or ios destroyed, after which all - of its copies are destroyed. Ownership of the underlying memory is not - transferred; the caller must ensure the memory remains valid until the - serializer’s copies are destroyed. + Prepares the serializer for write-mode streaming + using the message previously set via @ref set_message. + In this mode, the workspace is split into an input + buffer and an output buffer. Use @ref stream_prepare, + @ref stream_commit, and @ref stream_close to write + body data, or use the sink's BufferSink interface. @par Preconditions + A message was associated via @ref set_message. @code this->is_done() == true @endcode - @par Postconditions - @code - this->is_done() == false - @endcode - - @par Constraints - @code - capy::ConstBufferSequence - @endcode - @par Exception Safety Strong guarantee. - Exceptions thrown if there is insufficient internal buffer space - to start the operation. - @throw std::logic_error `this->is_done() == true`. + @throw std::logic_error if no message is associated. - @throw std::length_error If there is insufficient internal buffer + @throw std::length_error if there is insufficient internal buffer space to start the operation. - @param m The message to read the HTTP start-line and headers from. - - @param buffers A buffer sequence containing the message body. - - containing the message body data. While - the buffers object is copied, ownership of - the underlying memory remains with the - caller, who must ensure it stays valid - until @ref is_done returns `true`. - - @see - @ref message_base. + @see @ref set_message, @ref sink. */ - template< - class ConstBufferSequence, - class = typename std::enable_if< - capy::ConstBufferSequence>::type - > - void - start( - message_base const& m, - ConstBufferSequence&& buffers); - - /** Prepare the serializer for streaming body data. - - Initializes the serializer with the HTTP - start-line and headers from `m` for streaming - mode. After calling this function, use - @ref stream_prepare, @ref stream_commit, and - @ref stream_close to write body data. - - Changing the contents of the message - after calling this function and before - @ref is_done returns `true` results in - undefined behavior. - - @par Example - @code - auto sink = sr.sink_for(socket); - sr.start_stream(response); - - capy::mutable_buffer arr[16]; - auto bufs = sink.prepare(arr); - std::memcpy(bufs[0].data(), "Hello", 5); - co_await sink.commit(5, true); - @endcode - - @par Preconditions - @code - this->is_done() == true - @endcode - - @par Postconditions - @code - this->is_done() == false - @endcode - - @par Exception Safety - Strong guarantee. - Exceptions thrown if there is insufficient - internal buffer space to start the - operation. - - @throw std::length_error if there is - insufficient internal buffer space to - start the operation. - - @param m The message to read the HTTP - start-line and headers from. - - @see - @ref stream_prepare, - @ref stream_commit, - @ref stream_close, - @ref message_base. - */ BOOST_HTTP_DECL void - start_stream( - message_base const& m); + start_writes(); - /** Start streaming the associated message. + /** Start the serializer in buffer mode. - Uses the message associated at construction time. + Prepares the serializer for buffer-mode streaming + using the message previously set via @ref set_message. + In this mode, the entire workspace is used for output + buffering. The caller provides body data through the + sink's WriteSink methods (write, write_eof), passing + their own buffers directly. @par Preconditions - A message was associated at construction. + A message was associated via @ref set_message. @code this->is_done() == true @endcode - @par Postconditions - @code - this->is_done() == false - @endcode - @par Exception Safety Strong guarantee. - @throw std::logic_error if no message is associated or - `this->is_done() == false`. + @throw std::logic_error if no message is associated. @throw std::length_error if there is insufficient internal buffer space to start the operation. - @see - @ref stream_prepare, - @ref stream_commit, - @ref stream_close. + @see @ref set_message, @ref sink. */ BOOST_HTTP_DECL void - start_stream(); + start_buffers(); - /** Get a sink wrapper for writing body data. + /** Create a sink for writing body data. - Returns a @ref sink object that can be used to write body - data to the provided stream. This function does not call - @ref start_stream. The caller must call @ref start_stream - before using the sink. + Returns a lightweight @ref sink handle that writes + serialized body data to the provided stream. The sink + starts the serializer lazily on first use, so neither + @ref start_writes nor @ref start_buffers need to be + called beforehand. - This allows the sink to be obtained early (e.g., at - construction time) and stored, with streaming started - later when the message is ready. + The sink can be created once and reused across multiple + messages. The serializer must outlive the sink. @par Example @code - http::serializer sr(cfg, res); + http::serializer sr(cfg); auto sink = sr.sink_for(socket); - // ... later ... - sr.start_stream(); // Configure for streaming - capy::mutable_buffer arr[16]; - auto bufs = sink.prepare(arr); - std::memcpy(bufs[0].data(), "Hello", 5); - co_await sink.commit(5, true); + http::response res; + res.set_payload_size(5); + sr.set_message(res); + co_await sink.write_eof( + capy::make_buffer(std::string_view("hello"))); @endcode @tparam Stream The output stream type satisfying @@ -450,7 +325,7 @@ class serializer @return A @ref sink object for writing body data. - @see @ref sink, @ref start_stream. + @see @ref sink, @ref set_message. */ template sink @@ -478,16 +353,6 @@ class serializer to indicate that additional input is required to produce output. - If a @ref source object is in use and a - call to @ref source::read returns an - error, the serializer enters a faulted - state and propagates the error to the - caller. This faulted state can only be - cleared by calling @ref reset. This - ensures the caller is explicitly aware - that the previous message was truncated - and that the stream must be terminated. - @par Preconditions @code this->is_done() == false @@ -496,7 +361,6 @@ class serializer @par Exception Safety Strong guarantee. - Calls to @ref source::read may throw if in use. @throw std::logic_error `this->is_done() == true`. @@ -560,6 +424,12 @@ class serializer bool is_done() const noexcept; + /** Return true if serialization has not yet started. + */ + BOOST_HTTP_DECL + bool + is_start() const noexcept; + /** Return the available capacity for streaming. Returns the number of bytes that can be written @@ -653,25 +523,11 @@ class serializer private: class impl; - class cbs_gen; - template - class cbs_gen_impl; BOOST_HTTP_DECL detail::workspace& ws(); - BOOST_HTTP_DECL - void - start_init( - message_base const&); - - BOOST_HTTP_DECL - void - start_buffers( - message_base const&, - cbs_gen&); - impl* impl_ = nullptr; }; @@ -679,19 +535,23 @@ class serializer /** A sink adapter for writing HTTP message bodies. - This class wraps an underlying @ref capy::WriteStream and a - @ref serializer to provide a @ref capy::BufferSink interface - for writing message body data. The caller writes directly into - the serializer's internal buffer (zero-copy); the serializer - automatically handles: + Wraps a @ref serializer and a @ref capy::WriteStream to + provide two interfaces for body writing: + + @li **BufferSink** (@ref prepare / @ref commit / + @ref commit_eof) — write directly into the serializer's + internal buffer (zero-copy). Triggers @ref start_writes + lazily. + @li **WriteSink** (@ref write / @ref write_eof) — pass + caller-owned buffers; the sink copies data through the + serializer. Triggers @ref start_buffers lazily. - @li Chunked transfer-encoding (chunk framing added automatically) - @li Content-Encoding compression (gzip, deflate, brotli if configured) - @li Content-Length validation (if specified in headers) + Both interfaces handle chunked framing, compression, and + Content-Length validation automatically. - For @ref capy::WriteSink semantics (caller owns buffers), wrap - this sink with @ref capy::any_buffer_sink which provides both - interfaces. + The sink is a lightweight handle that can be created once + and reused across multiple messages. The serializer and + stream must outlive the sink. @tparam Stream The underlying stream type satisfying @ref capy::WriteStream. @@ -702,25 +562,24 @@ class serializer @par Example @code - capy::task + capy::task<> send_response(capy::WriteStream auto& socket) { - http::response res; - res.set_chunked(true); - http::serializer sr(cfg, res); - + http::serializer sr(cfg); auto sink = sr.sink_for(socket); - sr.start_stream(); - // Zero-copy write using BufferSink interface - capy::mutable_buffer arr[16]; - auto bufs = sink.prepare(arr); - std::memcpy(bufs[0].data(), "Hello", 5); - co_await sink.commit(5, true); + http::response res; + res.set_payload_size(5); + sr.set_message(res); + + // WriteSink: pass your own buffer + co_await sink.write_eof( + capy::make_buffer(std::string_view("hello"))); } @endcode - @see capy::BufferSink, capy::any_buffer_sink, serializer + @see @ref capy::BufferSink, @ref capy::any_buffer_sink, + @ref serializer. */ template class serializer::sink @@ -752,7 +611,8 @@ class serializer::sink Fills the provided span with mutable buffer descriptors pointing to the serializer's internal storage. This - operation is synchronous. + operation is synchronous. Lazily starts the serializer + in write mode if not already started. @param dest Span of mutable_buffer to fill. @@ -761,6 +621,8 @@ class serializer::sink std::span prepare(std::span dest) { + if(sr_->is_start()) + sr_->start_writes(); auto bufs = sr_->stream_prepare(); std::size_t count = 0; for(auto const& b : bufs) @@ -786,36 +648,65 @@ class serializer::sink commit(std::size_t n) -> capy::io_task<> { - return commit(n, false); + if(sr_->is_start()) + sr_->start_writes(); + sr_->stream_commit(n); + + while(!sr_->is_done()) + { + auto cbs = sr_->prepare(); + if(cbs.has_error()) + { + if(cbs.error() == error::need_data) + break; + co_return {cbs.error()}; + } + + if(capy::buffer_empty(*cbs)) + { + // advance state machine + sr_->consume(0); + continue; + } + + auto [ec, written] = co_await stream_->write_some(*cbs); + sr_->consume(written); + + if(ec) + co_return {ec}; + } + + co_return {}; } - /** Commit bytes written with optional end-of-stream. + /** Commit final bytes and signal end-of-stream. Commits `n` bytes written to the buffers returned by the - most recent call to @ref prepare. If `eof` is true, also - signals end-of-stream. + most recent call to @ref prepare and closes the body stream, + flushing any remaining serializer output to the underlying + stream. For chunked encoding, this writes the final + zero-length chunk. @param n The number of bytes to commit. - @param eof If true, signals end-of-stream after committing. @return An awaitable yielding `(error_code)`. + + @post The serializer's `is_done()` returns `true` on success. */ auto - commit(std::size_t n, bool eof) + commit_eof(std::size_t n) -> capy::io_task<> { + if(sr_->is_start()) + sr_->start_writes(); sr_->stream_commit(n); - - if(eof) - sr_->stream_close(); + sr_->stream_close(); while(!sr_->is_done()) { auto cbs = sr_->prepare(); if(cbs.has_error()) { - if(cbs.error() == error::need_data && !eof) - break; if(cbs.error() == error::need_data) continue; co_return {cbs.error()}; @@ -823,6 +714,7 @@ class serializer::sink if(capy::buffer_empty(*cbs)) { + // advance state machine sr_->consume(0); continue; } @@ -837,20 +729,174 @@ class serializer::sink co_return {}; } - /** Signal end-of-stream. + /** Write body data from caller-owned buffers. + + Lazily starts the serializer in buffer mode if not + already started. Writes all data from the provided + buffers through the serializer to the underlying stream. + + @param buffers The buffer sequence containing body data. + + @return An awaitable yielding `(error_code, std::size_t)`. + The size_t is the total number of body bytes written. + */ + template + auto + write(ConstBufferSequence const& buffers) + -> capy::io_task + { + if(sr_->is_start()) + sr_->start_buffers(); + + // Drain header first + while(!sr_->is_done()) + { + auto cbs = sr_->prepare(); + if(cbs.has_error()) + { + if(cbs.error() == error::need_data) + break; + co_return {cbs.error(), 0}; + } + + if(capy::buffer_empty(*cbs)) + { + // advance state machine + sr_->consume(0); + continue; + } + + auto [ec, written] = co_await stream_->write_some(*cbs); + sr_->consume(written); + + if(ec) + co_return {ec, 0}; + } + + // Write body data through stream_prepare/commit + std::size_t total = 0; + for(auto it = capy::begin(buffers); + it != capy::end(buffers); ++it) + { + capy::const_buffer src = *it; + while(src.size() != 0) + { + auto mbp = sr_->stream_prepare(); + std::size_t copied = 0; + for(auto const& mb : mbp) + { + auto chunk = (std::min)( + mb.size(), src.size()); + if(chunk == 0) + break; + std::memcpy(mb.data(), + src.data(), chunk); + src += chunk; + copied += chunk; + } + sr_->stream_commit(copied); + total += copied; + + // Drain output + while(!sr_->is_done()) + { + auto cbs = sr_->prepare(); + if(cbs.has_error()) + { + if(cbs.error() == error::need_data) + break; + co_return {cbs.error(), total}; + } + + if(capy::buffer_empty(*cbs)) + { + // advance state machine + sr_->consume(0); + continue; + } + + auto [ec, written] = + co_await stream_->write_some(*cbs); + sr_->consume(written); + + if(ec) + co_return {ec, total}; + } + } + } + + co_return {{}, total}; + } + + /** Write final body data and signal end-of-stream. + + Lazily starts the serializer in buffer mode if not + already started. Writes all data from the provided + buffers and then closes the body stream, flushing + any remaining output to the underlying stream. - Closes the body stream and flushes any remaining serializer - output to the underlying stream. For chunked encoding, this - writes the final zero-length chunk. + @param buffers The buffer sequence containing final body data. + + @return An awaitable yielding `(error_code, std::size_t)`. + The size_t is the total number of body bytes written. + + @post The serializer's `is_done()` returns `true` on success. + */ + template + auto + write_eof(ConstBufferSequence const& buffers) + -> capy::io_task + { + auto [ec, n] = co_await write(buffers); + if(ec) + co_return {ec, n}; + + sr_->stream_close(); + + while(!sr_->is_done()) + { + auto cbs = sr_->prepare(); + if(cbs.has_error()) + { + if(cbs.error() == error::need_data) + continue; + co_return {cbs.error(), n}; + } + + if(capy::buffer_empty(*cbs)) + { + // advance state machine + sr_->consume(0); + continue; + } + + auto [ec2, written] = co_await stream_->write_some(*cbs); + sr_->consume(written); + + if(ec2) + co_return {ec2, n}; + } + + co_return {{}, n}; + } + + /** Signal end-of-stream with no additional data. + + Lazily starts the serializer in buffer mode if not + already started. Closes the body stream and flushes + any remaining output to the underlying stream. @return An awaitable yielding `(error_code)`. @post The serializer's `is_done()` returns `true` on success. */ auto - commit_eof() + write_eof() -> capy::io_task<> { + if(sr_->is_start()) + sr_->start_buffers(); + sr_->stream_close(); while(!sr_->is_done()) @@ -865,6 +911,7 @@ class serializer::sink if(capy::buffer_empty(*cbs)) { + // advance state machine sr_->consume(0); continue; } @@ -892,6 +939,4 @@ serializer::sink_for(Stream& ws) noexcept } // http } // boost -#include - #endif diff --git a/include/boost/http/server/accepts.hpp b/include/boost/http/server/accepts.hpp new file mode 100644 index 00000000..d1fcf5ed --- /dev/null +++ b/include/boost/http/server/accepts.hpp @@ -0,0 +1,165 @@ +// +// 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 http://www.boost.org/LICENSE_1_0.txt) +// +// Official repository: https://github.com/cppalliance/http +// + +#ifndef BOOST_HTTP_SERVER_ACCEPTS_HPP +#define BOOST_HTTP_SERVER_ACCEPTS_HPP + +#include +#include +#include +#include +#include + +namespace boost { +namespace http { + +/** Content negotiation based on request headers. + + This class examines the Accept, Accept-Encoding, + Accept-Charset, and Accept-Language request headers + to determine which content types, encodings, charsets, + and languages the client prefers. + + @par Example + @code + accepts ac( req ); + + // Get the preferred type from offered list + auto t = ac.type({ "html", "json", "text/plain" }); + + // Get all accepted media types sorted by preference + auto all = ac.types(); + @endcode + + @see mime_types +*/ +class BOOST_HTTP_DECL accepts +{ + fields_base const& fields_; + +public: + /** Construct from message fields. + + @param fields The message fields to negotiate against. + */ + explicit + accepts( fields_base const& fields ) noexcept; + + /** Return the preferred media type from the offered list. + + Each value may be a MIME type such as "application/json", + or a file extension such as "json". The best match is + returned based on the request's Accept header. + + If the Accept header is absent, the first offered type + is returned. Returns an empty string view if no offered + type is acceptable. + + @par Example + @code + accepts ac( req ); + + // Accept: application/json + ac.type({ "json" }); // => "json" + ac.type({ "html" }); // => "" (empty) + + // Accept: application/json, text/html;q=0.5 + ac.type({ "html", "json" }); // => "json" + @endcode + + @param offered The types to negotiate from (MIME types + or file extensions). + + @return The best matching type from the offered + list, or an empty string view if none match. + */ + std::string_view + type( + std::initializer_list< + std::string_view> offered ) const; + + /** Return all accepted media types sorted by preference. + + Returns the media types from the Accept header, + ordered by quality value. + + @return A vector of accepted media types. + */ + std::vector + types() const; + + /** Return the preferred encoding from the offered list. + + @par Example + @code + // Accept-Encoding: gzip, deflate + accepts ac( req ); + ac.encoding({ "gzip", "deflate" }); // => "gzip" + @endcode + + @param offered The encodings to negotiate from. + + @return The best matching encoding, or an empty + string view if none are acceptable. + */ + std::string_view + encoding( + std::initializer_list< + std::string_view> offered ) const; + + /** Return all accepted encodings sorted by preference. + + @return A vector of accepted encodings. + */ + std::vector + encodings() const; + + /** Return the preferred charset from the offered list. + + @param offered The charsets to negotiate from. + + @return The best matching charset, or an empty + string view if none are acceptable. + */ + std::string_view + charset( + std::initializer_list< + std::string_view> offered ) const; + + /** Return all accepted charsets sorted by preference. + + @return A vector of accepted charsets. + */ + std::vector + charsets() const; + + /** Return the preferred language from the offered list. + + @param offered The languages to negotiate from. + + @return The best matching language, or an empty + string view if none are acceptable. + */ + std::string_view + language( + std::initializer_list< + std::string_view> offered ) const; + + /** Return all accepted languages sorted by preference. + + @return A vector of accepted languages. + */ + std::vector + languages() const; +}; + +} // http +} // boost + +#endif diff --git a/include/boost/http/server/basic_router.hpp b/include/boost/http/server/basic_router.hpp deleted file mode 100644 index 50ec1fce..00000000 --- a/include/boost/http/server/basic_router.hpp +++ /dev/null @@ -1,939 +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_BASIC_ROUTER_HPP -#define BOOST_HTTP_SERVER_BASIC_ROUTER_HPP - -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include - -namespace boost { -namespace http { - -template class basic_router; - -/** Configuration options for HTTP routers. -*/ -struct router_options -{ - /** Constructor. - - Routers constructed with default options inherit the values of - @ref case_sensitive and @ref strict from the parent router. - If there is no parent, both default to `false`. - The value of @ref merge_params always defaults to `false` - and is never inherited. - */ - router_options() = default; - - /** Set whether to merge parameters from parent routers. - - This setting controls whether route parameters defined on parent - routers are made available in nested routers. It is not inherited - and always defaults to `false`. - - @par Example - @code - basic_router r( router_options() - .merge_params( true ) - .case_sensitive( true ) - .strict( false ) ); - @endcode - - @param value `true` to merge parameters from parent routers. - - @return A reference to `*this` for chaining. - */ - router_options& - merge_params( - bool value) noexcept - { - v_ = (v_ & ~1) | (value ? 1 : 0); - return *this; - } - - /** Set whether pattern matching is case-sensitive. - - When this option is not set explicitly, the value is inherited - from the parent router or defaults to `false` if there is no parent. - - @par Example - @code - basic_router r( router_options() - .case_sensitive( true ) - .strict( true ) ); - @endcode - - @param value `true` to perform case-sensitive path matching. - - @return A reference to `*this` for chaining. - */ - router_options& - case_sensitive( - bool value) noexcept - { - if(value) - v_ = (v_ & ~6) | 2; - else - v_ = (v_ & ~6) | 4; - return *this; - } - - /** Set whether pattern matching is strict. - - When this option is not set explicitly, the value is inherited - from the parent router or defaults to `false` if there is no parent. - Strict matching treats a trailing slash as significant: - the pattern `"/api"` matches `"/api"` but not `"/api/"`. - When strict matching is disabled, these paths are treated - as equivalent. - - @par Example - @code - basic_router r( router_options() - .strict( true ) - .case_sensitive( false ) ); - @endcode - - @param value `true` to enable strict path matching. - - @return A reference to `*this` for chaining. - */ - router_options& - strict( - bool value) noexcept - { - if(value) - v_ = (v_ & ~24) | 8; - else - v_ = (v_ & ~24) | 16; - return *this; - } - -private: - template friend class basic_router; - unsigned int v_ = 0; -}; - -//----------------------------------------------- - -/** A container for HTTP route handlers. - - `basic_router` objects store and dispatch route handlers based on the - HTTP method and path of an incoming request. Routes are added with a - path pattern, method, and an associated handler, and the router is then - used to dispatch the appropriate handler. - - Patterns used to create route definitions have percent-decoding applied - when handlers are mounted. A literal "%2F" in the pattern string is - indistinguishable from a literal '/'. For example, "/x%2Fz" is the - same as "/x/z" when used as a pattern. - - @par Example - @code - using router_type = basic_router; - router_type router; - router.get( "/hello", - []( route_params& p ) - { - p.res.status( status::ok ); - p.res.set_body( "Hello, world!" ); - return route_done; - } ); - @endcode - - Router objects are lightweight, shared references to their contents. - Copies of a router obtained through construction, conversion, or - 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 - equivalent signature: - @code - route_result handler( Params& p ) - @endcode - - The return value is a @ref route_result used to indicate the desired - action through @ref route enum values, or to indicate that a failure - occurred. Failures are represented by error codes for which - `system::error_code::failed()` returns `true`. - - When a failing error code is produced and remains unhandled, the - router enters error-dispatching mode. In this mode, only error - handlers are invoked. Error handlers are registered globally or - for specific paths and execute in the order of registration whenever - a failing error code is present in the response. - - Error handlers have this equivalent signature: - @code - route_result error_handler( Params& p, system::error_code ec ) - @endcode - - Each error handler may return any failing @ref system::error_code, - which is equivalent to calling: - @code - p.next( ec ); // with ec == true - @endcode - - Returning @ref route_next indicates that control should proceed to - the next matching error handler. Returning a different failing code - replaces the current error and continues dispatch in error mode using - that new code. Error handlers are invoked until one returns a result - other than @ref route_next. - - Exception handlers have this equivalent signature: - @code - route_result exception_handler( Params& p, E ex ) - @endcode - - Where `E` is the type of exception caught. Handlers installed for an - exception of type `E` will also be called when the exception type is - a derived class of `E`. Exception handlers are invoked in the order - of registration whenever an exception is present in the request. - - The prefix match is not strict: middleware attached to `"/api"` - will also match `"/api/users"` and `"/api/data"`. When registered - before route handlers for the same prefix, middleware runs before - those routes. This is analogous to `app.use( path, ... )` in - Express.js. - - @par Thread Safety - - Member functions marked `const` such as @ref dispatch and @ref resume - may be called concurrently on routers that refer to the same data. - Modification of routers through calls to non-`const` member functions - is not thread-safe and must not be performed concurrently with any - other member function. - - @par Nesting Depth - - Routers may be nested to a maximum depth of `max_path_depth` (16 levels). - Exceeding this limit throws `std::length_error` when the nested router - is added via @ref use. This limit ensures that @ref flat_router dispatch - never overflows its fixed-size tracking arrays. - - @par Constraints - - `Params` must be publicly derived from @ref route_params_base. - - @tparam Params The type of the parameters object passed to handlers. -*/ -template -class basic_router : public detail::router_base -{ - static_assert(std::derived_from); - - template - static inline constexpr char handler_kind = - []() -> char - { - if constexpr (detail::returns_route_task) - { - return is_plain; - } - else if constexpr (detail::returns_route_task< - T, P&, system::error_code>) - { - return is_error; - } - else if constexpr( - std::is_base_of_v && - std::is_convertible_v && - std::is_constructible_v>) - { - return is_router; - } - else if constexpr (detail::returns_route_task< - T, P&, std::exception_ptr>) - { - return is_exception; - } - else - { - return is_invalid; - } - }(); - - template - static inline constexpr bool handler_crvals = - ((!std::is_lvalue_reference_v || - std::is_const_v> || - std::is_function_v>) && ...); - - template - static inline constexpr bool handler_check = - (((handler_kind & Mask) != 0) && ...); - - template - struct handler_impl : handler - { - std::decay_t h; - - template - explicit handler_impl(H_ h_) - : handler(handler_kind) - , h(std::forward(h_)) - { - } - - auto invoke(route_params_base& rp) const -> - route_task override - { - if constexpr (detail::returns_route_task) - { - return h(static_cast(rp)); - } - else if constexpr (detail::returns_route_task< - H, P&, system::error_code>) - { - return h(static_cast(rp), rp.ec_); - } - else if constexpr (detail::returns_route_task< - H, P&, std::exception_ptr>) - { - return h(static_cast(rp), rp.ep_); - } - else - { - // impossible with flat router - std::terminate(); - } - } - - detail::router_base* - get_router() noexcept override - { - if constexpr (std::is_base_of_v< - detail::router_base, std::decay_t>) - return &h; - else - return nullptr; - } - }; - - template - static handler_ptr make_handler(H&& h) - { - 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 - { - handler_ptr v[N]; - - template - explicit handlers_impl(HN&&... hn) - { - p = v; - n = sizeof...(HN); - assign<0>(std::forward(hn)...); - } - - private: - template - void assign(H1&& h1, HN&&... hn) - { - v[I] = make_handler(std::forward

(h1)); - assign(std::forward(hn)...); - } - - template - void assign(int = 0) - { - } - }; - - template - static auto make_handlers(HN&&... hn) - { - return handlers_impl( - std::forward(hn)...); - } - -public: - /** The type of params used in handlers. - */ - using params_type = P; - - /** A fluent interface for defining handlers on a specific route. - - This type represents a single route within the router and - provides a chainable API for registering handlers associated - with particular HTTP methods or for all methods collectively. - - Typical usage registers one or more handlers for a route: - @code - router.route( "/users/:id" ) - .get( show_user ) - .put( update_user ) - .all( log_access ); - @endcode - - Each call appends handlers in registration order. - */ - class fluent_route; - - basic_router(basic_router const&) = delete; - basic_router& operator=(basic_router const&) = delete; - - /** Constructor. - - Creates an empty router with the specified configuration. - Routers constructed with default options inherit the values - of @ref router_options::case_sensitive and - @ref router_options::strict from the parent router, or default - to `false` if there is no parent. The value of - @ref router_options::merge_params defaults to `false` and - is never inherited. - - @param options The configuration options to use. - */ - explicit - basic_router( - router_options options = {}) - : router_base(options.v_) - { - } - - /** Construct a router from another router with compatible types. - - This constructs a router that shares the same underlying routing - state as another router whose params type is a base class of `Params`. - - The resulting router participates in shared ownership of the - implementation; copying the router does not duplicate routes or - handlers, and changes visible through one router are visible - through all routers that share the same underlying state. - - @par Constraints - - `Params` must be derived from `OtherParams`. - - @param other The router to construct from. - - @tparam OtherParams The params type of the source router. - */ - template - requires std::derived_from - basic_router( - basic_router&& other) noexcept - : router_base(std::move(other)) - { - } - - /** Add middleware handlers for a path prefix. - - Each handler registered with this function participates in the - routing and error-dispatch process for requests whose path begins - with the specified prefix, as described in the @ref basic_router - class documentation. Handlers execute in the order they are added - and may return @ref route_next to transfer control to the - subsequent handler in the chain. - - @par Example - @code - router.use( "/api", - []( route_params& p ) - { - if( ! authenticate( p ) ) - { - p.res.status( 401 ); - p.res.set_body( "Unauthorized" ); - return route_done; - } - return route_next; - }, - []( route_params& p ) - { - p.res.set_header( "X-Powered-By", "MyServer" ); - return route_next; - } ); - @endcode - - @par Preconditions - - @p pattern must be a valid path prefix; it may be empty to - indicate the root scope. - - @param pattern The pattern to match. - - @param h1 The first handler to add. - - @param hn Additional handlers to add, invoked after @p h1 in - registration order. - */ - template - void use( - std::string_view pattern, - H1&& h1, HN&&... hn) - { - static_assert(handler_crvals, - "pass handlers by value or std::move()"); - static_assert(! handler_check<8, H1, HN...>, - "cannot use exception handlers here"); - static_assert(handler_check<7, H1, HN...>, - "invalid handler signature"); - add_impl(pattern, make_handlers( - std::forward

(h1), std::forward(hn)...)); - } - - /** Add global middleware handlers. - - Each handler registered with this function participates in the - routing and error-dispatch process as described in the - @ref basic_router class documentation. Handlers execute in the - order they are added and may return @ref route_next to transfer - control to the next handler in the chain. - - This is equivalent to writing: - @code - use( "/", h1, hn... ); - @endcode - - @par Example - @code - router.use( - []( Params& p ) - { - p.res.erase( "X-Powered-By" ); - return route_next; - } ); - @endcode - - @par Constraints - - @p h1 must not be convertible to @ref std::string_view. - - @param h1 The first handler to add. - - @param hn Additional handlers to add, invoked after @p h1 in - registration order. - */ - template - void use(H1&& h1, HN&&... hn) - requires (!std::convertible_to) - { - static_assert(handler_crvals, - "pass handlers by value or std::move()"); - static_assert(! handler_check<8, H1, HN...>, - "cannot use exception handlers here"); - static_assert(handler_check<7, H1, HN...>, - "invalid handler signature"); - use(std::string_view(), - std::forward

(h1), std::forward(hn)...); - } - - /** Add exception handlers for a route pattern. - - Registers one or more exception handlers that will be invoked - when an exception is thrown during request processing for routes - matching the specified pattern. - - Handlers are invoked in the order provided until one handles - the exception. - - @par Example - @code - app.except( "/api*", - []( route_params& p, std::exception const& ex ) - { - p.res.set_status( 500 ); - return route_done; - } ); - @endcode - - @param pattern The route pattern to match, or empty to match - all routes. - - @param h1 The first exception handler. - - @param hn Additional exception handlers. - */ - template - void except( - std::string_view pattern, - H1&& h1, HN&&... hn) - { - static_assert(handler_crvals, - "pass handlers by value or std::move()"); - static_assert(handler_check<8, H1, HN...>, - "only exception handlers are allowed here"); - add_impl(pattern, make_handlers( - std::forward

(h1), std::forward(hn)...)); - } - - /** Add global exception handlers. - - Registers one or more exception handlers that will be invoked - when an exception is thrown during request processing for any - route. - - Equivalent to calling `except( "", h1, hn... )`. - - @par Example - @code - app.except( - []( route_params& p, std::exception const& ex ) - { - p.res.set_status( 500 ); - return route_done; - } ); - @endcode - - @param h1 The first exception handler. - - @param hn Additional exception handlers. - */ - template - void except(H1&& h1, HN&&... hn) - requires (!std::convertible_to) - { - static_assert(handler_crvals, - "pass handlers by value or std::move()"); - static_assert(handler_check<8, H1, HN...>, - "only exception handlers are allowed here"); - except(std::string_view(), - std::forward

(h1), std::forward(hn)...); - } - - /** Add handlers for all HTTP methods matching a path pattern. - - This registers regular handlers for the specified path pattern, - participating in dispatch as described in the @ref basic_router - class documentation. Handlers run when the route matches, - regardless of HTTP method, and execute in registration order. - Error handlers and routers cannot be passed here. A new route - object is created even if the pattern already exists. - - @par Example - @code - router.route( "/status" ) - .add( method::head, check_headers ) - .add( method::get, send_status ) - .all( log_access ); - @endcode - - @par Preconditions - - @p pattern must be a valid path pattern; it must not be empty. - - @param pattern The path pattern to match. - - @param h1 The first handler to add. - - @param hn Additional handlers to add, invoked after @p h1 in - registration order. - */ - template - void all( - std::string_view pattern, - H1&& h1, HN&&... hn) - { - static_assert(handler_crvals, - "pass handlers by value or std::move()"); - static_assert(handler_check<1, H1, HN...>, - "only normal route handlers are allowed here"); - this->route(pattern).all( - std::forward

(h1), std::forward(hn)...); - } - - /** Add route handlers for a method and pattern. - - This registers regular handlers for the specified HTTP verb and - path pattern, participating in dispatch as described in the - @ref basic_router class documentation. Error handlers and - routers cannot be passed here. - - @param verb The known HTTP method to match. - - @param pattern The path pattern to match. - - @param h1 The first handler to add. - - @param hn Additional handlers to add, invoked after @p h1 in - registration order. - */ - template - void add( - http::method verb, - std::string_view pattern, - H1&& h1, HN&&... hn) - { - static_assert(handler_crvals, - "pass handlers by value or std::move()"); - static_assert(handler_check<1, H1, HN...>, - "only normal route handlers are allowed here"); - this->route(pattern).add(verb, - std::forward

(h1), std::forward(hn)...); - } - - /** Add route handlers for a method string and pattern. - - This registers regular handlers for the specified HTTP verb and - path pattern, participating in dispatch as described in the - @ref basic_router class documentation. Error handlers and - routers cannot be passed here. - - @param verb The HTTP method string to match. - - @param pattern The path pattern to match. - - @param h1 The first handler to add. - - @param hn Additional handlers to add, invoked after @p h1 in - registration order. - */ - template - void add( - std::string_view verb, - std::string_view pattern, - H1&& h1, HN&&... hn) - { - static_assert(handler_crvals, - "pass handlers by value or std::move()"); - static_assert(handler_check<1, H1, HN...>, - "only normal route handlers are allowed here"); - this->route(pattern).add(verb, - std::forward

(h1), std::forward(hn)...); - } - - /** Return a fluent route for the specified path pattern. - - Adds a new route to the router for the given pattern. - A new route object is always created, even if another - route with the same pattern already exists. The returned - @ref fluent_route reference allows method-specific handler - registration (such as GET or POST) or catch-all handlers - with @ref fluent_route::all. - - @param pattern The path expression to match against request - targets. This may include parameters or wildcards following - the router's pattern syntax. May not be empty. - - @return A fluent route interface for chaining handler - registrations. - */ - auto - route( - std::string_view pattern) -> fluent_route - { - 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 -class basic_router

:: - fluent_route -{ -public: - fluent_route(fluent_route const&) = default; - - /** Add handlers that apply to all HTTP methods. - - This registers regular handlers that run for any request matching - the route's pattern, regardless of HTTP method. Handlers are - appended to the route's handler sequence and are invoked in - registration order whenever a preceding handler returns - @ref route_next. Error handlers and routers cannot be passed here. - - This function returns a @ref fluent_route, allowing additional - method registrations to be chained. For example: - @code - router.route( "/resource" ) - .all( log_request ) - .add( method::get, show_resource ) - .add( method::post, update_resource ); - @endcode - - @param h1 The first handler to add. - - @param hn Additional handlers to add, invoked after @p h1 in - registration order. - - @return A reference to `*this` for chained registrations. - */ - template - auto all( - H1&& h1, HN&&... hn) -> - fluent_route - { - static_assert(handler_check<1, H1, HN...>); - owner_.add_impl(owner_.get_layer(layer_idx_), std::string_view{}, - owner_.make_handlers( - std::forward

(h1), std::forward(hn)...)); - return *this; - } - - /** Add handlers for a specific HTTP method. - - This registers regular handlers for the given method on the - current route, participating in dispatch as described in the - @ref basic_router class documentation. Handlers are appended - to the route's handler sequence and invoked in registration - order whenever a preceding handler returns @ref route_next. - Error handlers and routers cannot be passed here. - - @param verb The HTTP method to match. - - @param h1 The first handler to add. - - @param hn Additional handlers to add, invoked after @p h1 in - registration order. - - @return A reference to `*this` for chained registrations. - */ - template - auto add( - http::method verb, - H1&& h1, HN&&... hn) -> - fluent_route - { - static_assert(handler_check<1, H1, HN...>); - owner_.add_impl(owner_.get_layer(layer_idx_), verb, owner_.make_handlers( - std::forward

(h1), std::forward(hn)...)); - return *this; - } - - /** Add handlers for a method string. - - This registers regular handlers for the given HTTP method string - on the current route, participating in dispatch as described in - the @ref basic_router class documentation. This overload is - intended for methods not represented by @ref http::method. - Handlers are appended to the route's handler sequence and invoked - in registration order whenever a preceding handler returns - @ref route_next. Error handlers and routers cannot be passed here. - - @param verb The HTTP method string to match. - - @param h1 The first handler to add. - - @param hn Additional handlers to add, invoked after @p h1 in - registration order. - - @return A reference to `*this` for chained registrations. - */ - template - auto add( - std::string_view verb, - H1&& h1, HN&&... hn) -> - fluent_route - { - static_assert(handler_check<1, H1, HN...>); - owner_.add_impl(owner_.get_layer(layer_idx_), verb, owner_.make_handlers( - std::forward

(h1), std::forward(hn)...)); - return *this; - } - -private: - friend class basic_router; - fluent_route( - basic_router& owner, - std::string_view pattern) - : layer_idx_(owner.new_layer_idx(pattern)) - , owner_(owner) - { - } - - std::size_t layer_idx_; - basic_router& owner_; -}; - -} // http -} // boost - -#endif diff --git a/include/boost/http/server/cors.hpp b/include/boost/http/server/cors.hpp index 33c5a1ca..9ae0431a 100644 --- a/include/boost/http/server/cors.hpp +++ b/include/boost/http/server/cors.hpp @@ -1,5 +1,5 @@ // -// Copyright (c) 2025 Vinnie Falco (vinnie dot falco at gmail dot com) +// 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 http://www.boost.org/LICENSE_1_0.txt) @@ -11,7 +11,7 @@ #define BOOST_HTTP_SERVER_CORS_HPP #include -#include +#include #include #include diff --git a/include/boost/http/server/detail/dynamic_invoke.hpp b/include/boost/http/server/detail/dynamic_invoke.hpp new file mode 100644 index 00000000..2706925e --- /dev/null +++ b/include/boost/http/server/detail/dynamic_invoke.hpp @@ -0,0 +1,279 @@ +// +// 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 http://www.boost.org/LICENSE_1_0.txt) +// +// Official repository: https://github.com/cppalliance/http +// + +#ifndef BOOST_HTTP_SERVER_DETAIL_DYNAMIC_INVOKE_HPP +#define BOOST_HTTP_SERVER_DETAIL_DYNAMIC_INVOKE_HPP + +#include +#include +#include +#include +#include +#include + +namespace boost { +namespace http { +namespace detail { + +//------------------------------------------------ + +template +struct are_unique : std::true_type {}; + +template +struct are_unique + : std::bool_constant< + (!std::is_same_v && ...) && + are_unique::value> {}; + +//------------------------------------------------ + +template +using find_key_t = + std::remove_cv_t< + std::remove_reference_t>; + +// Polystore lookup key: also strips pointer +template +using lookup_key_t = + std::remove_cv_t< + std::remove_pointer_t< + find_key_t>>; + +// True when parameter is a pointer (optional dependency) +template +constexpr bool is_optional_v = + std::is_pointer_v>; + +// Resolve a polystore pointer to the handler's expected arg +template +auto resolve_arg(T* p) +{ + if constexpr (is_optional_v) + return static_cast>(p); + else + return static_cast(*p); +} + +//------------------------------------------------ + +template +route_result +dynamic_invoke_impl( + polystore& ps, + F const& f, + type_list const&) +{ + static_assert( + are_unique...>::value, + "callable has duplicate parameter types"); + + auto ptrs = std::make_tuple( + ps.find>()...); + + return [&]( + std::index_sequence) -> route_result + { + if constexpr (!(is_optional_v && ...)) + { + if(! (... && (is_optional_v || + std::get(ptrs) != nullptr))) + return route_next; + } + return f(resolve_arg( + std::get(ptrs))...); + }(std::index_sequence_for{}); +} + +/** Invoke a callable, resolving arguments from a polystore. + + Each parameter type of the callable is looked up in the + polystore via @ref polystore::find. If all required + parameters are found, the callable is invoked with the + resolved arguments and its result is returned. If any + required parameter is not found, @ref route_next is + returned without invoking the callable. + + Parameters declared as pointer types (e.g. `A*`) are + optional: `nullptr` is passed when the type is absent. + Rvalue reference parameters (e.g. `A&&`) are supported + and receive a moved reference to the stored object. + + Duplicate parameter types (after stripping cv-ref and + pointer) produce a compile-time error. + + @param ps The polystore to resolve arguments from. + @param f The callable to invoke. + @return The result of the callable, or @ref route_next + if any required parameter was not found. +*/ +template +route_result +dynamic_invoke( + polystore& ps, + F const& f) +{ + return dynamic_invoke_impl( + ps, f, + typename call_traits< + std::decay_t>::arg_types{}); +} + +//------------------------------------------------ + +/** Wraps a callable whose first parameter is `route_params&` + and whose remaining parameters are resolved from + @ref route_params::route_data at dispatch time. + + Produced by @ref dynamic_transform. Stored inside + the router's handler table. +*/ +template +struct dynamic_handler +{ + F f; + + // No extra parameters -- just forward to the callable + template + route_task + invoke_impl( + route_params& p, + type_list const&) const + { + static_assert( + std::is_convertible_v, + "first parameter must accept route_params&"); + using R = std::invoke_result_t< + F const&, route_params&>; + if constexpr (std::is_same_v) + return f(p); + else + return wrap_result(f(p)); + } + + static route_task + make_route_next() + { + co_return route_next; + } + + static route_task + wrap_result(route_result r) + { + co_return r; + } + + // Extra parameters resolved from route_data + template + route_task + invoke_impl( + route_params& p, + type_list const&) const + { + static_assert( + std::is_convertible_v, + "first parameter must accept route_params&"); + return invoke_extras(p, type_list{}); + } + + template + route_task + invoke_extras( + route_params& p, + type_list const&) const + { + static_assert( + are_unique< + lookup_key_t...>::value, + "callable has duplicate parameter types"); + + auto ptrs = std::make_tuple( + p.route_data.template find< + lookup_key_t>()...); + + return [this, &p, &ptrs]( + std::index_sequence) -> route_task + { + if constexpr (!(is_optional_v && ...)) + { + if(! (... && (is_optional_v || + std::get(ptrs) != nullptr))) + return make_route_next(); + } + + using R = std::invoke_result_t< + F const&, route_params&, Extras...>; + if constexpr (std::is_same_v) + return f(p, resolve_arg( + std::get(ptrs))...); + else + return wrap_result(f(p, + resolve_arg( + std::get(ptrs))...)); + }(std::index_sequence_for{}); + } + + route_task + operator()(route_params& p) const + { + return invoke_impl(p, + typename call_traits< + std::decay_t>::arg_types{}); + } +}; + +/** A handler transform that resolves extra parameters from route_data. + + When used with @ref router::with_transform, handlers may + declare a first parameter of type `route_params&` followed + by additional parameters of arbitrary types. At dispatch time, + each extra parameter type is looked up in + @ref route_params::route_data via @ref polystore::find. + If all required parameters are found the handler is invoked; + otherwise @ref route_next is returned. + + Parameters declared as pointer types (e.g. `A*`) are + optional: `nullptr` is passed when the type is absent. + Rvalue reference parameters (e.g. `A&&`) are supported + and receive a moved reference to the stored object. + + Duplicate extra parameter types (after stripping cv-ref + and pointer) produce a compile-time error. + + @par Example + @code + router base; + auto r = base.with_transform( dynamic_transform{} ); + + r.get( "/users", []( + route_params& p, + UserService& svc, + Config const& cfg) -> route_result + { + // svc and cfg resolved from p.route_data + return route_done; + }); + @endcode +*/ +struct dynamic_transform +{ + template + auto + operator()(F f) const -> + dynamic_handler> + { + return { std::move(f) }; + } +}; + +} // detail +} // http +} // boost + +#endif diff --git a/include/boost/http/server/detail/router_base.hpp b/include/boost/http/server/detail/router_base.hpp index 8526d7bb..7c524e33 100644 --- a/include/boost/http/server/detail/router_base.hpp +++ b/include/boost/http/server/detail/router_base.hpp @@ -1,5 +1,5 @@ // -// Copyright (c) 2025 Vinnie Falco (vinnie dot falco at gmail dot com) +// 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 http://www.boost.org/LICENSE_1_0.txt) @@ -11,11 +11,10 @@ #define BOOST_HTTP_SERVER_DETAIL_ROUTER_BASE_HPP #include -#include +#include #include #include #include -#include #include #include #include @@ -24,21 +23,36 @@ namespace boost { namespace http { +namespace detail { -template -class basic_router; -class flat_router; +template class router; -namespace detail { +/** Non-template base class for all routers. + + Holds a shared reference to an internal routing table + that is built incrementally as routes are added. The routing + table uses contiguous flat arrays for cache-friendly dispatch. + + Copies share the same underlying routing data. Modifying + a router after it has been copied is not permitted and + results in undefined behavior. -// implementation for all routers + @par Thread Safety + + `dispatch` may be called concurrently on routers that share + the same data. Modification through `router` is not + thread-safe and must not be performed concurrently with any + other operation. + + @see router +*/ class BOOST_HTTP_DECL router_base { struct impl; - impl* impl_; + std::shared_ptr impl_; - friend class http::flat_router; + template friend class http::router; protected: using opt_flags = unsigned int; @@ -48,7 +62,6 @@ class BOOST_HTTP_DECL is_invalid = 0, is_plain = 1, is_error = 2, - is_router = 4, is_exception = 8 }; @@ -58,12 +71,8 @@ class BOOST_HTTP_DECL char const kind; explicit handler(char kind_) noexcept : kind(kind_) {} virtual ~handler() = default; - virtual auto invoke(route_params_base&) const -> + virtual auto invoke(route_params&) const -> route_task = 0; - - // Returns the nested router if this handler wraps one, nullptr otherwise. - // Used by flat_router::flatten() to recurse into nested routers. - virtual router_base* get_router() noexcept { return nullptr; } }; using handler_ptr = std::unique_ptr; @@ -74,50 +83,98 @@ 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&, + route_params&, std::string_view allow) const = 0; }; using options_handler_ptr = std::unique_ptr; protected: - using match_result = route_params_base::match_result; + using match_result = route_params::match_result; struct matcher; struct entry; - struct layer; - - ~router_base(); - router_base(opt_flags); - router_base(router_base&&) noexcept; - router_base& operator=(router_base&&) noexcept; - layer& new_layer(std::string_view pattern); - std::size_t new_layer_idx(std::string_view pattern); - layer& get_layer(std::size_t idx); - void add_impl(std::string_view, handlers); - 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_; + + // Construct with options + explicit router_base(opt_flags); + + // Registration helpers + void add_middleware(std::string_view pattern, handlers hn); + void inline_router(std::string_view pattern, router_base&& sub); + std::size_t new_route(std::string_view pattern); + void add_to_route(std::size_t idx, http::method verb, handlers hn); + void add_to_route(std::size_t idx, std::string_view verb, handlers hn); + void finalize_pending(); + void set_options_handler_impl(options_handler_ptr p); public: + /** Default constructor. + + Creates a router in an empty state. The only valid + operations on a default-constructed router are + assignment, destruction, and copying. + */ + router_base() = default; + + router_base(router_base const&) = default; + router_base(router_base&&) noexcept = default; + router_base& operator=(router_base const&) = default; + router_base& operator=(router_base&&) noexcept = default; + ~router_base() = default; + + /** Dispatch a request using a known HTTP method. + + @param verb The HTTP method to match. Must not be + @ref http::method::unknown. + + @param url The full request target used for route matching. + + @param p The params to pass to handlers. + + @return A task yielding the @ref route_result describing + how routing completed. + + @throws std::invalid_argument If @p verb is + @ref http::method::unknown. + */ + route_task + dispatch( + http::method verb, + urls::url_view const& url, + route_params& p) const; + + /** Dispatch a request using a method string. + + @param verb The HTTP method string to match. Must not be empty. + + @param url The full request target used for route matching. + + @param p The params to pass to handlers. + + @return A task yielding the @ref route_result describing + how routing completed. + + @throws std::invalid_argument If @p verb is empty. + */ + route_task + dispatch( + std::string_view verb, + urls::url_view const& url, + route_params& p) const; + /** Maximum nesting depth for routers. This limit applies to nested routers added via use(). - Exceeding this limit throws std::length_error at insertion time. + Exceeding this limit throws std::length_error at + insertion time. */ static constexpr std::size_t max_path_depth = 16; }; -template -concept returns_route_task = std::same_as< - std::invoke_result_t, route_task>; - } // detail } // http } // boost diff --git a/include/boost/http/server/encode_url.hpp b/include/boost/http/server/encode_url.hpp index 50f01c40..4999e109 100644 --- a/include/boost/http/server/encode_url.hpp +++ b/include/boost/http/server/encode_url.hpp @@ -1,5 +1,5 @@ // -// Copyright (c) 2025 Vinnie Falco (vinnie dot falco at gmail dot com) +// 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 http://www.boost.org/LICENSE_1_0.txt) diff --git a/include/boost/http/server/escape_html.hpp b/include/boost/http/server/escape_html.hpp index 1c5374c0..3db52da6 100644 --- a/include/boost/http/server/escape_html.hpp +++ b/include/boost/http/server/escape_html.hpp @@ -1,5 +1,5 @@ // -// Copyright (c) 2025 Vinnie Falco (vinnie dot falco at gmail dot com) +// 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 http://www.boost.org/LICENSE_1_0.txt) diff --git a/include/boost/http/server/etag.hpp b/include/boost/http/server/etag.hpp index 911b2f10..31c4830e 100644 --- a/include/boost/http/server/etag.hpp +++ b/include/boost/http/server/etag.hpp @@ -1,5 +1,5 @@ // -// Copyright (c) 2025 Vinnie Falco (vinnie dot falco at gmail dot com) +// 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 http://www.boost.org/LICENSE_1_0.txt) diff --git a/include/boost/http/server/flat_router.hpp b/include/boost/http/server/flat_router.hpp deleted file mode 100644 index a15d175e..00000000 --- a/include/boost/http/server/flat_router.hpp +++ /dev/null @@ -1,109 +0,0 @@ -// -// Copyright (c) 2026 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_FLAT_ROUTER_HPP -#define BOOST_HTTP_SERVER_FLAT_ROUTER_HPP - -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include - -namespace boost { -namespace http { - -#ifdef BOOST_MSVC -#pragma warning(push) -#pragma warning(disable: 4251) // shared_ptr needs dll-interface -#endif - -/** A flattened router optimized for dispatch performance. - - `flat_router` is constructed from a @ref router by flattening - its nested structure into contiguous arrays. This eliminates - pointer chasing during dispatch and improves cache locality. - - The dispatch algorithm uses fixed-size arrays sized by - `detail::router_base::max_path_depth`. Since this limit is - enforced when routers are nested, dispatch is guaranteed - not to overflow. -*/ -class BOOST_HTTP_DECL - flat_router -{ - struct impl; - std::shared_ptr impl_; - -public: - /** Default constructor. - - Creates a flat_router in an empty state. The only valid - operations on a default-constructed router are assignment - and destruction. - */ - flat_router() = default; - - flat_router( - detail::router_base&&); - - /** Dispatch a request using a known HTTP method. - - @param verb The HTTP method to match. Must not be - @ref http::method::unknown. - - @param url The full request target used for route matching. - - @param p The params to pass to handlers. - - @return A task yielding the @ref route_result describing - how routing completed. - - @throws std::invalid_argument If @p verb is - @ref http::method::unknown. - */ - route_task - dispatch( - http::method verb, - urls::url_view const& url, - route_params_base& p) const; - - /** Dispatch a request using a method string. - - @param verb The HTTP method string to match. Must not be empty. - - @param url The full request target used for route matching. - - @param p The params to pass to handlers. - - @return A task yielding the @ref route_result describing - how routing completed. - - @throws std::invalid_argument If @p verb is empty. - */ - route_task - dispatch( - std::string_view verb, - urls::url_view const& url, - route_params_base& p) const; -}; - -#ifdef BOOST_MSVC -#pragma warning(pop) -#endif - -} // http -} // boost - -#endif diff --git a/include/boost/http/server/fresh.hpp b/include/boost/http/server/fresh.hpp index c403f819..29e9739e 100644 --- a/include/boost/http/server/fresh.hpp +++ b/include/boost/http/server/fresh.hpp @@ -1,5 +1,5 @@ // -// Copyright (c) 2025 Vinnie Falco (vinnie dot falco at gmail dot com) +// 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 http://www.boost.org/LICENSE_1_0.txt) diff --git a/include/boost/http/server/http_worker.hpp b/include/boost/http/server/http_worker.hpp new file mode 100644 index 00000000..e5265f13 --- /dev/null +++ b/include/boost/http/server/http_worker.hpp @@ -0,0 +1,128 @@ +// +// Copyright (c) 2026 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 http://www.boost.org/LICENSE_1_0.txt) +// +// Official repository: https://github.com/cppalliance/http +// + +#ifndef BOOST_HTTP_SERVER_WORKER_HPP +#define BOOST_HTTP_SERVER_WORKER_HPP + +#include +#include +#include +#include +#include +#include +#include + +namespace boost { +namespace http { + +/** Reusable HTTP request/response processing logic. + + This class provides the core HTTP processing loop: reading + requests, dispatching them through a router, and sending + responses. It is designed as a mix-in base class for use + with @ref corosio::tcp_server. + + @par Usage with tcp_server + + To use this class, derive a custom worker from both + @ref corosio::tcp_server::worker_base and `http_worker`. + The derived class must: + + @li Construct `http_worker` with a router and configurations + @li Initialize the @ref stream member before calling + @ref do_http_session + @li Wire the parser and serializer to the socket by setting + `rp.req_body` and `rp.res_body` + + @par Example + @code + struct my_worker + : tcp_server::worker_base + , http_worker + { + corosio::tcp_socket sock; + + my_worker( + corosio::io_context& ctx, + http::router const& router, + http::shared_parser_config parser_cfg, + http::shared_serializer_config serializer_cfg) + : http_worker(router, parser_cfg, serializer_cfg) + , sock(ctx) + { + sock.open(); + rp.req_body = capy::any_buffer_source(parser.source_for(sock)); + rp.res_body = capy::any_buffer_sink(serializer.sink_for(sock)); + stream = capy::any_read_stream(&sock); + } + + corosio::tcp_socket& socket() override { return sock; } + + void run(launcher launch) override + { + launch(sock.get_executor(), do_http_session()); + } + }; + @endcode + + @par Thread Safety + Distinct objects: Safe. + Shared objects: Unsafe. + + @see corosio::tcp_server, http_server +*/ +class BOOST_HTTP_DECL http_worker +{ +public: + http::router fr; + http::route_params rp; + capy::any_read_stream stream; + http::request_parser parser; + http::serializer serializer; + + /** Construct an HTTP worker. + + @param fr_ The router for dispatching requests to handlers. + @param parser_cfg Shared configuration for the request parser. + @param serializer_cfg Shared configuration for the response + serializer. + */ + template + http_worker( + Stream& stream_, + http::router fr_, + http::shared_parser_config parser_cfg, + http::shared_serializer_config serializer_cfg) + : fr(std::move(fr_)) + , stream(&stream_) + , parser(parser_cfg) + , serializer(serializer_cfg) + { + serializer.set_message(rp.res); + rp.req_body = capy::any_buffer_source(parser.source_for(stream_)); + rp.res_body = capy::any_buffer_sink(serializer.sink_for(stream_)); + } + + /** Handle an HTTP session. + + This coroutine reads HTTP requests, dispatches them through + the router, and sends responses until the connection is + closed or an error occurs. The stream data member must be + initialized before calling this function. + + @return An awaitable that completes when the session ends. + */ + capy::task + do_http_session(); +}; + +} // http +} // boost + +#endif diff --git a/include/boost/http/server/mime_db.hpp b/include/boost/http/server/mime_db.hpp index 7e9dab85..2f5241ee 100644 --- a/include/boost/http/server/mime_db.hpp +++ b/include/boost/http/server/mime_db.hpp @@ -1,5 +1,5 @@ // -// Copyright (c) 2025 Vinnie Falco (vinnie dot falco at gmail dot com) +// 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 http://www.boost.org/LICENSE_1_0.txt) diff --git a/include/boost/http/server/mime_types.hpp b/include/boost/http/server/mime_types.hpp index 088a8663..f438ea71 100644 --- a/include/boost/http/server/mime_types.hpp +++ b/include/boost/http/server/mime_types.hpp @@ -1,5 +1,5 @@ // -// Copyright (c) 2025 Vinnie Falco (vinnie dot falco at gmail dot com) +// 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 http://www.boost.org/LICENSE_1_0.txt) diff --git a/include/boost/http/server/range_parser.hpp b/include/boost/http/server/range_parser.hpp index 890d07e9..b478de53 100644 --- a/include/boost/http/server/range_parser.hpp +++ b/include/boost/http/server/range_parser.hpp @@ -1,5 +1,5 @@ // -// Copyright (c) 2025 Vinnie Falco (vinnie dot falco at gmail dot com) +// 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 http://www.boost.org/LICENSE_1_0.txt) diff --git a/include/boost/http/server/router_types.hpp b/include/boost/http/server/route_handler.hpp similarity index 78% rename from include/boost/http/server/router_types.hpp rename to include/boost/http/server/route_handler.hpp index db3ea6df..eec7e0ed 100644 --- a/include/boost/http/server/router_types.hpp +++ b/include/boost/http/server/route_handler.hpp @@ -1,5 +1,5 @@ // -// Copyright (c) 2025 Vinnie Falco (vinnie dot falco at gmail dot com) +// 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 http://www.boost.org/LICENSE_1_0.txt) @@ -7,18 +7,31 @@ // Official repository: https://github.com/cppalliance/http // -#ifndef BOOST_HTTP_SERVER_ROUTER_TYPES_HPP -#define BOOST_HTTP_SERVER_ROUTER_TYPES_HPP +#ifndef BOOST_HTTP_SERVER_ROUTE_HANDLER_HPP +#define BOOST_HTTP_SERVER_ROUTE_HANDLER_HPP #include #include #include +#include +#include +#include #include +#include +#include #include +#include #include +#include +#include +#include +#include #include #include +#include #include +#include +#include #include #include #include @@ -317,15 +330,15 @@ using route_task = capy::task; //------------------------------------------------ +template class router; + namespace detail { + +struct route_params_access; class router_base; -} // detail -template class basic_router; struct route_params_base_privates { - struct match_result; - std::string verb_str_; std::string decoded_path_; system::error_code ec_; @@ -337,23 +350,44 @@ struct route_params_base_privates bool addedSlash_ = false; bool case_sensitive = false; bool strict = false; - char kind_ = 0; // dispatch mode, initialized by flat_router::dispatch() + char kind_ = 0; }; -/** Base class for request objects +} // detail + +//------------------------------------------------ + +/** Parameters object for HTTP route handlers. + + This structure holds all the context needed for a route + handler to process an HTTP request and generate a response. + + @par Example + @code + route_task my_handler(route_params& p) + { + p.res.set(field::content_type, "text/plain"); + co_await p.send("Hello, World!"); + co_return route_done; + } + @endcode - This is a required public base for any `Request` - type used with @ref router. + @see route_task, route_result */ -class route_params_base : public route_params_base_privates +class BOOST_HTTP_SYMBOL_VISIBLE + route_params { + detail::route_params_base_privates priv_; + public: + struct match_result; + /** Return true if the request method matches `m` */ bool is_method( http::method m) const noexcept { - return verb_ == m; + return priv_.verb_ == m; } /** Return true if the request method matches `s` @@ -382,24 +416,52 @@ class route_params_base : public route_params_base_privates */ std::vector> params; - struct match_result; + /// The complete request target + urls::url_view url; + + /// The HTTP request + http::request req; + + /// The HTTP response + http::response res; + + /// Provides access to the request body + capy::any_buffer_source req_body; + + /// Provides access to the response body + capy::any_buffer_sink res_body; + + /// Arbitrary per-route data + http::datastore route_data; + + /// Arbitrary per-session data + http::datastore session_data; + + BOOST_HTTP_DECL ~route_params(); + BOOST_HTTP_DECL void reset(); + BOOST_HTTP_DECL route_params& status(http::status code); + + /** Send the response with an optional body. + */ + BOOST_HTTP_DECL capy::io_task<> send(std::string_view body = {}); private: - template - friend class basic_router; - friend struct route_params_access; + template + friend class router; + friend class detail::router_base; + friend struct detail::route_params_access; - route_params_base& operator=( - route_params_base const&) = delete; + route_params& operator=( + route_params const&) = delete; }; -struct route_params_base:: +struct route_params:: match_result { std::vector> params_; void adjust_path( - route_params_base& p, + route_params& p, std::size_t n) { n_ = n; @@ -415,20 +477,20 @@ struct route_params_base:: else { // append a soft slash - p.path = { p.decoded_path_.data() + - p.decoded_path_.size() - 1, 1}; + p.path = { p.priv_.decoded_path_.data() + + p.priv_.decoded_path_.size() - 1, 1}; BOOST_ASSERT(p.path == "/"); } } void restore_path( - route_params_base& p) + route_params& p) { if( n_ > 0 && - p.addedSlash_ && + p.priv_.addedSlash_ && p.path.data() == - p.decoded_path_.data() + - p.decoded_path_.size() - 1) + p.priv_.decoded_path_.data() + + p.priv_.decoded_path_.size() - 1) { // remove soft slash p.path = { @@ -445,16 +507,26 @@ struct route_params_base:: std::size_t n_ = 0; // chars moved from path to base_path }; +//------------------------------------------------ namespace detail { +template +concept returns_route_task = std::same_as< + std::invoke_result_t, route_task>; + struct route_params_access { - route_params_base& rp; + route_params& rp; + + route_params_base_privates& operator*() const noexcept + { + return rp.priv_; + } route_params_base_privates* operator->() const noexcept { - return &rp; + return &rp.priv_; } }; diff --git a/include/boost/http/server/router.hpp b/include/boost/http/server/router.hpp index 71b9bb9b..e5102344 100644 --- a/include/boost/http/server/router.hpp +++ b/include/boost/http/server/router.hpp @@ -1,5 +1,5 @@ // -// Copyright (c) 2025 Vinnie Falco (vinnie dot falco at gmail dot com) +// 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 http://www.boost.org/LICENSE_1_0.txt) @@ -11,63 +11,1215 @@ #define BOOST_HTTP_SERVER_ROUTER_HPP #include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include +#include +#include +#include #include -#include -#include -#include +#include +#include +#include +#include +#include namespace boost { namespace http { -/** Parameters object for HTTP route handlers. +template class router; - This structure holds all the context needed for a route - handler to process an HTTP request and generate a response. +/** Configuration options for HTTP routers. +*/ +struct router_options +{ + /** Constructor. + + Routers constructed with default options inherit the values of + @ref case_sensitive and @ref strict from the parent router. + If there is no parent, both default to `false`. + The value of @ref merge_params always defaults to `false` + and is never inherited. + */ + router_options() = default; + + /** Set whether to merge parameters from parent routers. + + This setting controls whether route parameters defined on parent + routers are made available in nested routers. It is not inherited + and always defaults to `false`. + + @par Example + @code + router r( router_options() + .merge_params( true ) + .case_sensitive( true ) + .strict( false ) ); + @endcode + + @param value `true` to merge parameters from parent routers. + + @return A reference to `*this` for chaining. + */ + router_options& + merge_params( + bool value) noexcept + { + v_ = (v_ & ~1) | (value ? 1 : 0); + return *this; + } + + /** Set whether pattern matching is case-sensitive. + + When this option is not set explicitly, the value is inherited + from the parent router or defaults to `false` if there is no parent. + + @par Example + @code + router r( router_options() + .case_sensitive( true ) + .strict( true ) ); + @endcode + + @param value `true` to perform case-sensitive path matching. + + @return A reference to `*this` for chaining. + */ + router_options& + case_sensitive( + bool value) noexcept + { + if(value) + v_ = (v_ & ~6) | 2; + else + v_ = (v_ & ~6) | 4; + return *this; + } + + /** Set whether pattern matching is strict. + + When this option is not set explicitly, the value is inherited + from the parent router or defaults to `false` if there is no parent. + Strict matching treats a trailing slash as significant: + the pattern `"/api"` matches `"/api"` but not `"/api/"`. + When strict matching is disabled, these paths are treated + as equivalent. + + @par Example + @code + router r( router_options() + .strict( true ) + .case_sensitive( false ) ); + @endcode + + @param value `true` to enable strict path matching. + + @return A reference to `*this` for chaining. + */ + router_options& + strict( + bool value) noexcept + { + if(value) + v_ = (v_ & ~24) | 8; + else + v_ = (v_ & ~24) | 16; + return *this; + } + +private: + template friend class router; + unsigned int v_ = 0; +}; + +//----------------------------------------------- + +/** The default handler transform. + + Passes each handler through unchanged. This is the + default value of the `HT` template parameter on + @ref router. +*/ +struct identity +{ + template< class T > + T operator()( T&& t ) const + { + return std::forward(t); + } +}; + +/** A container for HTTP route handlers. + + `router` objects store and dispatch route handlers based on the + HTTP method and path of an incoming request. Routes are added with a + path pattern, method, and an associated handler, and the router is then + used to dispatch the appropriate handler. + + Routes are flattened into contiguous arrays as they are added, so + dispatch is always cache-friendly regardless of nesting depth. + + Patterns used to create route definitions have percent-decoding applied + when handlers are mounted. A literal "%2F" in the pattern string is + indistinguishable from a literal '/'. For example, "/x%2Fz" is the + same as "/x/z" when used as a pattern. @par Example @code - route_task my_handler(route_params& p) + router r; + r.get( "/hello", + []( route_params& p ) + { + p.res.status( status::ok ); + p.res.set_body( "Hello, world!" ); + return route_done; + } ); + @endcode + + Router objects use shared ownership via `shared_ptr`. Copies refer + to the same underlying data. Modifying a router after it has been + copied is not permitted and results in undefined behavior. + + @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 + equivalent signature: + @code + route_result handler( Params& p ) + @endcode + + The return value is a @ref route_result used to indicate the desired + action through @ref route enum values, or to indicate that a failure + occurred. Failures are represented by error codes for which + `system::error_code::failed()` returns `true`. + + When a failing error code is produced and remains unhandled, the + router enters error-dispatching mode. In this mode, only error + handlers are invoked. Error handlers are registered globally or + for specific paths and execute in the order of registration whenever + a failing error code is present in the response. + + Error handlers have this equivalent signature: + @code + route_result error_handler( Params& p, system::error_code ec ) + @endcode + + Each error handler may return any failing @ref system::error_code, + which is equivalent to calling: + @code + p.next( ec ); // with ec == true + @endcode + + Returning @ref route_next indicates that control should proceed to + the next matching error handler. Returning a different failing code + replaces the current error and continues dispatch in error mode using + that new code. Error handlers are invoked until one returns a result + other than @ref route_next. + + Exception handlers have this equivalent signature: + @code + route_result exception_handler( Params& p, E ex ) + @endcode + + Where `E` is the type of exception caught. Handlers installed for an + exception of type `E` will also be called when the exception type is + a derived class of `E`. Exception handlers are invoked in the order + of registration whenever an exception is present in the request. + + The prefix match is not strict: middleware attached to `"/api"` + will also match `"/api/users"` and `"/api/data"`. When registered + before route handlers for the same prefix, middleware runs before + those routes. This is analogous to `app.use( path, ... )` in + Express.js. + + @par Handler Transforms + + The second template parameter `HT` is a handler transform. + A handler transform is a callable object that the router applies to + each plain handler at registration time, producing a new callable + with the canonical signature `route_task(Params&)`. + + When a handler is registered that does not directly satisfy + `route_task(Params&)`, the router applies `ht(handler)` to adapt + it. The transform is invoked once at registration time; + the returned callable is stored and invoked each time the route + matches at dispatch time. + + Error handlers and exception handlers are never transformed. + Only plain route handlers pass through the transform. + + The default transform is @ref identity, which passes handlers + through unchanged. + + A transform `HT` must satisfy the following contract: for any + handler `h` passed to the router, the expression `ht(h)` must + return a callable `g` such that `g(Params&)` returns + @ref route_task. + + @par Example: Logging Transform + @code + struct log_transform { - p.res.set(field::content_type, "text/plain"); - co_await p.send("Hello, World!"); + template + auto operator()(Handler h) const + { + struct wrapper + { + Handler h_; + + route_task operator()(route_params& p) const + { + auto t0 = steady_clock::now(); + auto rv = co_await h_(p); + log_elapsed(steady_clock::now() - t0); + co_return rv; + } + }; + return wrapper{ std::move(h) }; + } + }; + + router base; + auto r = base.with_transform( log_transform{} ); + + // The lambda is wrapped by log_transform at registration. + // At dispatch, log_transform::wrapper::operator() runs, + // which invokes the original lambda and logs elapsed time. + r.get( "/hello", []( route_params& p ) -> route_task { co_return route_done; - } + }); + @endcode + + @par Example: Dependency Injection Transform + + A transform can adapt handlers whose parameters are not + `Params&` at all. The transform resolves each parameter + from a service container at dispatch time: + + @code + struct inject_transform + { + template + auto operator()(Handler h) const + { + struct wrapper + { + Handler h_; + + route_task operator()(route_params& p) const + { + // Look up each of h_'s parameter types + // in p.route_data. Return route_next if + // any are missing. + co_return dynamic_invoke(p.route_data, h_); + } + }; + return wrapper{ std::move(h) }; + } + }; + + router base; + auto r = base.with_transform( inject_transform{} ); + + // Parameters are resolved from p.route_data automatically. + // If UserService or Config are not in route_data, the + // handler is skipped and route_next is returned. + r.get( "/users", []( + UserService& svc, + Config const& cfg) -> route_result + { + // use svc and cfg... + return route_done; + }); @endcode - @see route_task, route_result + @par Thread Safety + + Member functions marked `const` such as @ref dispatch + may be called concurrently on routers that refer to the same data. + Modification of routers through calls to non-`const` member functions + is not thread-safe and must not be performed concurrently with any + other member function. + + @par Nesting Depth + + Routers may be nested to a maximum depth of `max_path_depth` (16 levels). + Exceeding this limit throws `std::length_error` when the nested router + is added via @ref use. This limit ensures that dispatch never overflows + its fixed-size tracking arrays. + + @par Constraints + + `Params` must be publicly derived from @ref route_params. + + @tparam Params The type of the parameters object passed to handlers. */ -struct BOOST_HTTP_SYMBOL_VISIBLE - route_params : route_params_base +template +class router : public detail::router_base { - urls::url_view url; // The complete request target - http::request req; - http::response res; - capy::any_buffer_source req_body; - capy::any_buffer_sink res_body; - http::datastore route_data; // arbitrary data - http::datastore session_data; - - BOOST_HTTP_DECL ~route_params(); - BOOST_HTTP_DECL void reset(); // reset per request - BOOST_HTTP_DECL route_params& status(http::status code); - - BOOST_HTTP_DECL capy::io_task<> send(std::string_view body = {}); + template friend class router; + + HT ht_{}; + + static_assert(std::derived_from); + + template + static inline constexpr char handler_kind = + []() -> char + { + if constexpr (detail::returns_route_task< + T, P&, system::error_code>) + { + return is_error; + } + else if constexpr (detail::returns_route_task< + T, P&, std::exception_ptr>) + { + return is_exception; + } + else if constexpr (detail::returns_route_task) + { + return is_plain; + } + else if constexpr ( + std::is_invocable_v && + detail::returns_route_task< + std::invoke_result_t, P&>) + { + return is_plain; + } + else + { + return is_invalid; + } + }(); + + template + static inline constexpr bool is_sub_router = + std::is_base_of_v> && + std::is_convertible_v const volatile*, + detail::router_base const volatile*>; + + template + static inline constexpr bool handler_crvals = + ((!std::is_lvalue_reference_v || + std::is_const_v> || + std::is_function_v>) && ...); + + template + static inline constexpr bool handler_check = + (((handler_kind & Mask) != 0) && ...); + + template + struct handler_impl : handler + { + std::decay_t h; + + template + explicit handler_impl(H_ h_) + : handler(handler_kind) + , h(std::forward(h_)) + { + } + + auto invoke(route_params& rp) const -> + route_task override + { + if constexpr (detail::returns_route_task) + { + return h(static_cast(rp)); + } + else if constexpr (detail::returns_route_task< + H, P&, system::error_code>) + { + return h(static_cast(rp), rp.priv_.ec_); + } + else if constexpr (detail::returns_route_task< + H, P&, std::exception_ptr>) + { + return h(static_cast(rp), rp.priv_.ep_); + } + else + { + std::terminate(); + } + } + }; + + template + static handler_ptr make_handler(H&& h) + { + 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& rp, + std::string_view allow) const override + { + return h(static_cast(rp), allow); + } + }; + + template + struct handlers_impl : handlers + { + T const& ht; + handler_ptr v[N]; + + template + explicit handlers_impl(T const& ht_, HN&&... hn) + : ht(ht_) + { + p = v; + n = sizeof...(HN); + assign<0>(std::forward(hn)...); + } + + private: + template + void assign(H1&& h1, HN&&... hn) + { + if constexpr ( + detail::returns_route_task< + H1, P&, system::error_code> || + detail::returns_route_task< + H1, P&, std::exception_ptr>) + { + v[I] = make_handler(std::forward

(h1)); + } + else if constexpr (detail::returns_route_task< + decltype(std::declval()(std::declval

())), P&>) + { + v[I] = make_handler(ht(std::forward

(h1))); + } + assign(std::forward(hn)...); + } + + template + void assign(int = 0) + { + } + }; + + template + static auto make_handlers(T const& ht, HN&&... hn) + { + return handlers_impl(ht, + std::forward(hn)...); + } + +public: + /** The type of params used in handlers. + */ + using params_type = P; + + /** A fluent interface for defining handlers on a specific route. + + This type represents a single route within the router and + provides a chainable API for registering handlers associated + with particular HTTP methods or for all methods collectively. + + Typical usage registers one or more handlers for a route: + @code + r.route( "/users/:id" ) + .get( show_user ) + .put( update_user ) + .all( log_access ); + @endcode + + Each call appends handlers in registration order. + */ + class fluent_route; + + router(router const&) = default; + router& operator=(router const&) = default; + router(router&&) = default; + router& operator=(router&&) = default; + + /** Constructor. + + Creates an empty router with the specified configuration. + Routers constructed with default options inherit the values + of @ref router_options::case_sensitive and + @ref router_options::strict from the parent router, or default + to `false` if there is no parent. The value of + @ref router_options::merge_params defaults to `false` and + is never inherited. + + @param options The configuration options to use. + */ + explicit + router( + router_options options = {}) + : detail::router_base(options.v_) + { + } + + /** Construct a router from another router with compatible types. + + This constructs a router that shares the same underlying routing + state as another router whose params and handler transform types + may differ. + + The handler transform is initialized as follows: + - If `HT` is constructible from `OtherHT`, the transform is + move-constructed from the source router's transform. + - Otherwise, if `HT` is default-constructible, the transform + is value-initialized. + + @par Constraints + + `OtherParams` must be derived from `Params`, and `HT` must be + either constructible from `OtherHT` or default-constructible. + + @param other The router to construct from. + + @tparam OtherParams The params type of the source router. + + @tparam OtherHT The handler transform type of the source router. + */ + template + requires std::derived_from && + std::constructible_from + router( + router&& other) noexcept + : detail::router_base(std::move(other)) + , ht_(std::move(other.ht_)) + { + } + + /// @copydoc router(router&&) + template + requires std::derived_from && + (!std::constructible_from) && + std::default_initializable + router( + router&& other) noexcept + : detail::router_base(std::move(other)) + { + } + + /** Construct a router with a handler transform. + + Creates a router that shares the routing state of @p other + but applies @p ht to each plain handler before installation. + + @param other The router whose routing state to share. + + @param ht The handler transform to apply. + */ + template + router(router const& other, HT ht) + : detail::router_base(other) + , ht_(std::move(ht)) + { + } + + /** Return a router that applies a transform to plain handlers. + + Creates a new router that shares the same underlying routing + table but applies @p f to each plain handler before it is + stored. Error and exception handlers are not affected by + the transform. + + The transform is invoked once at handler registration time. + For each plain handler `h` passed to the returned router, + `f(h)` must produce a callable `g` such that `g(Params&)` + returns @ref route_task. The callable `g` is what gets + stored and invoked at dispatch time. + + @par Shared State + + The returned router shares the same routing table as + `*this`. Routes added through either router are visible + during dispatch from both. The transform only controls + how new handlers are wrapped when they are registered + through the returned router. + + @par Example: Simple Transform + @code + // A transform that logs before each handler runs + auto r = base.with_transform( + []( auto handler ) + { + struct wrapper + { + decltype(handler) h_; + route_task operator()(route_params& p) const + { + std::cout << "dispatching\n"; + co_return co_await h_(p); + } + }; + return wrapper{ std::move(handler) }; + }); + @endcode + + @par Example: Chaining Transforms + @code + auto r1 = base.with_transform( first_transform{} ); + auto r2 = base.with_transform( second_transform{} ); + + // r1 applies first_transform to its handlers + // r2 applies second_transform to its handlers + // Both share the same routing table + @endcode + + @par Constraints + + `f(handler)` must return a callable `g` where + `g(Params&)` returns @ref route_task. + + @param f The handler transform to apply. + + @return A router with the transform applied. + */ + template + auto with_transform(F&& f) const -> + router> + { + return router>( + *this, std::forward(f)); + } + + /** Dispatch a request using a known HTTP method. + + @param verb The HTTP method to match. Must not be + @ref http::method::unknown. + + @param url The full request target used for route matching. + + @param p The typed params to pass to handlers. + + @return A task yielding the @ref route_result describing + how routing completed. + + @throws std::invalid_argument If @p verb is + @ref http::method::unknown. + */ + route_task + dispatch( + http::method verb, + urls::url_view const& url, + P& p) const + { + return detail::router_base::dispatch( + verb, url, static_cast(p)); + } + + /** Dispatch a request using a method string. + + @param verb The HTTP method string to match. Must not be empty. + + @param url The full request target used for route matching. + + @param p The typed params to pass to handlers. + + @return A task yielding the @ref route_result describing + how routing completed. + + @throws std::invalid_argument If @p verb is empty. + */ + route_task + dispatch( + std::string_view verb, + urls::url_view const& url, + P& p) const + { + return detail::router_base::dispatch( + verb, url, static_cast(p)); + } + + /** Add middleware handlers for a path prefix. + + Each handler registered with this function participates in the + routing and error-dispatch process for requests whose path begins + with the specified prefix, as described in the @ref router + class documentation. Handlers execute in the order they are added + and may return @ref route_next to transfer control to the + subsequent handler in the chain. + + @par Example + @code + r.use( "/api", + []( route_params& p ) + { + if( ! authenticate( p ) ) + { + p.res.status( 401 ); + p.res.set_body( "Unauthorized" ); + return route_done; + } + return route_next; + }, + []( route_params& p ) + { + p.res.set_header( "X-Powered-By", "MyServer" ); + return route_next; + } ); + @endcode + + @par Preconditions + + @p pattern must be a valid path prefix; it may be empty to + indicate the root scope. + + @param pattern The pattern to match. + + @param h1 The first handler to add. + + @param hn Additional handlers to add, invoked after @p h1 in + registration order. + */ + template + void use( + std::string_view pattern, + H1&& h1, HN&&... hn) + { + // Single sub-router case + if constexpr(sizeof...(HN) == 0 && is_sub_router

) + { + static_assert(!std::is_lvalue_reference_v

, + "pass sub-routers by value or std::move()"); + this->inline_router(pattern, + std::forward

(h1)); + } + else + { + static_assert(handler_crvals, + "pass handlers by value or std::move()"); + static_assert(! handler_check<8, H1, HN...>, + "cannot use exception handlers here"); + static_assert(handler_check<3, H1, HN...>, + "invalid handler signature"); + this->add_middleware(pattern, make_handlers(ht_, + std::forward

(h1), std::forward(hn)...)); + } + } + + /** Add global middleware handlers. + + Each handler registered with this function participates in the + routing and error-dispatch process as described in the + @ref router class documentation. Handlers execute in the + order they are added and may return @ref route_next to transfer + control to the next handler in the chain. + + This is equivalent to writing: + @code + use( "/", h1, hn... ); + @endcode + + @par Example + @code + r.use( + []( Params& p ) + { + p.res.erase( "X-Powered-By" ); + return route_next; + } ); + @endcode + + @par Constraints + + @p h1 must not be convertible to @ref std::string_view. + + @param h1 The first handler to add. + + @param hn Additional handlers to add, invoked after @p h1 in + registration order. + */ + template + void use(H1&& h1, HN&&... hn) + requires (!std::convertible_to) + { + use(std::string_view(), + std::forward

(h1), std::forward(hn)...); + } + + /** Add exception handlers for a route pattern. + + Registers one or more exception handlers that will be invoked + when an exception is thrown during request processing for routes + matching the specified pattern. + + Handlers are invoked in the order provided until one handles + the exception. + + @par Example + @code + app.except( "/api*", + []( route_params& p, std::exception const& ex ) + { + p.res.set_status( 500 ); + return route_done; + } ); + @endcode + + @param pattern The route pattern to match, or empty to match + all routes. + + @param h1 The first exception handler. + + @param hn Additional exception handlers. + */ + template + void except( + std::string_view pattern, + H1&& h1, HN&&... hn) + { + static_assert(handler_crvals, + "pass handlers by value or std::move()"); + static_assert(handler_check<8, H1, HN...>, + "only exception handlers are allowed here"); + this->add_middleware(pattern, make_handlers(ht_, + std::forward

(h1), std::forward(hn)...)); + } + + /** Add global exception handlers. + + Registers one or more exception handlers that will be invoked + when an exception is thrown during request processing for any + route. + + Equivalent to calling `except( "", h1, hn... )`. + + @par Example + @code + app.except( + []( route_params& p, std::exception const& ex ) + { + p.res.set_status( 500 ); + return route_done; + } ); + @endcode + + @param h1 The first exception handler. + + @param hn Additional exception handlers. + */ + template + void except(H1&& h1, HN&&... hn) + requires (!std::convertible_to) + { + static_assert(handler_crvals, + "pass handlers by value or std::move()"); + static_assert(handler_check<8, H1, HN...>, + "only exception handlers are allowed here"); + except(std::string_view(), + std::forward

(h1), std::forward(hn)...); + } + + /** Add handlers for all HTTP methods matching a path pattern. + + This registers regular handlers for the specified path pattern, + participating in dispatch as described in the @ref router + class documentation. Handlers run when the route matches, + regardless of HTTP method, and execute in registration order. + Error handlers and routers cannot be passed here. A new route + object is created even if the pattern already exists. + + @par Example + @code + r.route( "/status" ) + .add( method::head, check_headers ) + .add( method::get, send_status ) + .all( log_access ); + @endcode + + @par Preconditions + + @p pattern must be a valid path pattern; it must not be empty. + + @param pattern The path pattern to match. + + @param h1 The first handler to add. + + @param hn Additional handlers to add, invoked after @p h1 in + registration order. + */ + template + void all( + std::string_view pattern, + H1&& h1, HN&&... hn) + { + static_assert(handler_crvals, + "pass handlers by value or std::move()"); + static_assert(handler_check<1, H1, HN...>, + "only normal route handlers are allowed here"); + this->route(pattern).all( + std::forward

(h1), std::forward(hn)...); + } + + /** Add route handlers for a method and pattern. + + This registers regular handlers for the specified HTTP verb and + path pattern, participating in dispatch as described in the + @ref router class documentation. Error handlers and + routers cannot be passed here. + + @param verb The known HTTP method to match. + + @param pattern The path pattern to match. + + @param h1 The first handler to add. + + @param hn Additional handlers to add, invoked after @p h1 in + registration order. + */ + template + void add( + http::method verb, + std::string_view pattern, + H1&& h1, HN&&... hn) + { + static_assert(handler_crvals, + "pass handlers by value or std::move()"); + static_assert(handler_check<1, H1, HN...>, + "only normal route handlers are allowed here"); + this->route(pattern).add(verb, + std::forward

(h1), std::forward(hn)...); + } + + /** Add route handlers for a method string and pattern. + + This registers regular handlers for the specified HTTP verb and + path pattern, participating in dispatch as described in the + @ref router class documentation. Error handlers and + routers cannot be passed here. + + @param verb The HTTP method string to match. + + @param pattern The path pattern to match. + + @param h1 The first handler to add. + + @param hn Additional handlers to add, invoked after @p h1 in + registration order. + */ + template + void add( + std::string_view verb, + std::string_view pattern, + H1&& h1, HN&&... hn) + { + static_assert(handler_crvals, + "pass handlers by value or std::move()"); + static_assert(handler_check<1, H1, HN...>, + "only normal route handlers are allowed here"); + this->route(pattern).add(verb, + std::forward

(h1), std::forward(hn)...); + } + + /** Return a fluent route for the specified path pattern. + + Adds a new route to the router for the given pattern. + A new route object is always created, even if another + route with the same pattern already exists. The returned + @ref fluent_route reference allows method-specific handler + registration (such as GET or POST) or catch-all handlers + with @ref fluent_route::all. + + @param pattern The path expression to match against request + targets. This may include parameters or wildcards following + the router's pattern syntax. May not be empty. + + @return A fluent route interface for chaining handler + registrations. + */ + auto + route( + std::string_view pattern) -> fluent_route + { + 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->set_options_handler_impl( + std::make_unique>( + std::forward(h))); + } }; -/** The default router type using @ref route_params. -*/ -using router = basic_router; +template +class router:: + fluent_route +{ +public: + fluent_route(fluent_route const&) = default; + + /** Add handlers that apply to all HTTP methods. + + This registers regular handlers that run for any request matching + the route's pattern, regardless of HTTP method. Handlers are + appended to the route's handler sequence and are invoked in + registration order whenever a preceding handler returns + @ref route_next. Error handlers and routers cannot be passed here. + + This function returns a @ref fluent_route, allowing additional + method registrations to be chained. For example: + @code + r.route( "/resource" ) + .all( log_request ) + .add( method::get, show_resource ) + .add( method::post, update_resource ); + @endcode + + @param h1 The first handler to add. + + @param hn Additional handlers to add, invoked after @p h1 in + registration order. + + @return A reference to `*this` for chained registrations. + */ + template + auto all( + H1&& h1, HN&&... hn) -> + fluent_route + { + static_assert(handler_check<1, H1, HN...>); + owner_.add_to_route(route_idx_, std::string_view{}, + owner_.make_handlers(owner_.ht_, + std::forward

(h1), std::forward(hn)...)); + return *this; + } + + /** Add handlers for a specific HTTP method. + + This registers regular handlers for the given method on the + current route, participating in dispatch as described in the + @ref router class documentation. Handlers are appended + to the route's handler sequence and invoked in registration + order whenever a preceding handler returns @ref route_next. + Error handlers and routers cannot be passed here. + + @param verb The HTTP method to match. + + @param h1 The first handler to add. + + @param hn Additional handlers to add, invoked after @p h1 in + registration order. + + @return A reference to `*this` for chained registrations. + */ + template + auto add( + http::method verb, + H1&& h1, HN&&... hn) -> + fluent_route + { + static_assert(handler_check<1, H1, HN...>); + owner_.add_to_route(route_idx_, verb, + owner_.make_handlers(owner_.ht_, + std::forward

(h1), std::forward(hn)...)); + return *this; + } + + /** Add handlers for a method string. + + This registers regular handlers for the given HTTP method string + on the current route, participating in dispatch as described in + the @ref router class documentation. This overload is + intended for methods not represented by @ref http::method. + Handlers are appended to the route's handler sequence and invoked + in registration order whenever a preceding handler returns + @ref route_next. Error handlers and routers cannot be passed here. + + @param verb The HTTP method string to match. + + @param h1 The first handler to add. + + @param hn Additional handlers to add, invoked after @p h1 in + registration order. + + @return A reference to `*this` for chained registrations. + */ + template + auto add( + std::string_view verb, + H1&& h1, HN&&... hn) -> + fluent_route + { + static_assert(handler_check<1, H1, HN...>); + owner_.add_to_route(route_idx_, verb, + owner_.make_handlers(owner_.ht_, + std::forward

(h1), std::forward(hn)...)); + return *this; + } + +private: + friend class router; + fluent_route( + router& owner, + std::string_view pattern) + : route_idx_(owner.new_route(pattern)) + , owner_(owner) + { + } + + std::size_t route_idx_; + router& owner_; +}; } // http } // boost diff --git a/include/boost/http/server/send_file.hpp b/include/boost/http/server/send_file.hpp index 215bb968..1177909f 100644 --- a/include/boost/http/server/send_file.hpp +++ b/include/boost/http/server/send_file.hpp @@ -1,5 +1,5 @@ // -// Copyright (c) 2025 Vinnie Falco (vinnie dot falco at gmail dot com) +// 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 http://www.boost.org/LICENSE_1_0.txt) @@ -11,7 +11,7 @@ #define BOOST_HTTP_SERVER_SEND_FILE_HPP #include -#include +#include #include #include #include diff --git a/include/boost/http/server/serve_index.hpp b/include/boost/http/server/serve_index.hpp new file mode 100644 index 00000000..a440ac14 --- /dev/null +++ b/include/boost/http/server/serve_index.hpp @@ -0,0 +1,99 @@ +// +// 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 http://www.boost.org/LICENSE_1_0.txt) +// +// Official repository: https://github.com/cppalliance/http +// + +#ifndef BOOST_HTTP_SERVER_SERVE_INDEX_HPP +#define BOOST_HTTP_SERVER_SERVE_INDEX_HPP + +#include +#include + +namespace boost { +namespace http { + +/** Coroutine-based directory listing middleware. + + This middleware generates directory listings for + requests that map to filesystem directories. It + supports content negotiation, returning HTML, JSON, + or plain text responses based on the Accept header. + + This handler is intended to complement @ref serve_static. + When `serve_static` encounters a directory without an + index file, `serve_index` can provide a browsable listing. + + @par Example + @code + router r; + r.use( serve_static( root ) ); + r.use( serve_index( root ) ); + @endcode + + @see serve_static +*/ +class BOOST_HTTP_DECL serve_index +{ + struct impl; + impl* impl_; + +public: + /** Options for directory listing. + */ + struct options + { + /// Show hidden files (dotfiles). Default: false. + bool hidden = false; + + /// Show parent directory ("..") link. Default: true. + bool show_parent = true; + + /// Treat non-GET/HEAD as unhandled (true) or 405 (false). + bool fallthrough = true; + }; + + /** Destructor. + */ + ~serve_index(); + + /** Construct with document root and default options. + + @param root The document root path. + */ + explicit serve_index(core::string_view root); + + /** Construct with document root and options. + + @param root The document root path. + + @param opts Configuration options. + */ + serve_index( + core::string_view root, + options const& opts); + + /** Move constructor. + */ + serve_index(serve_index&& other) noexcept; + + /** Handle a request. + + Lists the contents of the directory matching the + request path. Uses the Accept header to choose + between HTML, JSON, and plain text responses. + + @param rp The route parameters. + + @return A task that completes with the routing result. + */ + route_task operator()(route_params& rp) const; +}; + +} // http +} // boost + +#endif diff --git a/include/boost/http/server/serve_static.hpp b/include/boost/http/server/serve_static.hpp index 00ca7b1b..6068ba1a 100644 --- a/include/boost/http/server/serve_static.hpp +++ b/include/boost/http/server/serve_static.hpp @@ -1,5 +1,5 @@ // -// Copyright (c) 2025 Vinnie Falco (vinnie dot falco at gmail dot com) +// 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 http://www.boost.org/LICENSE_1_0.txt) @@ -11,7 +11,7 @@ #define BOOST_HTTP_SERVER_SERVE_STATIC_HPP #include -#include +#include namespace boost { namespace http { diff --git a/include/boost/http/server/statuses.hpp b/include/boost/http/server/statuses.hpp index d6726356..5b956996 100644 --- a/include/boost/http/server/statuses.hpp +++ b/include/boost/http/server/statuses.hpp @@ -1,5 +1,5 @@ // -// Copyright (c) 2025 Vinnie Falco (vinnie dot falco at gmail dot com) +// 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 http://www.boost.org/LICENSE_1_0.txt) diff --git a/src/application.cpp b/src/application.cpp index 8484f8e6..32843274 100644 --- a/src/application.cpp +++ b/src/application.cpp @@ -1,5 +1,5 @@ // -// Copyright (c) 2022 Vinnie Falco (vinnie dot falco at gmail dot com) +// Copyright (c) 2022 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 http://www.boost.org/LICENSE_1_0.txt) diff --git a/src/bcrypt/crypt.hpp b/src/bcrypt/crypt.hpp index c93c9305..9d7573b6 100644 --- a/src/bcrypt/crypt.hpp +++ b/src/bcrypt/crypt.hpp @@ -10,7 +10,7 @@ #ifndef BOOST_HTTP_SRC_BCRYPT_CRYPT_HPP #define BOOST_HTTP_SRC_BCRYPT_CRYPT_HPP -#include +#include #include #include diff --git a/src/bcrypt/error.cpp b/src/bcrypt/error.cpp index 91be41aa..66e26a51 100644 --- a/src/bcrypt/error.cpp +++ b/src/bcrypt/error.cpp @@ -7,7 +7,7 @@ // Official repository: https://github.com/cppalliance/http // -#include +#include namespace boost { namespace http { diff --git a/src/bcrypt/hash.cpp b/src/bcrypt/hash.cpp index 2357416d..bd4f4a76 100644 --- a/src/bcrypt/hash.cpp +++ b/src/bcrypt/hash.cpp @@ -7,7 +7,7 @@ // Official repository: https://github.com/cppalliance/http // -#include +#include #include #include "base64.hpp" #include "crypt.hpp" diff --git a/src/config.cpp b/src/config.cpp index 17e120f3..cc789176 100644 --- a/src/config.cpp +++ b/src/config.cpp @@ -1,5 +1,5 @@ // -// Copyright (c) 2026 Vinnie Falco (vinnie dot falco at gmail dot com) +// Copyright (c) 2026 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 http://www.boost.org/LICENSE_1_0.txt) @@ -38,7 +38,6 @@ make_parser_config(parser_config cfg) fb flat_dynamic_buffer headers.max_size cb0 circular_buffer min_buffer cb1 circular_buffer min_buffer - T body max_type_erase f table max_table_space */ @@ -49,9 +48,6 @@ make_parser_config(parser_config cfg) // cb0_, cb1_ space_needed += impl->min_buffer + impl->min_buffer; - // T - space_needed += impl->max_type_erase; - // round up to alignof(detail::header::entry) auto const al = alignof(detail::header::entry); space_needed = al * ((space_needed + al - 1) / al); @@ -70,7 +66,6 @@ make_serializer_config(serializer_config cfg) std::size_t space_needed = 0; space_needed += impl->payload_buffer; - space_needed += impl->max_type_erase; impl->space_needed = space_needed; diff --git a/src/core/polystore.cpp b/src/core/polystore.cpp index a0754e87..e5a50cd4 100644 --- a/src/core/polystore.cpp +++ b/src/core/polystore.cpp @@ -1,5 +1,5 @@ // -// Copyright (c) 2025 Vinnie Falco (vinnie dot falco at gmail dot com) +// 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 http://www.boost.org/LICENSE_1_0.txt) diff --git a/src/datastore.cpp b/src/datastore.cpp index 58cc2c03..03e80eb8 100644 --- a/src/datastore.cpp +++ b/src/datastore.cpp @@ -1,5 +1,5 @@ // -// Copyright (c) 2025 Vinnie Falco (vinnie dot falco at gmail dot com) +// 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 http://www.boost.org/LICENSE_1_0.txt) diff --git a/src/json/json_body.cpp b/src/json/json_body.cpp new file mode 100644 index 00000000..621c34d9 --- /dev/null +++ b/src/json/json_body.cpp @@ -0,0 +1,50 @@ +// +// 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 http://www.boost.org/LICENSE_1_0.txt) +// +// Official repository: https://github.com/cppalliance/http +// + +#include +#include +#include +#include +#include +#include + +namespace boost { +namespace http { + +json_body:: +json_body(json_body_options options) noexcept + : options_(std::move(options)) +{ +} + +route_task +json_body:: +operator()(route_params& p) const +{ + if( ! p.is_method(method::post) || + ! p.req.value_or( + field::content_type, "") + .starts_with("application/json")) + co_return route_next; + + json_sink sink( + options_.storage, options_.parse_opts); + auto [ec, n] = co_await capy::push_to( + p.req_body, sink); + if(ec) + co_return route_error(ec); + + p.route_data.emplace( + sink.release()); + + co_return route_next; +} + +} // namespace http +} // namespace boost diff --git a/src/serializer.cpp b/src/serializer.cpp index 0f99da1f..2f429350 100644 --- a/src/serializer.cpp +++ b/src/serializer.cpp @@ -239,7 +239,6 @@ class serializer::impl enum class style { empty, - buffers, stream }; @@ -247,7 +246,6 @@ class serializer::impl detail::workspace ws_; std::unique_ptr filter_; - cbs_gen* cbs_gen_ = nullptr; capy::circular_dynamic_buffer out_; capy::circular_dynamic_buffer in_; @@ -318,40 +316,6 @@ class serializer::impl case style::empty: break; - case style::buffers: - { - // add more buffers if prepped_ is half empty. - if(more_input_ && - prepped_.capacity() >= prepped_.size()) - { - prepped_.slide_to_front(); - while(prepped_.capacity() != 0) - { - auto buf = cbs_gen_->next(); - if(buf.size() == 0) - break; - prepped_.append(buf); - } - if(cbs_gen_->is_empty()) - { - if(is_chunked_) - { - if(prepped_.capacity() != 0) - { - prepped_.append( - crlf_and_final_chunk); - more_input_ = false; - } - } - else - { - more_input_ = false; - } - } - } - return detail::make_span(prepped_); - } - case style::stream: if(out_.size() == 0 && is_header_done() && more_input_) BOOST_HTTP_RETURN_EC( @@ -391,44 +355,6 @@ class serializer::impl break; } - case style::buffers: - { - while(out_capacity() != 0 && !filter_done_) - { - if(more_input_ && tmp_.size() == 0) - { - tmp_ = cbs_gen_->next(); - if(tmp_.size() == 0) // cbs_gen_ is empty - more_input_ = false; - } - - const auto rs = filter_->process( - detail::make_span(out_prepare()), - {{ {tmp_}, {} }}, - more_input_); - - if(rs.ec) - { - ws_.clear(); - state_ = state::reset; - return rs.ec; - } - - capy::remove_prefix(tmp_, rs.in_bytes); - out_commit(rs.out_bytes); - - if(rs.out_short) - break; - - if(rs.finished) - { - filter_done_ = true; - out_finish(); - } - } - break; - } - case style::stream: { if(out_capacity() == 0 || filter_done_) @@ -610,60 +536,32 @@ class serializer::impl } void - start_buffers( - message_base const& m, - cbs_gen& cbs_gen) + start_stream(message_base const& m) { - // start_init() already called - style_ = style::buffers; - cbs_gen_ = &cbs_gen; - - if(!filter_) - { - auto stats = cbs_gen_->stats(); - auto batch_size = clamp(stats.count, 16); - - prepped_ = make_array( - 1 + // header - batch_size + // buffers - (is_chunked_ ? 2 : 0)); // chunk header + final chunk - - prepped_.append({ m.h_.cbuf, m.h_.size }); - more_input_ = (batch_size != 0); - - if(is_chunked_) - { - if(!more_input_) - { - prepped_.append(final_chunk); - } - else - { - auto h_len = chunk_header_len(stats.size); - capy::mutable_buffer mb( - ws_.reserve_front(h_len), h_len); - write_chunk_header({{ {mb}, {} }}, stats.size); - prepped_.append(mb); - } - } - return; - } - - // filter + start_init(m); + style_ = style::stream; prepped_ = make_array( 1 + // header 2); // out buffer pairs + if(filter_) + { + // TODO: smarter buffer distribution + auto const n = (ws_.size() - 1) / 2; + in_ = { ws_.reserve_front(n), n }; + } + out_init(); prepped_.append({ m.h_.cbuf, m.h_.size }); - tmp_ = {}; more_input_ = true; } + // Like start_stream but without in_ allocation. + // Entire workspace is used for output buffering. void - start_stream(message_base const& m) + start_buffers_direct(message_base const& m) { start_init(m); style_ = style::stream; @@ -672,13 +570,6 @@ class serializer::impl 1 + // header 2); // out buffer pairs - if(filter_) - { - // TODO: smarter buffer distribution - auto const n = (ws_.size() - 1) / 2; - in_ = { ws_.reserve_front(n), n }; - } - out_init(); prepped_.append({ m.h_.cbuf, m.h_.size }); @@ -734,6 +625,12 @@ class serializer::impl return state_ == state::start; } + bool + is_start() const noexcept + { + return state_ == state::start; + } + detail::workspace& ws() noexcept { @@ -869,14 +766,6 @@ serializer( { } -serializer:: -serializer( - std::shared_ptr cfg, - message_base const& m) - : impl_(new impl(std::move(cfg), m)) -{ -} - void serializer:: reset() noexcept @@ -895,37 +784,38 @@ set_message(message_base const& m) noexcept void serializer:: -start(message_base const& m) +start() { - BOOST_ASSERT(impl_); - impl_->start_empty(m); + if(!impl_ || !impl_->msg_) + detail::throw_logic_error(); + impl_->start_empty(*impl_->msg_); } void serializer:: -start() +start_stream() { if(!impl_ || !impl_->msg_) detail::throw_logic_error(); - impl_->start_empty(*impl_->msg_); + impl_->start_stream(*impl_->msg_); } void serializer:: -start_stream( - message_base const& m) +start_writes() { - BOOST_ASSERT(impl_); - impl_->start_stream(m); + if(!impl_ || !impl_->msg_) + detail::throw_logic_error(); + impl_->start_stream(*impl_->msg_); } void serializer:: -start_stream() +start_buffers() { if(!impl_ || !impl_->msg_) detail::throw_logic_error(); - impl_->start_stream(*impl_->msg_); + impl_->start_buffers_direct(*impl_->msg_); } auto @@ -953,32 +843,22 @@ is_done() const noexcept return impl_->is_done(); } -//------------------------------------------------ - -detail::workspace& +bool serializer:: -ws() +is_start() const noexcept { BOOST_ASSERT(impl_); - return impl_->ws(); + return impl_->is_start(); } -void -serializer:: -start_init(message_base const& m) -{ - BOOST_ASSERT(impl_); - impl_->start_init(m); -} +//------------------------------------------------ -void +detail::workspace& serializer:: -start_buffers( - message_base const& m, - cbs_gen& cbs_gen) +ws() { BOOST_ASSERT(impl_); - impl_->start_buffers(m, cbs_gen); + return impl_->ws(); } //------------------------------------------------ diff --git a/src/server/accepts.cpp b/src/server/accepts.cpp new file mode 100644 index 00000000..608c4891 --- /dev/null +++ b/src/server/accepts.cpp @@ -0,0 +1,624 @@ +// +// 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 http://www.boost.org/LICENSE_1_0.txt) +// +// Official repository: https://github.com/cppalliance/http +// + +#include +#include +#include +#include + +namespace boost { +namespace http { + +namespace { + +//---------------------------------------------------------- +// Helpers +//---------------------------------------------------------- + +std::string_view +trim_ows( std::string_view s ) noexcept +{ + while( ! s.empty() && + ( s.front() == ' ' || s.front() == '\t' ) ) + s.remove_prefix( 1 ); + while( ! s.empty() && + ( s.back() == ' ' || s.back() == '\t' ) ) + s.remove_suffix( 1 ); + return s; +} + +bool +iequals( + std::string_view a, + std::string_view b ) noexcept +{ + if( a.size() != b.size() ) + return false; + for( std::size_t i = 0; i < a.size(); ++i ) + { + unsigned char ca = a[i]; + unsigned char cb = b[i]; + if( ca >= 'A' && ca <= 'Z' ) + ca += 32; + if( cb >= 'A' && cb <= 'Z' ) + cb += 32; + if( ca != cb ) + return false; + } + return true; +} + +// Returns quality as integer 0-1000 +int +parse_q( std::string_view s ) noexcept +{ + s = trim_ows( s ); + if( s.empty() ) + return 1000; + if( s[0] == '1' ) + return 1000; + if( s[0] != '0' ) + return 0; + if( s.size() < 2 || s[1] != '.' ) + return 0; + int result = 0; + int mult = 100; + for( std::size_t i = 2; + i < s.size() && i < 5; ++i ) + { + if( s[i] < '0' || s[i] > '9' ) + break; + result += ( s[i] - '0' ) * mult; + mult /= 10; + } + return result; +} + +// Extract q-value from parameters after first semicolon +int +extract_q( std::string_view params ) noexcept +{ + while( ! params.empty() ) + { + auto semi = params.find( ';' ); + auto param = trim_ows( + semi != std::string_view::npos + ? params.substr( 0, semi ) + : params ); + if( param.size() >= 2 && + ( param[0] == 'q' || param[0] == 'Q' ) && + param[1] == '=' ) + { + return parse_q( param.substr( 2 ) ); + } + if( semi != std::string_view::npos ) + params.remove_prefix( semi + 1 ); + else + break; + } + return 1000; +} + +//---------------------------------------------------------- +// Negotiation priority +//---------------------------------------------------------- + +struct priority +{ + int q; + int specificity; + int order; +}; + +bool +is_better( + priority const& a, + priority const& b ) noexcept +{ + if( a.q != b.q ) + return a.q > b.q; + if( a.specificity != b.specificity ) + return a.specificity > b.specificity; + return a.order < b.order; +} + +//---------------------------------------------------------- +// Media type parsing (Accept header) +//---------------------------------------------------------- + +struct media_range +{ + std::string_view type; + std::string_view subtype; + std::string_view full; + int q; + int order; +}; + +std::vector +parse_accept( std::string_view header ) +{ + std::vector result; + int order = 0; + + while( ! header.empty() ) + { + auto comma = header.find( ',' ); + auto entry = ( comma != std::string_view::npos ) + ? header.substr( 0, comma ) + : header; + if( comma != std::string_view::npos ) + header.remove_prefix( comma + 1 ); + else + header = {}; + + entry = trim_ows( entry ); + if( entry.empty() ) + continue; + + auto semi = entry.find( ';' ); + auto mime_part = trim_ows( + semi != std::string_view::npos + ? entry.substr( 0, semi ) + : entry ); + + auto slash = mime_part.find( '/' ); + if( slash == std::string_view::npos ) + continue; + + media_range mr; + mr.type = mime_part.substr( 0, slash ); + mr.subtype = mime_part.substr( slash + 1 ); + mr.full = mime_part; + mr.q = ( semi != std::string_view::npos ) + ? extract_q( entry.substr( semi + 1 ) ) + : 1000; + mr.order = order++; + result.push_back( mr ); + } + + return result; +} + +// Returns specificity (0-6) or -1 for no match +int +match_media( + media_range const& range, + std::string_view type, + std::string_view subtype ) noexcept +{ + int s = 0; + + if( range.type == "*" ) + { + // wildcard type + } + else if( iequals( range.type, type ) ) + { + s |= 4; + } + else + { + return -1; + } + + if( range.subtype == "*" ) + { + // wildcard subtype + } + else if( iequals( range.subtype, subtype ) ) + { + s |= 2; + } + else + { + return -1; + } + + return s; +} + +//---------------------------------------------------------- +// Simple token parsing (Accept-Encoding/Charset/Language) +//---------------------------------------------------------- + +struct simple_entry +{ + std::string_view value; + int q; + int order; +}; + +std::vector +parse_simple( std::string_view header ) +{ + std::vector result; + int order = 0; + + while( ! header.empty() ) + { + auto comma = header.find( ',' ); + auto entry = ( comma != std::string_view::npos ) + ? header.substr( 0, comma ) + : header; + if( comma != std::string_view::npos ) + header.remove_prefix( comma + 1 ); + else + header = {}; + + entry = trim_ows( entry ); + if( entry.empty() ) + continue; + + auto semi = entry.find( ';' ); + auto value = trim_ows( + semi != std::string_view::npos + ? entry.substr( 0, semi ) + : entry ); + if( value.empty() ) + continue; + + simple_entry se; + se.value = value; + se.q = ( semi != std::string_view::npos ) + ? extract_q( entry.substr( semi + 1 ) ) + : 1000; + se.order = order++; + result.push_back( se ); + } + + return result; +} + +//---------------------------------------------------------- +// Matching helpers +//---------------------------------------------------------- + +// Exact or wildcard match (encoding, charset) +int +match_exact( + std::string_view spec, + std::string_view offered ) noexcept +{ + if( iequals( spec, offered ) ) + return 1; + if( spec == "*" ) + return 0; + return -1; +} + +// Language prefix: "en-US" -> "en" +std::string_view +lang_prefix( std::string_view tag ) noexcept +{ + auto dash = tag.find( '-' ); + if( dash != std::string_view::npos ) + return tag.substr( 0, dash ); + return tag; +} + +// Language match with prefix support +int +match_language( + std::string_view spec, + std::string_view offered ) noexcept +{ + if( iequals( spec, offered ) ) + return 4; + if( iequals( lang_prefix( spec ), offered ) ) + return 2; + if( iequals( spec, lang_prefix( offered ) ) ) + return 1; + if( spec == "*" ) + return 0; + return -1; +} + +//---------------------------------------------------------- +// Generic negotiation for simple headers +//---------------------------------------------------------- + +template< class MatchFn > +std::string_view +negotiate( + std::vector const& entries, + std::initializer_list offered, + MatchFn match ) +{ + std::string_view best_val; + priority best_pri{ -1, -1, 0 }; + bool found = false; + + for( auto const& o : offered ) + { + priority pri{ -1, -1, 0 }; + bool matched = false; + + for( auto const& e : entries ) + { + if( e.q <= 0 ) + continue; + auto s = match( e.value, o ); + if( s < 0 ) + continue; + priority p{ e.q, s, e.order }; + if( ! matched || + p.specificity > pri.specificity || + ( p.specificity == pri.specificity && + p.q > pri.q ) || + ( p.specificity == pri.specificity && + p.q == pri.q && + p.order < pri.order ) ) + { + pri = p; + matched = true; + } + } + + if( ! matched || pri.q <= 0 ) + continue; + + if( ! found || is_better( pri, best_pri ) ) + { + best_val = o; + best_pri = pri; + found = true; + } + } + + return found ? best_val : std::string_view{}; +} + +// Return sorted values from simple entries +std::vector +sorted_values( + std::vector& entries ) +{ + std::sort( entries.begin(), entries.end(), + []( simple_entry const& a, + simple_entry const& b ) + { + if( a.q != b.q ) + return a.q > b.q; + return a.order < b.order; + }); + + std::vector result; + result.reserve( entries.size() ); + for( auto const& e : entries ) + { + if( e.q <= 0 ) + continue; + result.push_back( e.value ); + } + return result; +} + +} // (anon) + +//---------------------------------------------------------- + +accepts::accepts( + fields_base const& fields ) noexcept + : fields_( fields ) +{ +} + +std::string_view +accepts::type( + std::initializer_list< + std::string_view> offered ) const +{ + if( offered.size() == 0 ) + return {}; + + auto accept = fields_.value_or( + field::accept, "" ); + + if( accept.empty() ) + return *offered.begin(); + + auto ranges = parse_accept( accept ); + if( ranges.empty() ) + return *offered.begin(); + + std::string_view best_val; + priority best_pri{ -1, -1, 0 }; + bool found = false; + + for( auto const& o : offered ) + { + // Convert extension to MIME if needed + std::string_view mime_str = o; + if( o.find( '/' ) == std::string_view::npos ) + { + auto looked = mime_types::lookup( o ); + if( ! looked.empty() ) + mime_str = looked; + else + continue; + } + + auto slash = mime_str.find( '/' ); + if( slash == std::string_view::npos ) + continue; + + auto type = mime_str.substr( 0, slash ); + auto subtype = mime_str.substr( slash + 1 ); + + // Find best matching range for this type + priority pri{ -1, -1, 0 }; + bool matched = false; + + for( auto const& r : ranges ) + { + if( r.q <= 0 ) + continue; + auto s = match_media( r, type, subtype ); + if( s < 0 ) + continue; + priority p{ r.q, s, r.order }; + if( ! matched || + p.specificity > pri.specificity || + ( p.specificity == pri.specificity && + p.q > pri.q ) || + ( p.specificity == pri.specificity && + p.q == pri.q && + p.order < pri.order ) ) + { + pri = p; + matched = true; + } + } + + if( ! matched || pri.q <= 0 ) + continue; + + if( ! found || is_better( pri, best_pri ) ) + { + best_val = o; + best_pri = pri; + found = true; + } + } + + return found ? best_val : std::string_view{}; +} + +std::vector +accepts::types() const +{ + auto accept = fields_.value_or( + field::accept, "" ); + if( accept.empty() ) + return {}; + + auto ranges = parse_accept( accept ); + + std::sort( ranges.begin(), ranges.end(), + []( media_range const& a, + media_range const& b ) + { + if( a.q != b.q ) + return a.q > b.q; + return a.order < b.order; + }); + + std::vector result; + result.reserve( ranges.size() ); + for( auto const& r : ranges ) + { + if( r.q <= 0 ) + continue; + result.push_back( r.full ); + } + return result; +} + +std::string_view +accepts::encoding( + std::initializer_list< + std::string_view> offered ) const +{ + if( offered.size() == 0 ) + return {}; + + auto header = fields_.value_or( + field::accept_encoding, "" ); + + if( header.empty() ) + return *offered.begin(); + + auto entries = parse_simple( header ); + if( entries.empty() ) + return *offered.begin(); + + return negotiate( entries, offered, match_exact ); +} + +std::vector +accepts::encodings() const +{ + auto header = fields_.value_or( + field::accept_encoding, "" ); + if( header.empty() ) + return {}; + + auto entries = parse_simple( header ); + return sorted_values( entries ); +} + +std::string_view +accepts::charset( + std::initializer_list< + std::string_view> offered ) const +{ + if( offered.size() == 0 ) + return {}; + + auto header = fields_.value_or( + field::accept_charset, "" ); + + if( header.empty() ) + return *offered.begin(); + + auto entries = parse_simple( header ); + if( entries.empty() ) + return *offered.begin(); + + return negotiate( entries, offered, match_exact ); +} + +std::vector +accepts::charsets() const +{ + auto header = fields_.value_or( + field::accept_charset, "" ); + if( header.empty() ) + return {}; + + auto entries = parse_simple( header ); + return sorted_values( entries ); +} + +std::string_view +accepts::language( + std::initializer_list< + std::string_view> offered ) const +{ + if( offered.size() == 0 ) + return {}; + + auto header = fields_.value_or( + field::accept_language, "" ); + + if( header.empty() ) + return *offered.begin(); + + auto entries = parse_simple( header ); + if( entries.empty() ) + return *offered.begin(); + + return negotiate( entries, offered, match_language ); +} + +std::vector +accepts::languages() const +{ + auto header = fields_.value_or( + field::accept_language, "" ); + if( header.empty() ) + return {}; + + auto entries = parse_simple( header ); + return sorted_values( entries ); +} + +} // http +} // boost diff --git a/src/server/any_router.cpp b/src/server/any_router.cpp new file mode 100644 index 00000000..32d320df --- /dev/null +++ b/src/server/any_router.cpp @@ -0,0 +1,705 @@ +// +// 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 http://www.boost.org/LICENSE_1_0.txt) +// +// Official repository: https://github.com/cppalliance/http +// + +#include "src/server/detail/any_router.hpp" +#include +#include +#include +#include +#include +#include "src/server/detail/pct_decode.hpp" + +#include + +namespace boost { +namespace http { +namespace detail { + +//------------------------------------------------ +// +// impl helpers +// +//------------------------------------------------ + +std::string +router_base::impl:: +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; + 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; + } + } + for(auto const& v : custom) + { + if(!result.empty()) + result += ", "; + result += v; + } + return result; +} + +router_base::opt_flags +router_base::impl:: +compute_effective_opts( + opt_flags parent, + opt_flags child) +{ + opt_flags result = parent; + + // case_sensitive: bits 1-2 (2=true, 4=false) + if(child & 2) + result = (result & ~6) | 2; + else if(child & 4) + result = (result & ~6) | 4; + + // strict: bits 3-4 (8=true, 16=false) + if(child & 8) + result = (result & ~24) | 8; + else if(child & 16) + result = (result & ~24) | 16; + + return result; +} + +void +router_base::impl:: +restore_path( + route_params& p, + std::size_t base_len) +{ + auto& pv = *route_params_access{p}; + p.base_path = { pv.decoded_path_.data(), base_len }; + auto const path_len = pv.decoded_path_.size() - (pv.addedSlash_ ? 1 : 0); + if(base_len < path_len) + p.path = { pv.decoded_path_.data() + base_len, + path_len - base_len }; + else + p.path = { pv.decoded_path_.data() + + pv.decoded_path_.size() - 1, 1 }; // soft slash +} + +void +router_base::impl:: +update_allow_for_entry( + matcher& m, + entry const& e) +{ + if(!m.end_) + return; + + // 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); + + // Rebuild per-matcher Allow header eagerly + m.allow_header_ = build_allow_header( + m.allowed_methods_, m.custom_verbs_); + + // 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); +} + +void +router_base::impl:: +rebuild_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 +router_base::impl:: +finalize_pending() +{ + if(pending_route_ == SIZE_MAX) + return; + auto& m = matchers[pending_route_]; + if(entries.size() == m.first_entry_) + { + // empty route, remove it + matchers.pop_back(); + } + else + { + m.skip_ = entries.size(); + } + pending_route_ = SIZE_MAX; +} + +//------------------------------------------------ +// +// dispatch +// +//------------------------------------------------ + +route_task +router_base::impl:: +dispatch_loop(route_params& p, bool is_options) const +{ + auto& pv = *route_params_access{p}; + + std::size_t last_matched = SIZE_MAX; + std::uint32_t current_depth = 0; + + std::uint64_t options_methods = 0; + std::vector options_custom_verbs; + + std::size_t path_stack[router_base::max_path_depth]; + path_stack[0] = 0; + + std::size_t matched_at_depth[router_base::max_path_depth]; + for(std::size_t d = 0; d < router_base::max_path_depth; ++d) + matched_at_depth[d] = SIZE_MAX; + + for(std::size_t i = 0; i < entries.size(); ) + { + auto const& e = entries[i]; + auto const& m = matchers[e.matcher_idx]; + auto const target_depth = m.depth_; + + bool ancestors_ok = true; + + std::size_t start_idx = (last_matched == SIZE_MAX) ? 0 : last_matched + 1; + + for(std::size_t check_idx = start_idx; + check_idx <= e.matcher_idx && ancestors_ok; + ++check_idx) + { + auto const& cm = matchers[check_idx]; + + bool is_needed_ancestor = (cm.depth_ < target_depth) && + (matched_at_depth[cm.depth_] == SIZE_MAX); + bool is_self = (check_idx == e.matcher_idx); + + if(!is_needed_ancestor && !is_self) + continue; + + if(cm.depth_ <= current_depth && current_depth > 0) + { + restore_path(p, path_stack[cm.depth_]); + } + + if(cm.end_ && pv.kind_ != router_base::is_plain) + { + i = cm.skip_; + ancestors_ok = false; + break; + } + + pv.case_sensitive = (cm.effective_opts_ & 2) != 0; + pv.strict = (cm.effective_opts_ & 8) != 0; + + if(cm.depth_ < router_base::max_path_depth) + path_stack[cm.depth_] = p.base_path.size(); + + match_result mr; + if(!cm(p, mr)) + { + for(std::size_t d = cm.depth_; d < router_base::max_path_depth; ++d) + matched_at_depth[d] = SIZE_MAX; + i = cm.skip_; + ancestors_ok = false; + break; + } + + if(!mr.params_.empty()) + { + for(auto& param : mr.params_) + p.params.push_back(std::move(param)); + } + + if(cm.depth_ < router_base::max_path_depth) + matched_at_depth[cm.depth_] = check_idx; + + last_matched = check_idx; + current_depth = cm.depth_ + 1; + + if(current_depth < router_base::max_path_depth) + path_stack[current_depth] = p.base_path.size(); + } + + 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); + } + + if(m.end_ && !e.match_method( + const_cast(p))) + { + ++i; + continue; + } + + if(e.h->kind != pv.kind_) + { + ++i; + continue; + } + + //-------------------------------------------------- + // Invoke handler + //-------------------------------------------------- + + route_result rv; + try + { + rv = co_await e.h->invoke( + const_cast(p)); + } + catch(...) + { + pv.ep_ = std::current_exception(); + pv.kind_ = router_base::is_exception; + ++i; + continue; + } + + if(rv.what() == route_what::next) + { + ++i; + continue; + } + + if(rv.what() == route_what::next_route) + { + if(!m.end_) + co_return route_error(error::invalid_route_result); + i = m.skip_; + continue; + } + + if(rv.what() == route_what::done || + rv.what() == route_what::close) + { + co_return rv; + } + + // Error - transition to error mode + pv.ec_ = rv.error(); + pv.kind_ = router_base::is_error; + + if(m.end_) + { + i = m.skip_; + continue; + } + + ++i; + } + + if(pv.kind_ == router_base::is_exception) + co_return route_error(error::unhandled_exception); + if(pv.kind_ == router_base::is_error) + co_return route_error(pv.ec_); + + // OPTIONS fallback + if(is_options && options_methods != 0 && options_handler_) + { + std::string allow = build_allow_header(options_methods, options_custom_verbs); + co_return co_await options_handler_->invoke(p, allow); + } + + co_return route_next; +} + +//------------------------------------------------ +// +// router_base +// +//------------------------------------------------ + +router_base:: +router_base( + opt_flags opt) + : impl_(std::make_shared(opt)) +{ +} + +void +router_base:: +add_middleware( + std::string_view pattern, + handlers hn) +{ + impl_->finalize_pending(); + + if(pattern.empty()) + pattern = "/"; + + auto const matcher_idx = impl_->matchers.size(); + impl_->matchers.emplace_back(pattern, false); + auto& m = impl_->matchers.back(); + if(m.error()) + throw_invalid_argument(); + m.first_entry_ = impl_->entries.size(); + m.effective_opts_ = impl::compute_effective_opts(0, impl_->opt_); + m.own_opts_ = impl_->opt_; + m.depth_ = 0; + + for(std::size_t i = 0; i < hn.n; ++i) + { + impl_->entries.emplace_back(std::move(hn.p[i])); + impl_->entries.back().matcher_idx = matcher_idx; + } + + m.skip_ = impl_->entries.size(); +} + +void +router_base:: +inline_router( + std::string_view pattern, + router_base&& sub) +{ + impl_->finalize_pending(); + + if(!sub.impl_) + return; + + sub.impl_->finalize_pending(); + + if(pattern.empty()) + pattern = "/"; + + // Create parent matcher for the mount point + auto const parent_matcher_idx = impl_->matchers.size(); + impl_->matchers.emplace_back(pattern, false); + auto& parent_m = impl_->matchers.back(); + if(parent_m.error()) + throw_invalid_argument(); + parent_m.first_entry_ = impl_->entries.size(); + + auto parent_eff = impl::compute_effective_opts(0, impl_->opt_); + parent_m.effective_opts_ = parent_eff; + parent_m.own_opts_ = impl_->opt_; + parent_m.depth_ = 0; + + // Check nesting depth + std::size_t max_sub_depth = 0; + for(auto const& sm : sub.impl_->matchers) + max_sub_depth = (std::max)(max_sub_depth, + static_cast(sm.depth_)); + if(max_sub_depth + 1 >= max_path_depth) + throw_length_error( + "router nesting depth exceeds max_path_depth"); + + // Compute offsets for re-indexing + auto const matcher_offset = impl_->matchers.size(); + auto const entry_offset = impl_->entries.size(); + + // Recompute effective_opts for inlined matchers using depth stack + auto sub_root_eff = impl::compute_effective_opts( + parent_eff, sub.impl_->opt_); + opt_flags eff_stack[max_path_depth]; + eff_stack[0] = sub_root_eff; + + // Inline sub's matchers + for(auto& sm : sub.impl_->matchers) + { + auto d = sm.depth_; + opt_flags parent = (d > 0) ? eff_stack[d - 1] : parent_eff; + eff_stack[d] = impl::compute_effective_opts(parent, sm.own_opts_); + sm.effective_opts_ = eff_stack[d]; + sm.depth_ += 1; // increase by 1 (parent is at depth 0) + sm.first_entry_ += entry_offset; + sm.skip_ += entry_offset; + impl_->matchers.push_back(std::move(sm)); + } + + // Inline sub's entries + for(auto& se : sub.impl_->entries) + { + se.matcher_idx += matcher_offset; + impl_->entries.push_back(std::move(se)); + } + + // Set parent matcher's skip + // Need to re-fetch since vector may have reallocated + impl_->matchers[parent_matcher_idx].skip_ = impl_->entries.size(); + + // Merge global methods + impl_->global_methods_ |= sub.impl_->global_methods_; + for(auto& v : sub.impl_->global_custom_verbs_) + impl_->global_custom_verbs_.push_back(std::move(v)); + impl_->rebuild_global_allow_header(); + + // Move options handler if sub has one and parent doesn't + if(sub.impl_->options_handler_ && !impl_->options_handler_) + impl_->options_handler_ = std::move(sub.impl_->options_handler_); + + sub.impl_.reset(); +} + +std::size_t +router_base:: +new_route( + std::string_view pattern) +{ + impl_->finalize_pending(); + + if(pattern.empty()) + throw_invalid_argument(); + + auto const idx = impl_->matchers.size(); + impl_->matchers.emplace_back(pattern, true); + auto& m = impl_->matchers.back(); + if(m.error()) + throw_invalid_argument(); + m.first_entry_ = impl_->entries.size(); + m.effective_opts_ = impl::compute_effective_opts(0, impl_->opt_); + m.own_opts_ = impl_->opt_; + m.depth_ = 0; + + impl_->pending_route_ = idx; + return idx; +} + +void +router_base:: +add_to_route( + std::size_t idx, + http::method verb, + handlers hn) +{ + if(verb == http::method::unknown) + throw_invalid_argument(); + + auto& m = impl_->matchers[idx]; + for(std::size_t i = 0; i < hn.n; ++i) + { + impl_->entries.emplace_back(verb, std::move(hn.p[i])); + impl_->entries.back().matcher_idx = idx; + impl_->update_allow_for_entry(m, impl_->entries.back()); + } + impl_->rebuild_global_allow_header(); +} + +void +router_base:: +add_to_route( + std::size_t idx, + std::string_view verb, + handlers hn) +{ + auto& m = impl_->matchers[idx]; + + if(verb.empty()) + { + // all methods + for(std::size_t i = 0; i < hn.n; ++i) + { + impl_->entries.emplace_back(std::move(hn.p[i])); + impl_->entries.back().matcher_idx = idx; + impl_->update_allow_for_entry(m, impl_->entries.back()); + } + } + else + { + // specific method string + for(std::size_t i = 0; i < hn.n; ++i) + { + impl_->entries.emplace_back(verb, std::move(hn.p[i])); + impl_->entries.back().matcher_idx = idx; + impl_->update_allow_for_entry(m, impl_->entries.back()); + } + } + impl_->rebuild_global_allow_header(); +} + +void +router_base:: +finalize_pending() +{ + if(impl_) + impl_->finalize_pending(); +} + +void +router_base:: +set_options_handler_impl( + options_handler_ptr p) +{ + impl_->options_handler_ = std::move(p); +} + +//------------------------------------------------ +// +// dispatch +// +//------------------------------------------------ + +route_task +router_base:: +dispatch( + http::method verb, + urls::url_view const& url, + route_params& p) const +{ + if(verb == http::method::unknown) + throw_invalid_argument(); + + impl_->ensure_finalized(); + + // 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_); + } + } + + // Initialize params + auto& pv = *route_params_access{p}; + pv.kind_ = is_plain; + pv.verb_ = verb; + pv.verb_str_.clear(); + pv.ec_.clear(); + pv.ep_ = nullptr; + p.params.clear(); + pv.decoded_path_ = pct_decode_path(url.encoded_path()); + if(pv.decoded_path_.empty() || pv.decoded_path_.back() != '/') + { + pv.decoded_path_.push_back('/'); + pv.addedSlash_ = true; + } + else + { + pv.addedSlash_ = false; + } + p.base_path = { pv.decoded_path_.data(), 0 }; + auto const subtract = (pv.addedSlash_ && pv.decoded_path_.size() > 1) ? 1 : 0; + p.path = { pv.decoded_path_.data(), pv.decoded_path_.size() - subtract }; + + return impl_->dispatch_loop(p, verb == http::method::options); +} + +route_task +router_base:: +dispatch( + std::string_view verb, + urls::url_view const& url, + route_params& p) const +{ + if(verb.empty()) + throw_invalid_argument(); + + impl_->ensure_finalized(); + + 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_); + } + } + + // Initialize params + auto& pv = *route_params_access{p}; + pv.kind_ = is_plain; + pv.verb_ = method; + if(pv.verb_ == http::method::unknown) + pv.verb_str_ = verb; + else + pv.verb_str_.clear(); + pv.ec_.clear(); + pv.ep_ = nullptr; + p.params.clear(); + pv.decoded_path_ = pct_decode_path(url.encoded_path()); + if(pv.decoded_path_.empty() || pv.decoded_path_.back() != '/') + { + pv.decoded_path_.push_back('/'); + pv.addedSlash_ = true; + } + else + { + pv.addedSlash_ = false; + } + p.base_path = { pv.decoded_path_.data(), 0 }; + auto const subtract = (pv.addedSlash_ && pv.decoded_path_.size() > 1) ? 1 : 0; + p.path = { pv.decoded_path_.data(), pv.decoded_path_.size() - subtract }; + + return impl_->dispatch_loop(p, is_options); +} + +} // detail +} // http +} // boost diff --git a/src/server/cors.cpp b/src/server/cors.cpp index c2be545e..c40ea115 100644 --- a/src/server/cors.cpp +++ b/src/server/cors.cpp @@ -1,5 +1,5 @@ // -// Copyright (c) 2025 Vinnie Falco (vinnie dot falco at gmail dot com) +// 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 http://www.boost.org/LICENSE_1_0.txt) diff --git a/src/server/detail/router_base.hpp b/src/server/detail/any_router.hpp similarity index 53% rename from src/server/detail/router_base.hpp rename to src/server/detail/any_router.hpp index 6bd17fce..dcb4dcc9 100644 --- a/src/server/detail/router_base.hpp +++ b/src/server/detail/any_router.hpp @@ -1,5 +1,5 @@ // -// Copyright (c) 2025 Vinnie Falco (vinnie dot falco at gmail dot com) +// 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 http://www.boost.org/LICENSE_1_0.txt) @@ -7,20 +7,18 @@ // Official repository: https://github.com/cppalliance/http // -#ifndef BOOST_HTTP_SRC_SERVER_DETAIL_ROUTER_BASE_HPP -#define BOOST_HTTP_SRC_SERVER_DETAIL_ROUTER_BASE_HPP +#ifndef BOOST_HTTP_SRC_SERVER_DETAIL_ANY_ROUTER_HPP +#define BOOST_HTTP_SRC_SERVER_DETAIL_ANY_ROUTER_HPP #include #include #include "src/server/detail/route_match.hpp" +#include namespace boost { namespace http { namespace detail { -// An entry describes a single route handler. -// This can be an end route or a middleware. -// Members ordered largest-to-smallest for optimal packing. struct router_base::entry { // ~32 bytes (SSO string) @@ -28,7 +26,7 @@ struct router_base::entry // 8 bytes each handler_ptr h; - std::size_t matcher_idx = 0; // flat_router: index into matchers vector + std::size_t matcher_idx = 0; // 4 bytes http::method verb = http::method::unknown; @@ -36,7 +34,7 @@ struct router_base::entry // 1 byte (+ 3 bytes padding) bool all; - // all + // all methods explicit entry( handler_ptr h_) noexcept : h(std::move(h_)) @@ -44,7 +42,7 @@ struct router_base::entry { } - // verb match + // known verb match entry( http::method verb_, handler_ptr h_) noexcept @@ -56,7 +54,7 @@ struct router_base::entry http::method::unknown); } - // verb match + // string verb match entry( std::string_view verb_str_, handler_ptr h_) noexcept @@ -70,9 +68,9 @@ struct router_base::entry } bool match_method( - route_params_base& rp) const noexcept + route_params& rp) const noexcept { - detail::route_params_access RP{rp}; + route_params_access RP{rp}; if(all) return true; if(verb != http::method::unknown) @@ -83,46 +81,63 @@ struct router_base::entry } }; -// A layer is a set of entries that match a route -struct router_base::layer +struct router_base::impl { - matcher match; std::vector entries; + std::vector matchers; - // middleware layer - layer( - std::string_view pat, - 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])); - } + std::size_t pending_route_ = SIZE_MAX; + mutable std::once_flag finalized_; - // route layer - explicit layer( - std::string_view pat) - : match(pat, true) - { - if(match.error()) - throw_invalid_argument(); - } -}; + options_handler_ptr options_handler_; + std::uint64_t global_methods_ = 0; + std::vector global_custom_verbs_; + std::string global_allow_header_; -struct router_base::impl -{ - std::vector layers; - opt_flags opt; + opt_flags opt_; std::size_t depth_ = 0; explicit impl( - opt_flags opt_) noexcept - : opt(opt_) + opt_flags opt) noexcept + : opt_(opt) + { + } + + void finalize_pending(); + + // Thread-safe lazy finalization for dispatch + void ensure_finalized() const { + std::call_once(finalized_, [this]() { + const_cast(this)->finalize_pending(); + }); } + + void update_allow_for_entry( + matcher& m, + entry const& e); + + void rebuild_global_allow_header(); + + route_task + dispatch_loop( + route_params& p, + bool is_options) const; + + static std::string + build_allow_header( + std::uint64_t methods, + std::vector const& custom); + + static opt_flags + compute_effective_opts( + opt_flags parent, + opt_flags child); + + static void + restore_path( + route_params& p, + std::size_t base_len); }; } // detail diff --git a/src/server/detail/pct_decode.cpp b/src/server/detail/pct_decode.cpp index 33716d9e..2c8fecdd 100644 --- a/src/server/detail/pct_decode.cpp +++ b/src/server/detail/pct_decode.cpp @@ -1,5 +1,5 @@ // -// Copyright (c) 2025 Vinnie Falco (vinnie dot falco at gmail dot com) +// 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 http://www.boost.org/LICENSE_1_0.txt) diff --git a/src/server/detail/pct_decode.hpp b/src/server/detail/pct_decode.hpp index c5c1c4af..4a522e29 100644 --- a/src/server/detail/pct_decode.hpp +++ b/src/server/detail/pct_decode.hpp @@ -1,5 +1,5 @@ // -// Copyright (c) 2025 Vinnie Falco (vinnie dot falco at gmail dot com) +// 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 http://www.boost.org/LICENSE_1_0.txt) diff --git a/src/server/detail/route_match.cpp b/src/server/detail/route_match.cpp index 0b1aed29..69eb8e82 100644 --- a/src/server/detail/route_match.cpp +++ b/src/server/detail/route_match.cpp @@ -1,5 +1,5 @@ // -// Copyright (c) 2025 Vinnie Falco (vinnie dot falco at gmail dot com) +// 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 http://www.boost.org/LICENSE_1_0.txt) @@ -12,9 +12,8 @@ namespace boost { namespace http { -namespace detail { -router_base:: +detail::router_base:: matcher:: matcher( std::string_view pat, @@ -22,7 +21,7 @@ matcher( : decoded_pat_( [&pat] { - auto s = pct_decode(pat); + auto s = detail::pct_decode(pat); if( s.size() > 1 && s.back() == '/') s.pop_back(); @@ -42,10 +41,10 @@ matcher( } bool -router_base:: +detail::router_base:: matcher:: operator()( - route_params_base& p, + route_params& p, match_result& mr) const { BOOST_ASSERT(! p.path.empty()); @@ -58,9 +57,10 @@ operator()( } // Convert bitflags to match_options - match_options opts{ - p.case_sensitive, - p.strict, + auto& pv = *detail::route_params_access{p}; + detail::match_options opts{ + pv.case_sensitive, + pv.strict, end_ }; @@ -74,6 +74,5 @@ operator()( return true; } -} // detail } // http } // boost diff --git a/src/server/detail/route_match.hpp b/src/server/detail/route_match.hpp index 4d943f7b..4d80f612 100644 --- a/src/server/detail/route_match.hpp +++ b/src/server/detail/route_match.hpp @@ -1,5 +1,5 @@ // -// Copyright (c) 2025 Vinnie Falco (vinnie dot falco at gmail dot com) +// 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 http://www.boost.org/LICENSE_1_0.txt) @@ -11,7 +11,7 @@ #define BOOST_HTTP_SERVER_DETAIL_ROUTE_MATCH_HPP #include -#include +#include #include "src/server/route_abnf.hpp" #include "src/server/detail/stable_string.hpp" #include @@ -20,48 +20,48 @@ namespace boost { namespace http { -namespace detail { // Matches a path against a pattern // Members ordered largest-to-smallest for optimal packing -struct router_base::matcher +struct detail::router_base::matcher { - friend class http::flat_router; - matcher(std::string_view pat, bool end_); // true if match bool operator()( - route_params_base& p, + route_params& p, match_result& mr) const; // Returns error from pattern parsing, or empty if valid system::error_code error() const noexcept { return ec_; } private: + friend class detail::router_base; + friend struct detail::router_base::impl; + system::error_code ec_; std::string allow_header_; - route_pattern pattern_; + detail::route_pattern pattern_; std::vector custom_verbs_; // 16 bytes (pointer + size) - stable_string decoded_pat_; + detail::stable_string decoded_pat_; // 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 + std::size_t first_entry_ = 0; + std::size_t skip_ = 0; + std::uint64_t allowed_methods_ = 0; // 4 bytes each - opt_flags effective_opts_ = 0; // flat_router: computed opts for this scope - std::uint32_t depth_ = 0; // flat_router: nesting level (0 = root) + opt_flags effective_opts_ = 0; + opt_flags own_opts_ = 0; // router's opt_flags (for re-parenting during inline) + std::uint32_t depth_ = 0; // 1 byte each bool end_; // false for middleware bool slash_; }; -} // detail } // http } // boost diff --git a/src/server/detail/router_base.cpp b/src/server/detail/router_base.cpp deleted file mode 100644 index 06ed470e..00000000 --- a/src/server/detail/router_base.cpp +++ /dev/null @@ -1,186 +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 -// - -#include "src/server/detail/router_base.hpp" -#include -#include -#include -#include -#include -#include -#include "src/server/detail/route_match.hpp" - -/* - -pattern target path(use) path(get) -------------------------------------------------- -/ / / -/ /api /api -/api /api / /api -/api /api/ / /api/ -/api /api/ / no-match strict -/api /api/v0 /v0 no-match -/api/ /api / /api -/api/ /api / no-match strict -/api/ /api/ / /api/ -/api/ /api/v0 /v0 no-match - -*/ - -namespace boost { -namespace http { -namespace detail { - -router_base:: -~router_base() -{ - delete impl_; -} - -router_base:: -router_base( - opt_flags opt) - : impl_(new impl(opt)) -{ -} - -router_base:: -router_base( - router_base&& other) noexcept - :impl_(other.impl_) -{ - other.impl_ = nullptr; -} - -router_base& -router_base:: -operator=( - router_base&& other) noexcept -{ - delete impl_; - impl_ = other.impl_; - other.impl_ = 0; - return *this; -} - -auto -router_base:: -new_layer( - std::string_view pattern) -> layer& -{ - // the pattern must not be empty - if(pattern.empty()) - detail::throw_invalid_argument(); - // delete the last route if it is empty, - // this happens if they call route() without - // adding anything - if(! impl_->layers.empty() && - impl_->layers.back().entries.empty()) - impl_->layers.pop_back(); - impl_->layers.emplace_back(pattern); - return impl_->layers.back(); -}; - -std::size_t -router_base:: -new_layer_idx( - std::string_view pattern) -{ - new_layer(pattern); - return impl_->layers.size() - 1; -} - -auto -router_base:: -get_layer( - std::size_t idx) -> layer& -{ - return impl_->layers[idx]; -} - -void -router_base:: -add_impl( - std::string_view pattern, - handlers hn) -{ - if( pattern.empty()) - pattern = "/"; - impl_->layers.emplace_back( - pattern, hn); - - // Validate depth for any nested routers - auto& lay = impl_->layers.back(); - for(auto& entry : lay.entries) - if(entry.h->kind == is_router) - if(auto* r = entry.h->get_router()) - r->set_nested_depth(impl_->depth_); -} - -void -router_base:: -add_impl( - layer& l, - http::method verb, - handlers hn) -{ - // cannot be unknown - if(verb == http::method::unknown) - detail::throw_invalid_argument(); - - l.entries.reserve(l.entries.size() + hn.n); - for(std::size_t i = 0; i < hn.n; ++i) - l.entries.emplace_back(verb, - std::move(hn.p[i])); -} - -void -router_base:: -add_impl( - layer& l, - std::string_view verb_str, - handlers hn) -{ - l.entries.reserve(l.entries.size() + hn.n); - - if(verb_str.empty()) - { - // all - for(std::size_t i = 0; i < hn.n; ++i) - l.entries.emplace_back(std::move(hn.p[i])); - return; - } - - // possibly custom string - for(std::size_t i = 0; i < hn.n; ++i) - l.entries.emplace_back(verb_str, - std::move(hn.p[i])); -} - -void -router_base:: -set_nested_depth( - std::size_t parent_depth) -{ - std::size_t d = parent_depth + 1; - if(d >= max_path_depth) - detail::throw_length_error( - "router nesting depth exceeds max_path_depth"); - impl_->depth_ = d; - for(auto& layer : impl_->layers) - for(auto& entry : layer.entries) - if(entry.h->kind == is_router) - if(auto* r = entry.h->get_router()) - r->set_nested_depth(d); -} - -} // detail -} // http -} // boost - diff --git a/src/server/detail/stable_string.hpp b/src/server/detail/stable_string.hpp index b4148d11..8d46cf57 100644 --- a/src/server/detail/stable_string.hpp +++ b/src/server/detail/stable_string.hpp @@ -1,5 +1,5 @@ // -// Copyright (c) 2025 Vinnie Falco (vinnie dot falco at gmail dot com) +// 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 http://www.boost.org/LICENSE_1_0.txt) diff --git a/src/server/encode_url.cpp b/src/server/encode_url.cpp index 8cb4885c..b4adcc76 100644 --- a/src/server/encode_url.cpp +++ b/src/server/encode_url.cpp @@ -1,5 +1,5 @@ // -// Copyright (c) 2025 Vinnie Falco (vinnie dot falco at gmail dot com) +// 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 http://www.boost.org/LICENSE_1_0.txt) diff --git a/src/server/escape_html.cpp b/src/server/escape_html.cpp index 0c1b2f39..a8bc8009 100644 --- a/src/server/escape_html.cpp +++ b/src/server/escape_html.cpp @@ -1,5 +1,5 @@ // -// Copyright (c) 2025 Vinnie Falco (vinnie dot falco at gmail dot com) +// 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 http://www.boost.org/LICENSE_1_0.txt) diff --git a/src/server/etag.cpp b/src/server/etag.cpp index 6429183e..b78c8e09 100644 --- a/src/server/etag.cpp +++ b/src/server/etag.cpp @@ -1,5 +1,5 @@ // -// Copyright (c) 2025 Vinnie Falco (vinnie dot falco at gmail dot com) +// 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 http://www.boost.org/LICENSE_1_0.txt) diff --git a/src/server/flat_router.cpp b/src/server/flat_router.cpp deleted file mode 100644 index 5f65297e..00000000 --- a/src/server/flat_router.cpp +++ /dev/null @@ -1,595 +0,0 @@ -// -// Copyright (c) 2026 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 -#include -#include - -#include "src/server/detail/router_base.hpp" -#include "src/server/detail/pct_decode.hpp" -#include "src/server/detail/route_match.hpp" - -#include - -namespace boost { -namespace http { - -//------------------------------------------------ - -struct flat_router::impl -{ - using entry = detail::router_base::entry; - using layer = detail::router_base::layer; - using handler = detail::router_base::handler; - 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 - { - std::vector& matchers_; - std::vector& entries_; - std::size_t matcher_idx_; - - scope_tracker( - std::vector& m, - std::vector& e, - std::size_t idx) - : matchers_(m) - , entries_(e) - , matcher_idx_(idx) - { - } - - ~scope_tracker() - { - matchers_[matcher_idx_].skip_ = entries_.size(); - } - }; - - // 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, - opt_flags child) - { - opt_flags result = parent; - - // case_sensitive: bits 1-2 (2=true, 4=false) - if(child & 2) - result = (result & ~6) | 2; - else if(child & 4) - result = (result & ~6) | 4; - - // strict: bits 3-4 (8=true, 16=false) - if(child & 8) - result = (result & ~24) | 8; - else if(child & 16) - result = (result & ~24) | 16; - - return result; - } - - void - 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 - flatten_recursive( - detail::router_base::impl& src, - opt_flags parent_opts, - std::uint32_t depth) - { - opt_flags eff = compute_effective_opts(parent_opts, src.opt); - - for(auto& layer : src.layers) - { - // Move matcher, set flat router fields - std::size_t matcher_idx = matchers.size(); - matchers.emplace_back(std::move(layer.match)); - auto& m = matchers.back(); - m.first_entry_ = entries.size(); - m.effective_opts_ = eff; - m.depth_ = depth; - // m.skip_ set by scope_tracker dtor - - scope_tracker scope(matchers, entries, matcher_idx); - - for(auto& e : layer.entries) - { - if(e.h->kind == detail::router_base::is_router) - { - // Recurse into nested router - auto* nested = e.h->get_router(); - if(nested && nested->impl_) - flatten_recursive(*nested->impl_, eff, depth + 1); - } - 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)); - } - } - // ~scope_tracker sets matchers[matcher_idx].skip_ - } - } - - // Restore path to a given base_path length - static void - restore_path( - route_params_base& p, - std::size_t base_len) - { - p.base_path = { p.decoded_path_.data(), base_len }; - // Account for the addedSlash_ when computing path length - auto const path_len = p.decoded_path_.size() - (p.addedSlash_ ? 1 : 0); - if(base_len < path_len) - p.path = { p.decoded_path_.data() + base_len, - path_len - base_len }; - else - p.path = { p.decoded_path_.data() + - p.decoded_path_.size() - 1, 1 }; // soft slash - } - - route_task - 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. - std::size_t path_stack[detail::router_base::max_path_depth]; - path_stack[0] = 0; - - // Track which matcher index is matched at each depth level. - // matched_at_depth[d] = matcher index that successfully matched at depth d. - std::size_t matched_at_depth[detail::router_base::max_path_depth]; - for(std::size_t d = 0; d < detail::router_base::max_path_depth; ++d) - matched_at_depth[d] = SIZE_MAX; - - for(std::size_t i = 0; i < entries.size(); ) - { - auto const& e = entries[i]; - auto const& m = matchers[e.matcher_idx]; - auto const target_depth = m.depth_; - - //-------------------------------------------------- - // Pre-invoke checks (no coroutine yet) - //-------------------------------------------------- - - // For nested routes: verify ancestors at depths 0..target_depth-1 are matched. - // For siblings: if moving to same depth with different matcher, restore path. - bool ancestors_ok = true; - - // Check if we need to match new ancestor matchers for this entry. - // We iterate through matchers from last_matched+1 to e.matcher_idx, - // but ONLY process those that are at depths we need (ancestors or self). - std::size_t start_idx = (last_matched == SIZE_MAX) ? 0 : last_matched + 1; - - for(std::size_t check_idx = start_idx; - check_idx <= e.matcher_idx && ancestors_ok; - ++check_idx) - { - auto const& cm = matchers[check_idx]; - - // Only check matchers that are: - // 1. Ancestors (depth < target_depth) that we haven't matched yet, or - // 2. The entry's own matcher - bool is_needed_ancestor = (cm.depth_ < target_depth) && - (matched_at_depth[cm.depth_] == SIZE_MAX); - bool is_self = (check_idx == e.matcher_idx); - - if(!is_needed_ancestor && !is_self) - continue; - - // Restore path if moving to same or shallower depth - if(cm.depth_ <= current_depth && current_depth > 0) - { - restore_path(p, path_stack[cm.depth_]); - } - - // In error/exception mode, skip end routes - if(cm.end_ && p.kind_ != detail::router_base::is_plain) - { - i = cm.skip_; - ancestors_ok = false; - break; - } - - // Apply effective_opts for this matcher - p.case_sensitive = (cm.effective_opts_ & 2) != 0; - p.strict = (cm.effective_opts_ & 8) != 0; - - // Save path state before trying this matcher - if(cm.depth_ < detail::router_base::max_path_depth) - path_stack[cm.depth_] = p.base_path.size(); - - match_result mr; - if(!cm(p, mr)) - { - // Clear matched_at_depth for this depth and deeper - for(std::size_t d = cm.depth_; d < detail::router_base::max_path_depth; ++d) - matched_at_depth[d] = SIZE_MAX; - i = cm.skip_; - ancestors_ok = false; - 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; - - last_matched = check_idx; - current_depth = cm.depth_ + 1; - - // Save state for next depth level - if(current_depth < detail::router_base::max_path_depth) - path_stack[current_depth] = p.base_path.size(); - } - - 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))) - { - ++i; - continue; - } - - // Check kind match (cheap char comparison) - if(e.h->kind != p.kind_) - { - ++i; - continue; - } - - //-------------------------------------------------- - // Invoke handler (coroutine starts here) - //-------------------------------------------------- - - route_result rv; - try - { - rv = co_await e.h->invoke( - const_cast(p)); - } - catch(...) - { - // Only touch ep_ when actually catching - p.ep_ = std::current_exception(); - p.kind_ = detail::router_base::is_exception; - ++i; - continue; - } - - //-------------------------------------------------- - // Handle result - // - // Coroutines invert control - handler does the send. - // route_what::done = handler completed request - // route_what::next = continue to next handler - // route_what::next_route = skip to next route - // route_what::close = close connection - // route_what::error = enter error mode - //-------------------------------------------------- - - if(rv.what() == route_what::next) - { - ++i; - continue; - } - - if(rv.what() == route_what::next_route) - { - // next_route only valid for end routes, not middleware - if(!m.end_) - // VFALCO this is a logic error - co_return route_error(error::invalid_route_result); - i = m.skip_; - continue; - } - - if(rv.what() == route_what::done || - rv.what() == route_what::close) - { - // Handler completed or requested close - co_return rv; - } - - // Error - transition to error mode - p.ec_ = rv.error(); - p.kind_ = detail::router_base::is_error; - - if(m.end_) - { - // End routes don't have error handlers - i = m.skip_; - continue; - } - - ++i; - } - - // Final state - if(p.kind_ == detail::router_base::is_exception) - co_return route_error(error::unhandled_exception); - 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 - } -}; - -//------------------------------------------------ - -flat_router:: -flat_router( - detail::router_base&& src) - : impl_(std::make_shared()) -{ - impl_->flatten(*src.impl_); - impl_->options_handler_ = std::move(src.options_handler_); -} - -route_task -flat_router:: -dispatch( - http::method verb, - urls::url_view const& url, - route_params_base& p) const -{ - 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()); - if(p.decoded_path_.empty() || p.decoded_path_.back() != '/') - { - p.decoded_path_.push_back('/'); - p.addedSlash_ = true; - } - else - { - p.addedSlash_ = false; - } - // Set path views after potential reallocation from push_back - // Exclude added trailing slash from visible path, but keep "/" if empty - p.base_path = { p.decoded_path_.data(), 0 }; - auto const subtract = (p.addedSlash_ && p.decoded_path_.size() > 1) ? 1 : 0; - p.path = { p.decoded_path_.data(), p.decoded_path_.size() - subtract }; - - return impl_->dispatch_loop(p, verb == http::method::options); -} - -route_task -flat_router:: -dispatch( - std::string_view verb, - urls::url_view const& url, - route_params_base& p) const -{ - 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_ = 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()); - if(p.decoded_path_.empty() || p.decoded_path_.back() != '/') - { - p.decoded_path_.push_back('/'); - p.addedSlash_ = true; - } - else - { - p.addedSlash_ = false; - } - // Set path views after potential reallocation from push_back - // Exclude added trailing slash from visible path, but keep "/" if empty - p.base_path = { p.decoded_path_.data(), 0 }; - auto const subtract = (p.addedSlash_ && p.decoded_path_.size() > 1) ? 1 : 0; - p.path = { p.decoded_path_.data(), p.decoded_path_.size() - subtract }; - - return impl_->dispatch_loop(p, is_options); -} - -} // http -} // boost - diff --git a/src/server/fresh.cpp b/src/server/fresh.cpp index 2b15b6bf..81cb5915 100644 --- a/src/server/fresh.cpp +++ b/src/server/fresh.cpp @@ -1,5 +1,5 @@ // -// Copyright (c) 2025 Vinnie Falco (vinnie dot falco at gmail dot com) +// 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 http://www.boost.org/LICENSE_1_0.txt) diff --git a/src/server/http_worker.cpp b/src/server/http_worker.cpp new file mode 100644 index 00000000..5c5ee79e --- /dev/null +++ b/src/server/http_worker.cpp @@ -0,0 +1,90 @@ +// +// Copyright (c) 2026 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 http://www.boost.org/LICENSE_1_0.txt) +// +// Official repository: https://github.com/cppalliance/http +// + +#include +#include +#include +#include + +namespace boost { +namespace http { + +capy::task +http_worker:: +do_http_session() +{ + struct guard + { + http_worker& self; + + guard(http_worker& self_) + : self(self_) + { + } + + ~guard() + { + self.parser.reset(); + self.parser.start(); + self.rp.session_data.clear(); + } + }; + + guard g(*this); // clear things when session ends + + // read request, send response loop + for(;;) + { + parser.reset(); + parser.start(); + rp.session_data.clear(); + + // Read HTTP request header + auto [ec] = co_await parser.read_header(stream); + if(ec) + { + std::cerr << "read_header error: " << ec.message() << "\n"; + break; + } + + // Process headers and dispatch + // Set up Request and Response objects + rp.req = parser.get(); + rp.route_data.clear(); + rp.res.set_start_line( + http::status::ok, rp.req.version()); + rp.res.set_keep_alive(rp.req.keep_alive()); + serializer.reset(); + + // Parse the URL + { + auto rv = urls::parse_uri_reference(rp.req.target()); + if(rv.has_error()) + { + rp.status(http::status::bad_request); + } + rp.url = rv.value(); + } + + { + auto rv = co_await fr.dispatch(rp.req.method(), rp.url, rp); + if(rv.failed()) + { + // VFALCO log rv.error() + break; + } + + if(! rp.res.keep_alive()) + break; + } + } +} + +} // http +} // boost diff --git a/src/server/mime_db.cpp b/src/server/mime_db.cpp index 17ac57c7..8af174bf 100644 --- a/src/server/mime_db.cpp +++ b/src/server/mime_db.cpp @@ -1,5 +1,5 @@ // -// Copyright (c) 2025 Vinnie Falco (vinnie dot falco at gmail dot com) +// 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 http://www.boost.org/LICENSE_1_0.txt) diff --git a/src/server/mime_types.cpp b/src/server/mime_types.cpp index 125bf1c3..2dc8fa84 100644 --- a/src/server/mime_types.cpp +++ b/src/server/mime_types.cpp @@ -1,5 +1,5 @@ // -// Copyright (c) 2025 Vinnie Falco (vinnie dot falco at gmail dot com) +// 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 http://www.boost.org/LICENSE_1_0.txt) diff --git a/src/server/range_parser.cpp b/src/server/range_parser.cpp index e88f2fe9..84d3a788 100644 --- a/src/server/range_parser.cpp +++ b/src/server/range_parser.cpp @@ -1,5 +1,5 @@ // -// Copyright (c) 2025 Vinnie Falco (vinnie dot falco at gmail dot com) +// 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 http://www.boost.org/LICENSE_1_0.txt) diff --git a/src/server/route_abnf.cpp b/src/server/route_abnf.cpp index f9855d61..ced1e7b1 100644 --- a/src/server/route_abnf.cpp +++ b/src/server/route_abnf.cpp @@ -1,5 +1,5 @@ // -// Copyright (c) 2025 Vinnie Falco (vinnie dot falco at gmail dot com) +// 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 http://www.boost.org/LICENSE_1_0.txt) diff --git a/src/server/route_abnf.hpp b/src/server/route_abnf.hpp index 18e17c1e..5cf6ad97 100644 --- a/src/server/route_abnf.hpp +++ b/src/server/route_abnf.hpp @@ -1,5 +1,5 @@ // -// Copyright (c) 2025 Vinnie Falco (vinnie dot falco at gmail dot com) +// 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 http://www.boost.org/LICENSE_1_0.txt) diff --git a/src/server/router.cpp b/src/server/router.cpp index 113cd971..aef2ddb6 100644 --- a/src/server/router.cpp +++ b/src/server/router.cpp @@ -1,5 +1,5 @@ // -// Copyright (c) 2025 Vinnie Falco (vinnie dot falco at gmail dot com) +// 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 http://www.boost.org/LICENSE_1_0.txt) @@ -7,7 +7,7 @@ // Official repository: https://github.com/cppalliance/http // -#include +#include namespace boost { namespace http { diff --git a/src/server/router_types.cpp b/src/server/router_types.cpp index 3df442a6..f2585ef7 100644 --- a/src/server/router_types.cpp +++ b/src/server/router_types.cpp @@ -1,5 +1,5 @@ // -// Copyright (c) 2025 Vinnie Falco (vinnie dot falco at gmail dot com) +// 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 http://www.boost.org/LICENSE_1_0.txt) @@ -7,8 +7,7 @@ // Official repository: https://github.com/cppalliance/http // -#include -#include +#include #include #include #include @@ -129,14 +128,14 @@ error() const noexcept -> //------------------------------------------------ bool -route_params_base:: +route_params:: is_method( core::string_view s) const noexcept { auto m = http::string_to_method(s); if(m != http::method::unknown) - return verb_ == m; - return s == verb_str_; + return priv_.verb_ == m; + return s == priv_.verb_str_; } //------------------------------------------------ @@ -200,8 +199,8 @@ send(std::string_view body) co_return co_await res_body.write_eof(); } - auto [ec, n] = co_await res_body.write( - capy::make_buffer(body), true); + auto [ec, n] = co_await res_body.write_eof( + capy::make_buffer(body)); co_return {ec}; } diff --git a/src/server/send_file.cpp b/src/server/send_file.cpp index 43bd13ec..a53a8e6c 100644 --- a/src/server/send_file.cpp +++ b/src/server/send_file.cpp @@ -1,5 +1,5 @@ // -// Copyright (c) 2025 Vinnie Falco (vinnie dot falco at gmail dot com) +// 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 http://www.boost.org/LICENSE_1_0.txt) diff --git a/src/server/serve_index.cpp b/src/server/serve_index.cpp new file mode 100644 index 00000000..c0497ea9 --- /dev/null +++ b/src/server/serve_index.cpp @@ -0,0 +1,444 @@ +// +// 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 http://www.boost.org/LICENSE_1_0.txt) +// +// Official repository: https://github.com/cppalliance/http +// + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace boost { +namespace http { + +namespace { + +// Append an HTTP rel-path to a local filesystem path. +void +path_cat( + std::string& result, + core::string_view prefix, + core::string_view suffix) +{ + result = prefix; + +#ifdef _WIN32 + char constexpr path_separator = '\\'; +#else + char constexpr path_separator = '/'; +#endif + if(! result.empty() && result.back() == path_separator) + result.resize(result.size() - 1); + +#ifdef _WIN32 + for(auto& c : result) + if(c == '/') + c = path_separator; +#endif + for(auto const& c : suffix) + { + if(c == '/') + result.push_back(path_separator); + else + result.push_back(c); + } +} + +struct dir_entry +{ + std::string name; + bool is_dir = false; + std::uint64_t size = 0; + std::uint64_t mtime = 0; +}; + +// Directories first, then case-insensitive alphabetical +bool +entry_less( + dir_entry const& a, + dir_entry const& b) noexcept +{ + if(a.is_dir != b.is_dir) + return a.is_dir; + + // Case-insensitive compare + auto const& an = a.name; + auto const& bn = b.name; + auto const n = (std::min)(an.size(), bn.size()); + for(std::size_t i = 0; i < n; ++i) + { + auto ac = static_cast(an[i]); + auto bc = static_cast(bn[i]); + if(ac >= 'A' && ac <= 'Z') ac += 32; + if(bc >= 'A' && bc <= 'Z') bc += 32; + if(ac != bc) + return ac < bc; + } + return an.size() < bn.size(); +} + +std::uint64_t +to_epoch(std::filesystem::file_time_type tp) +{ + auto const sctp = std::chrono::clock_cast< + std::chrono::system_clock>(tp); + auto const dur = sctp.time_since_epoch(); + return static_cast( + std::chrono::duration_cast< + std::chrono::seconds>(dur).count()); +} + +std::string +format_size(std::uint64_t bytes) +{ + if(bytes < 1024) + return std::to_string(bytes) + " B"; + if(bytes < 1024 * 1024) + return std::to_string(bytes / 1024) + " KB"; + if(bytes < 1024 * 1024 * 1024) + return std::to_string(bytes / (1024 * 1024)) + " MB"; + return std::to_string( + bytes / (1024ULL * 1024 * 1024)) + " GB"; +} + +std::string +format_time(std::uint64_t epoch) +{ + if(epoch == 0) + return "-"; + + auto const t = static_cast(epoch); + std::tm tm; +#ifdef _WIN32 + gmtime_s(&tm, &t); +#else + gmtime_r(&t, &tm); +#endif + char buf[64]; + std::strftime(buf, sizeof(buf), + "%Y-%m-%d %H:%M:%S", &tm); + return buf; +} + + +std::string +render_html( + core::string_view dir, + std::vector const& entries, + bool show_parent) +{ + std::string body; + body.reserve(4096); + + body.append( + "\n" + "\n\n" + "\n" + "\n" + "Index of "); + body.append(escape_html(dir)); + body.append( + "\n" + "\n" + "\n\n" + "

Index of "); + body.append(escape_html(dir)); + body.append("

\n"); + + body.append( + "\n" + "" + "" + "\n"); + + if(show_parent) + { + body.append( + "" + "" + "\n"); + } + + for(auto const& e : entries) + { + auto display_name = escape_html(e.name); + auto href = encode_url(e.name); + if(e.is_dir) + href += '/'; + + body.append(""); + body.append(""); + body.append("\n"); + } + + body.append("
NameSizeModified
" + "..--
"); + body.append(display_name); + if(e.is_dir) + body.append("/"); + body.append(""); + body.append(e.is_dir ? "-" : format_size(e.size)); + body.append(""); + body.append(format_time(e.mtime)); + body.append("
\n\n\n"); + return body; +} + +std::string +render_json( + std::vector const& entries) +{ + std::string body; + body.reserve(1024); + body.push_back('['); + + bool first = true; + for(auto const& e : entries) + { + if(! first) + body.push_back(','); + first = false; + + body.append("{\"name\":\""); + + // Escape JSON string + for(auto c : e.name) + { + switch(c) + { + case '"': body.append("\\\""); break; + case '\\': body.append("\\\\"); break; + case '\n': body.append("\\n"); break; + case '\r': body.append("\\r"); break; + case '\t': body.append("\\t"); break; + default: body.push_back(c); break; + } + } + + body.append("\",\"type\":\""); + body.append(e.is_dir ? "directory" : "file"); + body.append("\",\"size\":"); + body.append(std::to_string(e.size)); + body.append(",\"mtime\":"); + body.append(std::to_string(e.mtime)); + body.push_back('}'); + } + + body.push_back(']'); + return body; +} + +std::string +render_plain( + std::vector const& entries) +{ + std::string body; + body.reserve(1024); + for(auto const& e : entries) + { + body.append(e.name); + if(e.is_dir) + body.push_back('/'); + body.push_back('\n'); + } + return body; +} + +} // (anon) + +//------------------------------------------------ + +struct serve_index::impl +{ + std::string root; + serve_index::options opts; + + impl( + core::string_view root_, + serve_index::options const& opts_) + : root(root_) + , opts(opts_) + { + } +}; + +serve_index:: +~serve_index() +{ + delete impl_; +} + +serve_index:: +serve_index(core::string_view root) + : serve_index(root, options{}) +{ +} + +serve_index:: +serve_index( + core::string_view root, + options const& opts) + : impl_(new impl(root, opts)) +{ +} + +serve_index:: +serve_index(serve_index&& other) noexcept + : impl_(other.impl_) +{ + other.impl_ = nullptr; +} + +route_task +serve_index:: +operator()(route_params& rp) const +{ + // Only handle GET and HEAD + if(rp.req.method() != method::get && + rp.req.method() != method::head) + { + if(impl_->opts.fallthrough) + co_return route_next; + + rp.res.set_status(status::method_not_allowed); + rp.res.set(field::allow, "GET, HEAD, OPTIONS"); + auto [ec] = co_await rp.send(); + if(ec) + co_return route_error(ec); + co_return route_done; + } + + auto req_path = rp.url.path(); + + // Build filesystem path + std::string path; + path_cat(path, impl_->root, req_path); + + // Must be a directory + std::error_code fec; + auto fs_status = std::filesystem::status(path, fec); + if(fec || fs_status.type() != + std::filesystem::file_type::directory) + co_return route_next; + + // Redirect if missing trailing slash + if(req_path.empty() || req_path.back() != '/') + { + std::string location(req_path); + location += '/'; + rp.res.set_status(status::moved_permanently); + rp.res.set(field::location, location); + auto [ec] = co_await rp.send(""); + if(ec) + co_return route_error(ec); + co_return route_done; + } + + // Read directory entries + std::vector entries; + { + std::filesystem::directory_iterator it(path, fec); + if(fec) + co_return route_next; + + for(auto const& de : + std::filesystem::directory_iterator(path, fec)) + { + auto name = de.path().filename().string(); + + // Skip hidden files unless configured + if(! impl_->opts.hidden && + ! name.empty() && name[0] == '.') + continue; + + dir_entry e; + e.name = std::move(name); + + std::error_code sec; + e.is_dir = de.is_directory(sec); + if(! e.is_dir) + e.size = de.file_size(sec); + auto lwt = de.last_write_time(sec); + if(! sec) + e.mtime = to_epoch(lwt); + + entries.push_back(std::move(e)); + } + } + + std::sort(entries.begin(), entries.end(), entry_less); + + // Determine ".." display + std::filesystem::path root_canonical(impl_->root); + std::filesystem::path dir_canonical(path); + { + std::error_code ec2; + root_canonical = + std::filesystem::canonical(root_canonical, ec2); + dir_canonical = + std::filesystem::canonical(dir_canonical, ec2); + } + bool show_up = impl_->opts.show_parent && + dir_canonical != root_canonical; + + // Content negotiation + accepts ac( rp.req ); + auto type = ac.type({ "html", "json", "text" }); + + std::string body; + std::string_view content_type; + if( type == "json" ) + { + body = render_json(entries); + content_type = "application/json; charset=utf-8"; + } + else if( type == "text" ) + { + body = render_plain(entries); + content_type = "text/plain; charset=utf-8"; + } + else + { + body = render_html(req_path, entries, show_up); + content_type = "text/html; charset=utf-8"; + } + + rp.res.set(field::content_type, content_type); + rp.res.set("X-Content-Type-Options", "nosniff"); + + auto [ec] = co_await rp.send(body); + if(ec) + co_return route_error(ec); + co_return route_done; +} + +} // http +} // boost diff --git a/src/server/serve_static.cpp b/src/server/serve_static.cpp index 0d5a72a6..92538cb3 100644 --- a/src/server/serve_static.cpp +++ b/src/server/serve_static.cpp @@ -1,5 +1,5 @@ // -// Copyright (c) 2025 Vinnie Falco (vinnie dot falco at gmail dot com) +// 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 http://www.boost.org/LICENSE_1_0.txt) diff --git a/src/server/statuses.cpp b/src/server/statuses.cpp index cb8cd484..aaf9e0d0 100644 --- a/src/server/statuses.cpp +++ b/src/server/statuses.cpp @@ -1,5 +1,5 @@ // -// Copyright (c) 2025 Vinnie Falco (vinnie dot falco at gmail dot com) +// 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 http://www.boost.org/LICENSE_1_0.txt) diff --git a/test/limits/CMakeLists.txt b/test/limits/CMakeLists.txt index f3d5d565..b51c795b 100644 --- a/test/limits/CMakeLists.txt +++ b/test/limits/CMakeLists.txt @@ -29,6 +29,7 @@ target_link_libraries(boost_http_limits PRIVATE Boost::config Boost::container_hash Boost::capy + Boost::json Boost::system Boost::throw_exception Boost::url diff --git a/test/limits/Jamfile b/test/limits/Jamfile index bb81d758..33a8fbab 100644 --- a/test/limits/Jamfile +++ b/test/limits/Jamfile @@ -22,6 +22,7 @@ project ../.. ../../../url/extra/test_suite /boost//capy + /boost/json//boost_json/off /boost//url ; diff --git a/test/limits/limits.cpp b/test/limits/limits.cpp index fff2d41e..bcfac2c2 100644 --- a/test/limits/limits.cpp +++ b/test/limits/limits.cpp @@ -1,5 +1,5 @@ // -// Copyright (c) 2019 Vinnie Falco (vinnie dot falco at gmail dot com) +// Copyright (c) 2019 Vinnie Falco (vinnie.falco@gmail.com) // Copyright (c) 2025 Mohammad Nejati // // Distributed under the Boost Software License, Version 1.0. (See accompanying diff --git a/test/unit/application.cpp b/test/unit/application.cpp index 65a35696..86e46c4c 100644 --- a/test/unit/application.cpp +++ b/test/unit/application.cpp @@ -1,5 +1,5 @@ // -// Copyright (c) 2025 Vinnie Falco (vinnie dot falco at gmail dot com) +// 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 http://www.boost.org/LICENSE_1_0.txt) diff --git a/test/unit/bcrypt.cpp b/test/unit/bcrypt.cpp index 63b1e1d8..31d47398 100644 --- a/test/unit/bcrypt.cpp +++ b/test/unit/bcrypt.cpp @@ -9,11 +9,87 @@ #include +#include +#include +#include +#include +#include + #include "test_helpers.hpp" +#include + namespace boost { namespace http { +namespace { + +class test_io_context; + +inline test_io_context& +default_test_io_context() noexcept; + +struct test_executor +{ + test_io_context* ctx_ = nullptr; + + test_executor() = default; + + explicit + test_executor(test_io_context& ctx) noexcept + : ctx_(&ctx) + { + } + + bool operator==(test_executor const& other) const noexcept + { + return ctx_ == other.ctx_; + } + + test_io_context& context() const noexcept; + + void on_work_started() const noexcept {} + void on_work_finished() const noexcept {} + + void dispatch(capy::coro h) const + { + h.resume(); + } + + void post(capy::coro h) const + { + h.resume(); + } +}; + +class test_io_context : public capy::execution_context +{ +public: + using executor_type = test_executor; + + executor_type get_executor() noexcept + { + return test_executor(*this); + } +}; + +inline test_io_context& +default_test_io_context() noexcept +{ + static test_io_context ctx; + return ctx; +} + +inline test_io_context& +test_executor::context() const noexcept +{ + return ctx_ ? *ctx_ : default_test_io_context(); +} + +static_assert(capy::Executor); + +} // namespace + struct bcrypt_test { void @@ -269,6 +345,203 @@ struct bcrypt_test BOOST_TEST(r1.str() == r2.str()); } + //------------------------------------------------ + // Tier 2 -- task API + //------------------------------------------------ + + void + test_hash_task() + { + test_io_context ctx; + auto ex = ctx.get_executor(); + + // hash_task produces valid 60-char result + { + bool completed = false; + capy::run_async(ex, + [&](bcrypt::result r) + { + BOOST_TEST(r.size() == 60); + BOOST_TEST(r.str().substr(0, 7) == "$2b$04$"); + completed = true; + }, + [](std::exception_ptr) {} + )(bcrypt::hash_task("password", 4)); + BOOST_TEST(completed); + } + + // Different passwords yield different hashes + { + bcrypt::result r1, r2; + bool done1 = false, done2 = false; + + capy::run_async(ex, + [&](bcrypt::result r) { r1 = r; done1 = true; }, + [](std::exception_ptr) {} + )(bcrypt::hash_task("password1", 4)); + + capy::run_async(ex, + [&](bcrypt::result r) { r2 = r; done2 = true; }, + [](std::exception_ptr) {} + )(bcrypt::hash_task("password2", 4)); + + BOOST_TEST(done1); + BOOST_TEST(done2); + BOOST_TEST(r1.str() != r2.str()); + } + } + + void + test_compare_task() + { + test_io_context ctx; + auto ex = ctx.get_executor(); + bcrypt::result hashed = bcrypt::hash("correct", 4); + + // Correct password matches + { + bool completed = false; + capy::run_async(ex, + [&](bool ok) + { + BOOST_TEST(ok); + completed = true; + }, + [](std::exception_ptr) {} + )(bcrypt::compare_task("correct", hashed.str())); + BOOST_TEST(completed); + } + + // Wrong password does not match + { + bool completed = false; + capy::run_async(ex, + [&](bool ok) + { + BOOST_TEST(! ok); + completed = true; + }, + [](std::exception_ptr) {} + )(bcrypt::compare_task("wrong", hashed.str())); + BOOST_TEST(completed); + } + + // Malformed hash throws + { + bool got_exception = false; + capy::run_async(ex, + [](bool) {}, + [&](std::exception_ptr ep) + { + got_exception = (ep != nullptr); + } + )(bcrypt::compare_task("pw", "invalid")); + BOOST_TEST(got_exception); + } + } + + //------------------------------------------------ + // Tier 3 -- friendly async API + //------------------------------------------------ + + void + test_hash_async() + { + test_io_context ctx; + auto ex = ctx.get_executor(); + + // hash_async offloads to system pool and produces valid result + { + std::binary_semaphore done(0); + bool pass = false; + auto do_test = []() -> capy::task<> + { + bcrypt::result r = + co_await bcrypt::hash_async("password", 4); + BOOST_TEST(r.size() == 60); + BOOST_TEST(r.str().substr(0, 7) == "$2b$04$"); + }; + + capy::run_async(ex, + [&]() { pass = true; done.release(); }, + [&](std::exception_ptr) { done.release(); } + )(do_test()); + done.acquire(); + BOOST_TEST(pass); + } + } + + void + test_compare_async() + { + test_io_context ctx; + auto ex = ctx.get_executor(); + bcrypt::result hashed = bcrypt::hash("secret", 4); + std::string stored(hashed.str()); + + // Correct password + { + std::binary_semaphore done(0); + bool pass = false; + auto do_test = []( + std::string stored_hash) -> capy::task<> + { + bool ok = co_await bcrypt::compare_async( + "secret", stored_hash); + BOOST_TEST(ok); + }; + + capy::run_async(ex, + [&]() { pass = true; done.release(); }, + [&](std::exception_ptr) { done.release(); } + )(do_test(stored)); + done.acquire(); + BOOST_TEST(pass); + } + + // Wrong password + { + std::binary_semaphore done(0); + bool pass = false; + auto do_test = []( + std::string stored_hash) -> capy::task<> + { + bool ok = co_await bcrypt::compare_async( + "wrong", stored_hash); + BOOST_TEST(! ok); + }; + + capy::run_async(ex, + [&]() { pass = true; done.release(); }, + [&](std::exception_ptr) { done.release(); } + )(do_test(stored)); + done.acquire(); + BOOST_TEST(pass); + } + + // Malformed hash throws + { + std::binary_semaphore done(0); + bool got_exception = false; + auto do_test = []() -> capy::task<> + { + co_await bcrypt::compare_async( + "password", "invalid"); + }; + + capy::run_async(ex, + [&]() { done.release(); }, + [&](std::exception_ptr ep) + { + got_exception = (ep != nullptr); + done.release(); + } + )(do_test()); + done.acquire(); + BOOST_TEST(got_exception); + } + } + void run() { @@ -281,6 +554,10 @@ struct bcrypt_test test_get_rounds(); test_known_vectors(); test_password_truncation(); + test_hash_task(); + test_compare_task(); + test_hash_async(); + test_compare_async(); } }; diff --git a/test/unit/bcrypt/bcrypt_error.cpp b/test/unit/bcrypt/bcrypt_error.cpp deleted file mode 100644 index 1a356ce9..00000000 --- a/test/unit/bcrypt/bcrypt_error.cpp +++ /dev/null @@ -1,11 +0,0 @@ -// -// Copyright (c) 2025 Vinnie Falco (vinnie dot falco at gmail dot com) -// -// Distributed under the Boost Software License, Version 1.0. (See accompanying -// file LICENSE_1_0.txt or copy at http://www.boost.org/LICENSE_1_0.txt) -// -// Official repository: https://github.com/cppalliance/http -// - -// Test that header file is self-contained. -#include diff --git a/test/unit/bcrypt/bcrypt_hash.cpp b/test/unit/bcrypt/bcrypt_hash.cpp deleted file mode 100644 index 6ae4124a..00000000 --- a/test/unit/bcrypt/bcrypt_hash.cpp +++ /dev/null @@ -1,11 +0,0 @@ -// -// Copyright (c) 2025 Vinnie Falco (vinnie dot falco at gmail dot com) -// -// Distributed under the Boost Software License, Version 1.0. (See accompanying -// file LICENSE_1_0.txt or copy at http://www.boost.org/LICENSE_1_0.txt) -// -// Official repository: https://github.com/cppalliance/http -// - -// Test that header file is self-contained. -#include diff --git a/test/unit/bcrypt/bcrypt_result.cpp b/test/unit/bcrypt/bcrypt_result.cpp deleted file mode 100644 index b1e35b55..00000000 --- a/test/unit/bcrypt/bcrypt_result.cpp +++ /dev/null @@ -1,11 +0,0 @@ -// -// Copyright (c) 2025 Vinnie Falco (vinnie dot falco at gmail dot com) -// -// Distributed under the Boost Software License, Version 1.0. (See accompanying -// file LICENSE_1_0.txt or copy at http://www.boost.org/LICENSE_1_0.txt) -// -// Official repository: https://github.com/cppalliance/http -// - -// Test that header file is self-contained. -#include diff --git a/test/unit/bcrypt/bcrypt_version.cpp b/test/unit/bcrypt/bcrypt_version.cpp deleted file mode 100644 index 1914d890..00000000 --- a/test/unit/bcrypt/bcrypt_version.cpp +++ /dev/null @@ -1,11 +0,0 @@ -// -// Copyright (c) 2025 Vinnie Falco (vinnie dot falco at gmail dot com) -// -// Distributed under the Boost Software License, Version 1.0. (See accompanying -// file LICENSE_1_0.txt or copy at http://www.boost.org/LICENSE_1_0.txt) -// -// Official repository: https://github.com/cppalliance/http -// - -// Test that header file is self-contained. -#include diff --git a/test/unit/brotli/brotli_error.cpp b/test/unit/brotli/brotli_error.cpp index 07bd1b9f..9140d77e 100644 --- a/test/unit/brotli/brotli_error.cpp +++ b/test/unit/brotli/brotli_error.cpp @@ -1,5 +1,5 @@ // -// Copyright (c) 2025 Vinnie Falco (vinnie dot falco at gmail dot com) +// 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 http://www.boost.org/LICENSE_1_0.txt) diff --git a/test/unit/brotli/decode.cpp b/test/unit/brotli/decode.cpp index 90befaf8..6607efee 100644 --- a/test/unit/brotli/decode.cpp +++ b/test/unit/brotli/decode.cpp @@ -1,5 +1,5 @@ // -// Copyright (c) 2025 Vinnie Falco (vinnie dot falco at gmail dot com) +// 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 http://www.boost.org/LICENSE_1_0.txt) diff --git a/test/unit/brotli/encode.cpp b/test/unit/brotli/encode.cpp index 72662299..4257b904 100644 --- a/test/unit/brotli/encode.cpp +++ b/test/unit/brotli/encode.cpp @@ -1,5 +1,5 @@ // -// Copyright (c) 2025 Vinnie Falco (vinnie dot falco at gmail dot com) +// 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 http://www.boost.org/LICENSE_1_0.txt) diff --git a/test/unit/brotli/shared_dictionary.cpp b/test/unit/brotli/shared_dictionary.cpp index 2cae9966..6913cd84 100644 --- a/test/unit/brotli/shared_dictionary.cpp +++ b/test/unit/brotli/shared_dictionary.cpp @@ -1,5 +1,5 @@ // -// Copyright (c) 2025 Vinnie Falco (vinnie dot falco at gmail dot com) +// 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 http://www.boost.org/LICENSE_1_0.txt) diff --git a/test/unit/brotli/types.cpp b/test/unit/brotli/types.cpp index 770edadc..bd459b54 100644 --- a/test/unit/brotli/types.cpp +++ b/test/unit/brotli/types.cpp @@ -1,5 +1,5 @@ // -// Copyright (c) 2025 Vinnie Falco (vinnie dot falco at gmail dot com) +// 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 http://www.boost.org/LICENSE_1_0.txt) diff --git a/test/unit/core/polystore.cpp b/test/unit/core/polystore.cpp index eb51925b..e1794631 100644 --- a/test/unit/core/polystore.cpp +++ b/test/unit/core/polystore.cpp @@ -1,5 +1,5 @@ // -// Copyright (c) 2025 Vinnie Falco (vinnie dot falco at gmail dot com) +// 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 http://www.boost.org/LICENSE_1_0.txt) diff --git a/test/unit/core/polystore_fwd.cpp b/test/unit/core/polystore_fwd.cpp index bd8aab52..eff42bdd 100644 --- a/test/unit/core/polystore_fwd.cpp +++ b/test/unit/core/polystore_fwd.cpp @@ -1,5 +1,5 @@ // -// Copyright (c) 2025 Vinnie Falco (vinnie dot falco at gmail dot com) +// 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 http://www.boost.org/LICENSE_1_0.txt) diff --git a/test/unit/datastore.cpp b/test/unit/datastore.cpp index 8e655de5..9e961cfe 100644 --- a/test/unit/datastore.cpp +++ b/test/unit/datastore.cpp @@ -1,5 +1,5 @@ // -// Copyright (c) 2025 Vinnie Falco (vinnie dot falco at gmail dot com) +// 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 http://www.boost.org/LICENSE_1_0.txt) diff --git a/test/unit/db/schema.cpp b/test/unit/db/schema.cpp new file mode 100644 index 00000000..ddf7ee65 --- /dev/null +++ b/test/unit/db/schema.cpp @@ -0,0 +1,416 @@ +// +// Copyright (c) 2026 Vinnie Falco (vinnie.falco@gmail.com) +// +// Distributed under the Boost Software License, Version 1.0. +// https://www.boost.org/LICENSE_1_0.txt +// + +// Test that header file is self-contained. +#include + +#include +#include + +#include "test_suite.hpp" + +namespace boost { +namespace http { +namespace db { + +//---------------------------------------------------------- +// Test fixtures +//---------------------------------------------------------- + +struct address +{ + std::string street; + std::string city; +}; + +constexpr auto tag_invoke(table_name_t, address const&) +{ + return "addresses"; +} + +constexpr auto tag_invoke(fields_t, address const&) +{ + return std::tuple( + field("street", &address::street), + field("city", &address::city)); +} + +struct post +{ + int id = 0; + int user_id = 0; + std::string title; +}; + +constexpr auto tag_invoke(table_name_t, post const&) +{ + return "posts"; +} + +constexpr auto tag_invoke(fields_t, post const&) +{ + return std::tuple( + field("id", &post::id).primary_key().auto_increment(), + field("user_id", &post::user_id).not_null().indexed(), + field("title", &post::title)); +} + +struct user +{ + int id = 0; + std::string email; + std::string name; + address addr; + address billing; + std::vector posts; +}; + +constexpr auto tag_invoke(table_name_t, user const&) +{ + return "users"; +} + +constexpr auto tag_invoke(fields_t, user const&) +{ + return std::tuple( + field("id", &user::id).primary_key().auto_increment(), + field("email", &user::email).not_null().unique(), + field("name", &user::name)); +} + +// Type with no mapping (for negative concept test) +struct unmapped {}; + +//---------------------------------------------------------- + +struct schema_test +{ + void + test_field_flags() + { + BOOST_TEST(flag_none == 0u); + BOOST_TEST(flag_primary_key == (1u << 0)); + BOOST_TEST(flag_auto_increment == (1u << 1)); + BOOST_TEST(flag_not_null == (1u << 2)); + BOOST_TEST(flag_unique == (1u << 3)); + BOOST_TEST(flag_indexed == (1u << 4)); + + // Flags combine correctly + unsigned combined = + flag_primary_key | + flag_auto_increment | + flag_not_null; + BOOST_TEST(combined & flag_primary_key); + BOOST_TEST(combined & flag_auto_increment); + BOOST_TEST(combined & flag_not_null); + BOOST_TEST(!(combined & flag_unique)); + BOOST_TEST(!(combined & flag_indexed)); + } + + void + test_field_construction() + { + constexpr auto f = field("email", &user::email); + + BOOST_TEST(f.name == "email"); + BOOST_TEST(f.flags == flag_none); + + static_assert( + std::is_same_v< + decltype(f)::value_type, + std::string>); + static_assert( + std::is_same_v< + decltype(f)::class_type, + user>); + } + + void + test_field_builder() + { + // Single flag + { + constexpr auto f = + field("id", &user::id).primary_key(); + BOOST_TEST(f.is_primary_key()); + BOOST_TEST(!f.is_auto_increment()); + BOOST_TEST(!f.is_not_null()); + BOOST_TEST(!f.is_unique()); + BOOST_TEST(!f.is_indexed()); + } + + // Chained flags + { + constexpr auto f = + field("id", &user::id) + .primary_key() + .auto_increment(); + BOOST_TEST(f.is_primary_key()); + BOOST_TEST(f.is_auto_increment()); + BOOST_TEST(!f.is_not_null()); + } + + // All flags + { + constexpr auto f = + field("x", &user::id) + .primary_key() + .auto_increment() + .not_null() + .unique() + .indexed(); + BOOST_TEST(f.is_primary_key()); + BOOST_TEST(f.is_auto_increment()); + BOOST_TEST(f.is_not_null()); + BOOST_TEST(f.is_unique()); + BOOST_TEST(f.is_indexed()); + } + } + + void + test_field_get_set() + { + auto f_name = field("name", &user::name); + auto f_id = field("id", &user::id); + + user u; + u.name = "Alice"; + u.id = 42; + + // get + BOOST_TEST(f_name.get(u) == "Alice"); + BOOST_TEST(f_id.get(u) == 42); + + // set (lvalue) + std::string new_name = "Bob"; + f_name.set(u, new_name); + BOOST_TEST(u.name == "Bob"); + + // set (rvalue) + f_name.set(u, std::string("Carol")); + BOOST_TEST(u.name == "Carol"); + + // set int + f_id.set(u, 99); + BOOST_TEST(u.id == 99); + } + + void + test_embed_construction() + { + constexpr auto e = + embed("addr_", &user::addr); + + BOOST_TEST(e.prefix == "addr_"); + + static_assert( + std::is_same_v< + decltype(e)::value_type, + address>); + static_assert( + std::is_same_v< + decltype(e)::class_type, + user>); + } + + void + test_embed_get_set() + { + auto e = embed("addr_", &user::addr); + + user u; + u.addr.street = "123 Main St"; + u.addr.city = "Springfield"; + + // get + BOOST_TEST(e.get(u).street == "123 Main St"); + BOOST_TEST(e.get(u).city == "Springfield"); + + // set (lvalue) + address a2{"456 Oak Ave", "Shelbyville"}; + e.set(u, a2); + BOOST_TEST(u.addr.street == "456 Oak Ave"); + BOOST_TEST(u.addr.city == "Shelbyville"); + + // set (rvalue) + e.set(u, address{"789 Elm", "Capital City"}); + BOOST_TEST(u.addr.street == "789 Elm"); + BOOST_TEST(u.addr.city == "Capital City"); + } + + void + test_has_one() + { + constexpr auto h = + has_one("billing_id", &user::billing); + + BOOST_TEST(h.foreign_key == "billing_id"); + + static_assert( + std::is_same_v< + decltype(h)::value_type, + address>); + static_assert( + std::is_same_v< + decltype(h)::class_type, + user>); + + user u; + u.billing.street = "100 Invoice Lane"; + + // get + BOOST_TEST(h.get(u).street == "100 Invoice Lane"); + + // set (lvalue) + address a{"200 Pay St", "Billtown"}; + h.set(u, a); + BOOST_TEST(u.billing.street == "200 Pay St"); + + // set (rvalue) + h.set(u, address{"300 Coin Rd", "Mintville"}); + BOOST_TEST(u.billing.city == "Mintville"); + } + + void + test_has_many() + { + constexpr auto hm = + has_many(&user::posts, &post::user_id); + + static_assert( + std::is_same_v< + decltype(hm)::collection_type, + std::vector>); + static_assert( + std::is_same_v< + decltype(hm)::parent_type, + user>); + static_assert( + std::is_same_v< + decltype(hm)::child_type, + post>); + static_assert( + std::is_same_v< + decltype(hm)::fk_value_type, + int>); + } + + void + test_table_name() + { + BOOST_TEST(std::string_view(table_name(user{})) == "users"); + BOOST_TEST(std::string_view(table_name(address{})) == "addresses"); + BOOST_TEST(std::string_view(table_name(post{})) == "posts"); + } + + void + test_fields_cpo() + { + auto fs = fields(user{}); + BOOST_TEST(std::tuple_size_v == 3u); + BOOST_TEST(std::get<0>(fs).name == "id"); + BOOST_TEST(std::get<1>(fs).name == "email"); + BOOST_TEST(std::get<2>(fs).name == "name"); + + // Verify flags survive the round trip + BOOST_TEST(std::get<0>(fs).is_primary_key()); + BOOST_TEST(std::get<0>(fs).is_auto_increment()); + BOOST_TEST(std::get<1>(fs).is_not_null()); + BOOST_TEST(std::get<1>(fs).is_unique()); + } + + void + test_has_mapping_concept() + { + static_assert(HasMapping); + static_assert(HasMapping
); + static_assert(HasMapping); + static_assert(!HasMapping); + } + + void + test_for_each_field() + { + // Collect field names + std::vector names; + for_each_field( + [&](auto const& f) { names.push_back(f.name); }); + + BOOST_TEST(names.size() == 3u); + BOOST_TEST(names[0] == "id"); + BOOST_TEST(names[1] == "email"); + BOOST_TEST(names[2] == "name"); + + // Count flagged fields + unsigned pk_count = 0; + for_each_field( + [&](auto const& f) + { + if(f.is_primary_key()) + ++pk_count; + }); + BOOST_TEST(pk_count == 1u); + } + + void + test_field_count() + { + static_assert(field_count() == 3); + static_assert(field_count
() == 2); + static_assert(field_count() == 3); + } + + void + test_field_get_set_via_for_each() + { + user u; + u.id = 1; + u.email = "alice@example.com"; + u.name = "Alice"; + + // Read every field via for_each_field + std::vector values; + for_each_field( + [&](auto const& f) + { + auto const& v = f.get(u); + if constexpr (std::is_same_v< + std::remove_cvref_t, + std::string>) + values.push_back(v); + }); + + BOOST_TEST(values.size() == 2u); + BOOST_TEST(values[0] == "alice@example.com"); + BOOST_TEST(values[1] == "Alice"); + } + + void + run() + { + test_field_flags(); + test_field_construction(); + test_field_builder(); + test_field_get_set(); + test_embed_construction(); + test_embed_get_set(); + test_has_one(); + test_has_many(); + test_table_name(); + test_fields_cpo(); + test_has_mapping_concept(); + test_for_each_field(); + test_field_count(); + test_field_get_set_via_for_each(); + } +}; + +TEST_SUITE(schema_test, "boost.http.db.schema"); + +} // namespace db +} // namespace http +} // namespace boost diff --git a/test/unit/detail/file_posix.cpp b/test/unit/detail/file_posix.cpp index ed65e152..e47713bb 100644 --- a/test/unit/detail/file_posix.cpp +++ b/test/unit/detail/file_posix.cpp @@ -1,5 +1,5 @@ // -// Copyright (c) 2022 Vinnie Falco (vinnie dot falco at gmail dot com) +// Copyright (c) 2022 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 http://www.boost.org/LICENSE_1_0.txt) diff --git a/test/unit/detail/file_stdio.cpp b/test/unit/detail/file_stdio.cpp index 9446232c..8e280716 100644 --- a/test/unit/detail/file_stdio.cpp +++ b/test/unit/detail/file_stdio.cpp @@ -1,5 +1,5 @@ // -// Copyright (c) 2022 Vinnie Falco (vinnie dot falco at gmail dot com) +// Copyright (c) 2022 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 http://www.boost.org/LICENSE_1_0.txt) diff --git a/test/unit/detail/file_win32.cpp b/test/unit/detail/file_win32.cpp index cdb9e54b..19a7bc52 100644 --- a/test/unit/detail/file_win32.cpp +++ b/test/unit/detail/file_win32.cpp @@ -1,5 +1,5 @@ // -// Copyright (c) 2022 Vinnie Falco (vinnie dot falco at gmail dot com) +// Copyright (c) 2022 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 http://www.boost.org/LICENSE_1_0.txt) diff --git a/test/unit/error.cpp b/test/unit/error.cpp index c6e2a6d7..65927da1 100644 --- a/test/unit/error.cpp +++ b/test/unit/error.cpp @@ -1,5 +1,5 @@ // -// Copyright (c) 2021 Vinnie Falco (vinnie dot falco at gmail dot com) +// Copyright (c) 2021 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 http://www.boost.org/LICENSE_1_0.txt) diff --git a/test/unit/field.cpp b/test/unit/field.cpp index ce2b7ccf..9018b04d 100644 --- a/test/unit/field.cpp +++ b/test/unit/field.cpp @@ -1,5 +1,5 @@ // -// Copyright (c) 2021 Vinnie Falco (vinnie dot falco at gmail dot com) +// Copyright (c) 2021 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 http://www.boost.org/LICENSE_1_0.txt) diff --git a/test/unit/fields.cpp b/test/unit/fields.cpp index d90f1727..9a05c5c4 100644 --- a/test/unit/fields.cpp +++ b/test/unit/fields.cpp @@ -1,5 +1,5 @@ // -// Copyright (c) 2021 Vinnie Falco (vinnie dot falco at gmail dot com) +// Copyright (c) 2021 Vinnie Falco (vinnie.falco@gmail.com) // Copyright (c) 2024 Christian Mazakas // // Distributed under the Boost Software License, Version 1.0. (See accompanying diff --git a/test/unit/fields_base.cpp b/test/unit/fields_base.cpp index 5eb4d421..846d51d9 100644 --- a/test/unit/fields_base.cpp +++ b/test/unit/fields_base.cpp @@ -1,5 +1,5 @@ // -// Copyright (c) 2021 Vinnie Falco (vinnie dot falco at gmail dot com) +// Copyright (c) 2021 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 http://www.boost.org/LICENSE_1_0.txt) diff --git a/test/unit/file.cpp b/test/unit/file.cpp index 7bdb394c..c79a5a04 100644 --- a/test/unit/file.cpp +++ b/test/unit/file.cpp @@ -1,5 +1,5 @@ // -// Copyright (c) 2022 Vinnie Falco (vinnie dot falco at gmail dot com) +// Copyright (c) 2022 Vinnie Falco (vinnie.falco@gmail.com) // Copyright (c) 2025 Mohammad Nejati // // Distributed under the Boost Software License, Version 1.0. (See accompanying diff --git a/test/unit/file_mode.cpp b/test/unit/file_mode.cpp index b8fab0de..e34fd6b6 100644 --- a/test/unit/file_mode.cpp +++ b/test/unit/file_mode.cpp @@ -1,5 +1,5 @@ // -// Copyright (c) 2022 Vinnie Falco (vinnie dot falco at gmail dot com) +// Copyright (c) 2022 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 http://www.boost.org/LICENSE_1_0.txt) diff --git a/test/unit/file_test.hpp b/test/unit/file_test.hpp index 3eb7e91d..eb261114 100644 --- a/test/unit/file_test.hpp +++ b/test/unit/file_test.hpp @@ -1,5 +1,5 @@ // -// Copyright (c) 2022 Vinnie Falco (vinnie dot falco at gmail dot com) +// Copyright (c) 2022 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 http://www.boost.org/LICENSE_1_0.txt) diff --git a/test/unit/http_proto.cpp b/test/unit/http_proto.cpp index 5b94b1c0..59126556 100644 --- a/test/unit/http_proto.cpp +++ b/test/unit/http_proto.cpp @@ -1,5 +1,5 @@ // -// Copyright (c) 2021 Vinnie Falco (vinnie dot falco at gmail dot com) +// Copyright (c) 2021 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 http://www.boost.org/LICENSE_1_0.txt) diff --git a/test/unit/json.cpp b/test/unit/json.cpp index 2e1f4c6a..8c45faa6 100644 --- a/test/unit/json.cpp +++ b/test/unit/json.cpp @@ -1,5 +1,5 @@ // -// Copyright (c) 2025 Vinnie Falco (vinnie dot falco at gmail dot com) +// 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 http://www.boost.org/LICENSE_1_0.txt) diff --git a/test/unit/json/json_sink.cpp b/test/unit/json/json_sink.cpp index aebea02f..13d9a585 100644 --- a/test/unit/json/json_sink.cpp +++ b/test/unit/json/json_sink.cpp @@ -13,6 +13,7 @@ #include #include #include +#include #include #include #include @@ -32,40 +33,78 @@ namespace { // Test executor //---------------------------------------------------------- +class test_io_context; + +inline test_io_context& +default_test_io_context() noexcept; + struct test_executor { - int& dispatch_count_; + int* dispatch_count_ = nullptr; + test_io_context* ctx_ = nullptr; + + test_executor() = default; - explicit test_executor(int& count) noexcept - : dispatch_count_(count) + explicit + test_executor(test_io_context& ctx) noexcept + : ctx_(&ctx) { } - bool operator==(test_executor const& other) const noexcept + explicit + test_executor(int& count) noexcept + : dispatch_count_(&count) { - return &dispatch_count_ == &other.dispatch_count_; } - struct test_context : capy::execution_context {}; - - capy::execution_context& context() const noexcept + bool operator==(test_executor const& other) const noexcept { - static test_context ctx; - return ctx; + return dispatch_count_ == other.dispatch_count_ && + ctx_ == other.ctx_; } + test_io_context& context() const noexcept; + void on_work_started() const noexcept {} void on_work_finished() const noexcept {} - capy::coro dispatch(capy::coro h) const noexcept + void dispatch(capy::coro h) const { - ++dispatch_count_; - return h; + if(dispatch_count_) + ++(*dispatch_count_); + h.resume(); } - void post(capy::coro) const noexcept {} + void post(capy::coro h) const + { + h.resume(); + } }; +class test_io_context : public capy::execution_context +{ +public: + using executor_type = test_executor; + + executor_type get_executor() noexcept + { + return test_executor(*this); + } +}; + +inline test_io_context& +default_test_io_context() noexcept +{ + static test_io_context ctx; + return ctx; +} + +inline test_io_context& +test_executor::context() const noexcept +{ + return ctx_ ? *ctx_ : default_test_io_context(); +} + static_assert(capy::Executor); } // namespace diff --git a/test/unit/message_base.cpp b/test/unit/message_base.cpp index 4979ff1f..d3f2293c 100644 --- a/test/unit/message_base.cpp +++ b/test/unit/message_base.cpp @@ -1,5 +1,5 @@ // -// Copyright (c) 2021 Vinnie Falco (vinnie dot falco at gmail dot com) +// Copyright (c) 2021 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 http://www.boost.org/LICENSE_1_0.txt) diff --git a/test/unit/metadata.cpp b/test/unit/metadata.cpp index 8cbc31ea..59ae2691 100644 --- a/test/unit/metadata.cpp +++ b/test/unit/metadata.cpp @@ -1,5 +1,5 @@ // -// Copyright (c) 2021 Vinnie Falco (vinnie dot falco at gmail dot com) +// Copyright (c) 2021 Vinnie Falco (vinnie.falco@gmail.com) // Copyright (c) 2025 Mohammad Nejati // // Distributed under the Boost Software License, Version 1.0. (See accompanying diff --git a/test/unit/method.cpp b/test/unit/method.cpp index 23bb93b5..7cad31dd 100644 --- a/test/unit/method.cpp +++ b/test/unit/method.cpp @@ -1,5 +1,5 @@ // -// Copyright (c) 2021 Vinnie Falco (vinnie dot falco at gmail dot com) +// Copyright (c) 2021 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 http://www.boost.org/LICENSE_1_0.txt) diff --git a/test/unit/natvis.cpp b/test/unit/natvis.cpp index 794a1944..637a6642 100644 --- a/test/unit/natvis.cpp +++ b/test/unit/natvis.cpp @@ -1,5 +1,5 @@ // -// Copyright (c) 2021 Vinnie Falco (vinnie dot falco at gmail dot com) +// Copyright (c) 2021 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 http://www.boost.org/LICENSE_1_0.txt) diff --git a/test/unit/parser.cpp b/test/unit/parser.cpp index 1f08a522..4ca54b12 100644 --- a/test/unit/parser.cpp +++ b/test/unit/parser.cpp @@ -1905,10 +1905,10 @@ struct parser_coro_test for(;;) { auto [ec, bufs] = co_await source.pull(arr); + if(ec == capy::cond::eof) + break; if(ec) co_return; - if(bufs.empty()) - break; std::size_t n = 0; for(auto const& buf : bufs) { @@ -1953,10 +1953,10 @@ struct parser_coro_test for(;;) { auto [ec, bufs] = co_await source.pull(arr); + if(ec == capy::cond::eof) + break; if(ec) co_return; - if(bufs.empty()) - break; std::size_t n = 0; for(auto const& buf : bufs) { diff --git a/test/unit/sandbox.cpp b/test/unit/sandbox.cpp index f4ff0f8a..91a2e21c 100644 --- a/test/unit/sandbox.cpp +++ b/test/unit/sandbox.cpp @@ -1,5 +1,5 @@ // -// Copyright (c) 2021 Vinnie Falco (vinnie dot falco at gmail dot com) +// Copyright (c) 2021 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 http://www.boost.org/LICENSE_1_0.txt) diff --git a/test/unit/serializer.cpp b/test/unit/serializer.cpp index 61a68e01..7cf2ef31 100644 --- a/test/unit/serializer.cpp +++ b/test/unit/serializer.cpp @@ -132,22 +132,18 @@ struct serializer_test serializer sr(cfg_); response res; - sr.start(res); + sr.set_message(res); + sr.start(); sr.reset(); - sr.start(res, capy::const_buffer{}); + sr.set_message(res); + sr.start_writes(); + sr.stream_close(); sr.reset(); - sr.start(res, capy::mutable_buffer{}); - sr.reset(); - - sr.start(res, capy::const_buffer{}); - sr.reset(); - - sr.start(res, capy::mutable_buffer{}); - sr.reset(); - - sr.start_stream(res); + sr.set_message(res); + sr.start_buffers(); + sr.stream_close(); sr.reset(); } @@ -181,7 +177,8 @@ struct serializer_test std::string message; capy::string_dynamic_buffer buf(&message); serializer sr1(cfg_); - sr1.start(res); + sr1.set_message(res); + sr1.start(); // consume 5 bytes { @@ -221,7 +218,8 @@ struct serializer_test { response res(headers); serializer sr(cfg_); - sr.start(res); + sr.set_message(res); + sr.start(); std::string s = read(sr); BOOST_TEST(s == expected); }; @@ -250,43 +248,42 @@ struct serializer_test //-------------------------------------------- - void - check_buffers( + std::string + sink_serialize( core::string_view headers, - core::string_view body, - core::string_view expected_header, - core::string_view expected_body) + core::string_view body = {}) { + std::string result; response res(headers); - std::array< - capy::const_buffer, 23> buf; - - const auto buf_size = - (body.size() / buf.size()) + 1; - for(auto& cb : buf) + capy::test::fuse f; + auto r = f.armed( + [this, &res, &result, body] + (capy::test::fuse& f) -> capy::task<> { - if(body.size() < buf_size) + capy::test::write_stream ws(f); + serializer sr(cfg_); + sr.set_message(res); + auto sink = sr.sink_for(ws); + if(body.empty()) { - cb = { body.data(), body.size() }; - body.remove_prefix(body.size()); - break; + auto [ec] = co_await sink.write_eof(); + if(ec) + co_return; } - cb = { body.data(), buf_size }; - body.remove_prefix(buf_size); - } - - serializer sr(cfg_); - sr.start(res, buf); - std::string s = read(sr); - core::string_view sv(s); - - BOOST_TEST( - sv.substr(0, expected_header.size()) - == expected_header); - - BOOST_TEST( - sv.substr(expected_header.size()) - == expected_body); + else + { + std::string_view sv( + body.data(), body.size()); + auto [ec, n] = co_await sink.write_eof( + capy::make_buffer(sv)); + if(ec) + co_return; + } + BOOST_TEST(sr.is_done()); + result = ws.data(); + }); + BOOST_TEST(r.success); + return result; } template @@ -298,10 +295,9 @@ struct serializer_test { response res(headers); serializer sr(cfg_); - sr.start_stream(res); - BOOST_TEST_GT( - sr.stream_capacity(), - serializer_config{}.payload_buffer); + sr.set_message(res); + sr.start_writes(); + BOOST_TEST(sr.stream_capacity() != 0); std::vector s; // stores complete output @@ -381,63 +377,73 @@ struct serializer_test void testOutput() { - // buffers (0 size) - check_buffers( - "HTTP/1.1 200 OK\r\n" - "Server: test\r\n" - "Content-Length: 0\r\n" - "\r\n", - "", - //-------------------------- - "HTTP/1.1 200 OK\r\n" - "Server: test\r\n" - "Content-Length: 0\r\n" - "\r\n", - ""); + // WriteSink: Content-Length: 0 + { + auto s = sink_serialize( + "HTTP/1.1 200 OK\r\n" + "Server: test\r\n" + "Content-Length: 0\r\n" + "\r\n"); + BOOST_TEST(s == + "HTTP/1.1 200 OK\r\n" + "Server: test\r\n" + "Content-Length: 0\r\n" + "\r\n"); + } - // buffers - check_buffers( - "HTTP/1.1 200 OK\r\n" - "Server: test\r\n" - "Content-Length: 2048\r\n" - "\r\n", - std::string(2048, '*'), - //-------------------------- - "HTTP/1.1 200 OK\r\n" - "Server: test\r\n" - "Content-Length: 2048\r\n" - "\r\n", - std::string(2048, '*')); + // WriteSink: Content-Length: 2048 + { + std::string body(2048, '*'); + auto s = sink_serialize( + "HTTP/1.1 200 OK\r\n" + "Server: test\r\n" + "Content-Length: 2048\r\n" + "\r\n", + body); + BOOST_TEST(s == + "HTTP/1.1 200 OK\r\n" + "Server: test\r\n" + "Content-Length: 2048\r\n" + "\r\n" + body); + } - // buffers chunked - check_buffers( - "HTTP/1.1 200 OK\r\n" - "Server: test\r\n" - "Transfer-Encoding: chunked\r\n" - "\r\n", - std::string(2048, '*'), - //-------------------------- - "HTTP/1.1 200 OK\r\n" - "Server: test\r\n" - "Transfer-Encoding: chunked\r\n" - "\r\n", - std::string("800\r\n") + - std::string(2048, '*') + - std::string("\r\n0\r\n\r\n")); + // WriteSink: chunked 2048 + { + std::string body(2048, '*'); + auto s = sink_serialize( + "HTTP/1.1 200 OK\r\n" + "Server: test\r\n" + "Transfer-Encoding: chunked\r\n" + "\r\n", + body); + core::string_view sv(s); + core::string_view hdr = + "HTTP/1.1 200 OK\r\n" + "Server: test\r\n" + "Transfer-Encoding: chunked\r\n" + "\r\n"; + BOOST_TEST(sv.starts_with(hdr)); + sv.remove_prefix(hdr.size()); + check_chunked_body(sv, body); + } - // buffers chunked empty - check_buffers( - "HTTP/1.1 200 OK\r\n" - "Server: test\r\n" - "Transfer-Encoding: chunked\r\n" - "\r\n", - "", - //-------------------------- - "HTTP/1.1 200 OK\r\n" - "Server: test\r\n" - "Transfer-Encoding: chunked\r\n" - "\r\n", - "0\r\n\r\n"); + // WriteSink: chunked empty + { + auto s = sink_serialize( + "HTTP/1.1 200 OK\r\n" + "Server: test\r\n" + "Transfer-Encoding: chunked\r\n" + "\r\n"); + core::string_view sv(s); + core::string_view hdr = + "HTTP/1.1 200 OK\r\n" + "Server: test\r\n" + "Transfer-Encoding: chunked\r\n" + "\r\n"; + BOOST_TEST(sv.starts_with(hdr)); + sv.remove_prefix(hdr.size()); + BOOST_TEST(sv == "0\r\n\r\n"); + } // empty stream { @@ -538,7 +544,26 @@ struct serializer_test "Expect: 100-continue\r\n" "Content-Length: 5\r\n" "\r\n"); - sr.start(req, capy::const_buffer("12345", 5)); + sr.set_message(req); + sr.start_writes(); + { + auto mbp = sr.stream_prepare(); + capy::const_buffer body("12345", 5); + std::size_t n = 0; + for(auto const& mb : mbp) + { + auto chunk = + (std::min)(mb.size(), body.size()); + if(chunk == 0) + break; + std::memcpy( + mb.data(), body.data(), chunk); + body += chunk; + n += chunk; + } + sr.stream_commit(n); + } + sr.stream_close(); std::string s; system::result< serializer::const_buffers_type> rv; @@ -582,7 +607,8 @@ struct serializer_test "Expect: 100-continue\r\n" "Content-Length: 5\r\n" "\r\n"); - sr.start(req); + sr.set_message(req); + sr.start(); std::string s; system::result< serializer::const_buffers_type> rv; @@ -621,7 +647,26 @@ struct serializer_test serializer sr(cfg_); response res(sv); - sr.start(res, capy::const_buffer("12345", 5)); + sr.set_message(res); + sr.start_writes(); + { + auto mbp = sr.stream_prepare(); + capy::const_buffer body("12345", 5); + std::size_t n = 0; + for(auto const& mb : mbp) + { + auto chunk = + (std::min)(mb.size(), body.size()); + if(chunk == 0) + break; + std::memcpy( + mb.data(), body.data(), chunk); + body += chunk; + n += chunk; + } + sr.stream_commit(n); + } + sr.stream_close(); auto s = read(sr); BOOST_TEST(s == "HTTP/1.1 200 OK\r\n" @@ -642,7 +687,8 @@ struct serializer_test "\r\n"; response res(sv); serializer sr(cfg_); - sr.start_stream(res); + sr.set_message(res); + sr.start_writes(); // consume whole header { @@ -692,7 +738,8 @@ struct serializer_test "\r\n"; response res(sv); serializer sr(cfg_); - sr.start_stream(res); + sr.set_message(res); + sr.start_writes(); auto mbs = sr.stream_prepare(); BOOST_TEST_GT( @@ -739,7 +786,8 @@ struct serializer_test { serializer sr(cfg_); request req; - sr.start(req); + sr.set_message(req); + sr.start(); auto cbs = sr.prepare().value(); sr.consume(capy::buffer_size(cbs) + 1); BOOST_TEST(sr.is_done()); @@ -764,8 +812,8 @@ struct serializer_test response res; res.set_payload_size(13); + sr.set_message(res); auto sink = sr.sink_for(ws); - sr.start_stream(res); capy::mutable_buffer arr[16]; auto bufs = sink.prepare(arr); @@ -779,7 +827,7 @@ struct serializer_test if(ec) co_return; - auto [ec2] = co_await sink.commit_eof(); + auto [ec2] = co_await sink.commit_eof(0); if(ec2) co_return; @@ -801,8 +849,8 @@ struct serializer_test response res; res.set_payload_size(5); + sr.set_message(res); auto sink = sr.sink_for(ws); - sr.start_stream(res); capy::mutable_buffer arr[16]; auto bufs = sink.prepare(arr); @@ -812,7 +860,7 @@ struct serializer_test std::string_view body = "hello"; std::memcpy(bufs[0].data(), body.data(), body.size()); - auto [ec] = co_await sink.commit(body.size(), true); + auto [ec] = co_await sink.commit_eof(body.size()); if(ec) co_return; @@ -834,14 +882,14 @@ struct serializer_test response res; res.set_payload_size(0); + sr.set_message(res); auto sink = sr.sink_for(ws); - sr.start_stream(res); auto [ec] = co_await sink.commit(0); if(ec) co_return; - auto [ec2] = co_await sink.commit_eof(); + auto [ec2] = co_await sink.commit_eof(0); if(ec2) co_return; }); @@ -859,8 +907,8 @@ struct serializer_test response res; res.set_chunked(true); + sr.set_message(res); auto sink = sr.sink_for(ws); - sr.start_stream(res); // First commit capy::mutable_buffer arr[16]; @@ -885,7 +933,7 @@ struct serializer_test if(ec2) co_return; - auto [ec3] = co_await sink.commit_eof(); + auto [ec3] = co_await sink.commit_eof(0); if(ec3) co_return; @@ -909,8 +957,8 @@ struct serializer_test response res; res.set_chunked(true); + sr.set_message(res); auto sink = sr.sink_for(ws); - sr.start_stream(res); capy::mutable_buffer arr[16]; auto bufs = sink.prepare(arr); @@ -923,7 +971,7 @@ struct serializer_test if(ec1) co_return; - auto [ec2] = co_await sink.commit_eof(); + auto [ec2] = co_await sink.commit_eof(0); if(ec2) co_return; @@ -945,10 +993,10 @@ struct serializer_test response res; res.set_chunked(true); + sr.set_message(res); auto sink = sr.sink_for(ws); - sr.start_stream(res); - auto [ec] = co_await sink.commit_eof(); + auto [ec] = co_await sink.commit_eof(0); if(ec) co_return; @@ -971,8 +1019,8 @@ struct serializer_test response res; res.set_payload_size(5); res.set(field::content_type, "text/plain"); + sr.set_message(res); auto sink = sr.sink_for(ws); - sr.start_stream(res); capy::mutable_buffer arr[16]; auto bufs = sink.prepare(arr); @@ -985,7 +1033,7 @@ struct serializer_test if(ec) co_return; - auto [ec2] = co_await sink.commit_eof(); + auto [ec2] = co_await sink.commit_eof(0); if(ec2) co_return; @@ -1010,8 +1058,8 @@ struct serializer_test response res; res.set_chunked(true); res.set(field::content_type, "text/plain"); + sr.set_message(res); auto sink = sr.sink_for(ws); - sr.start_stream(res); capy::mutable_buffer arr[16]; auto bufs = sink.prepare(arr); @@ -1024,7 +1072,7 @@ struct serializer_test if(ec) co_return; - auto [ec2] = co_await sink.commit_eof(); + auto [ec2] = co_await sink.commit_eof(0); if(ec2) co_return; @@ -1037,6 +1085,132 @@ struct serializer_test BOOST_TEST(r.success); } + //-------------------------------------------- + // WriteSink direct tests + //-------------------------------------------- + + void + testWriteSinkMultiWrite() + { + // Multiple writes, Content-Length + { + response res( + "HTTP/1.1 200 OK\r\n" + "Server: test\r\n" + "Content-Length: 10\r\n" + "\r\n"); + capy::test::fuse f; + auto r = f.armed( + [this, &res] + (capy::test::fuse& f) -> capy::task<> + { + capy::test::write_stream ws(f); + serializer sr(cfg_); + sr.set_message(res); + auto sink = sr.sink_for(ws); + + std::string_view s1("Hello"); + auto [ec1, n1] = co_await sink.write( + capy::make_buffer(s1)); + if(ec1) + co_return; + BOOST_TEST_EQ(n1, 5u); + + std::string_view s2(", Bob"); + auto [ec2, n2] = co_await sink.write_eof( + capy::make_buffer(s2)); + if(ec2) + co_return; + BOOST_TEST_EQ(n2, 5u); + + BOOST_TEST(sr.is_done()); + BOOST_TEST(ws.data().find("Hello, Bob") != + std::string::npos); + }); + BOOST_TEST(r.success); + } + + // Multiple writes, chunked + { + response res( + "HTTP/1.1 200 OK\r\n" + "Server: test\r\n" + "Transfer-Encoding: chunked\r\n" + "\r\n"); + capy::test::fuse f; + auto r = f.armed( + [this, &res] + (capy::test::fuse& f) -> capy::task<> + { + capy::test::write_stream ws(f); + serializer sr(cfg_); + sr.set_message(res); + auto sink = sr.sink_for(ws); + + std::string_view s1("Hello"); + auto [ec1, n1] = co_await sink.write( + capy::make_buffer(s1)); + if(ec1) + co_return; + + std::string_view s2("World"); + auto [ec2, n2] = co_await sink.write( + capy::make_buffer(s2)); + if(ec2) + co_return; + + auto [ec3] = co_await sink.write_eof(); + if(ec3) + co_return; + + BOOST_TEST(sr.is_done()); + BOOST_TEST(ws.data().find("0\r\n\r\n") != + std::string::npos); + }); + BOOST_TEST(r.success); + } + } + + void + testWriteSinkLargeBody() + { + // Large body, Content-Length + { + std::string body(13370, '*'); + auto s = sink_serialize( + "HTTP/1.1 200 OK\r\n" + "Server: test\r\n" + "Content-Length: 13370\r\n" + "\r\n", + body); + BOOST_TEST(s == + "HTTP/1.1 200 OK\r\n" + "Server: test\r\n" + "Content-Length: 13370\r\n" + "\r\n" + body); + } + + // Large body, chunked + { + std::string body(13370, '*'); + auto s = sink_serialize( + "HTTP/1.1 200 OK\r\n" + "Server: test\r\n" + "Transfer-Encoding: chunked\r\n" + "\r\n", + body); + core::string_view sv(s); + core::string_view hdr = + "HTTP/1.1 200 OK\r\n" + "Server: test\r\n" + "Transfer-Encoding: chunked\r\n" + "\r\n"; + BOOST_TEST(sv.starts_with(hdr)); + sv.remove_prefix(hdr.size()); + check_chunked_body(sv, body); + } + } + //-------------------------------------------- // any_buffer_sink wrapper tests (WriteSink) //-------------------------------------------- @@ -1052,8 +1226,8 @@ struct serializer_test response res; res.set_payload_size(13); + sr.set_message(res); auto sink = sr.sink_for(ws); - sr.start_stream(res); capy::any_buffer_sink abs(sink); @@ -1087,14 +1261,14 @@ struct serializer_test response res; res.set_payload_size(5); + sr.set_message(res); auto sink = sr.sink_for(ws); - sr.start_stream(res); capy::any_buffer_sink abs(sink); std::string_view body = "hello"; - auto [ec, n] = co_await abs.write( - capy::make_buffer(body), true); + auto [ec, n] = co_await abs.write_eof( + capy::make_buffer(body)); if(ec) co_return; @@ -1127,6 +1301,10 @@ struct serializer_test testSinkContentLength(); testSinkChunked(); + // WriteSink direct tests + testWriteSinkMultiWrite(); + testWriteSinkLargeBody(); + // any_buffer_sink wrapper tests (WriteSink) testAnyBufferSinkWrite(); testAnyBufferSinkWriteWithEof(); diff --git a/test/unit/server/accepts.cpp b/test/unit/server/accepts.cpp new file mode 100644 index 00000000..f2e75958 --- /dev/null +++ b/test/unit/server/accepts.cpp @@ -0,0 +1,354 @@ +// +// 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 http://www.boost.org/LICENSE_1_0.txt) +// +// Official repository: https://github.com/cppalliance/http +// + +// Test that header file is self-contained. +#include + +#include +#include + +#include "test_suite.hpp" + +namespace boost { +namespace http { + +struct accepts_test +{ + void + testType() + { + // No Accept header returns first offered + { + request req( method::get, "/" ); + accepts ac( req ); + BOOST_TEST( ac.type({ "html", "json" }) + == "html" ); + } + + // Accept: text/html + { + request req( method::get, "/" ); + req.set( field::accept, "text/html" ); + accepts ac( req ); + BOOST_TEST( ac.type({ "html" }) + == "html" ); + BOOST_TEST( ac.type({ "text/html" }) + == "text/html" ); + BOOST_TEST( ac.type({ "json" }).empty() ); + } + + // Accept: text/*, application/json + { + request req( method::get, "/" ); + req.set( field::accept, + "text/*, application/json" ); + accepts ac( req ); + BOOST_TEST( ac.type({ "html" }) + == "html" ); + BOOST_TEST( ac.type({ "text/html" }) + == "text/html" ); + BOOST_TEST( ac.type({ "json", "text" }) + == "json" ); + BOOST_TEST( ac.type({ "application/json" }) + == "application/json" ); + } + + // Accept: text/*, application/json - no match + { + request req( method::get, "/" ); + req.set( field::accept, + "text/*, application/json" ); + accepts ac( req ); + BOOST_TEST( ac.type({ "png" }).empty() ); + } + + // Accept: text/*;q=.5, application/json + { + request req( method::get, "/" ); + req.set( field::accept, + "text/*;q=.5, application/json" ); + accepts ac( req ); + BOOST_TEST( ac.type({ "html", "json" }) + == "json" ); + } + + // Accept: */* + { + request req( method::get, "/" ); + req.set( field::accept, "*/*" ); + accepts ac( req ); + BOOST_TEST( ac.type({ "json" }) + == "json" ); + } + + // Empty offered list + { + request req( method::get, "/" ); + req.set( field::accept, "text/html" ); + accepts ac( req ); + BOOST_TEST( ac.type({}).empty() ); + } + } + + void + testTypes() + { + // No Accept header + { + request req( method::get, "/" ); + accepts ac( req ); + auto v = ac.types(); + BOOST_TEST( v.empty() ); + } + + // Single type + { + request req( method::get, "/" ); + req.set( field::accept, "text/html" ); + accepts ac( req ); + auto v = ac.types(); + BOOST_TEST( v.size() == 1 ); + if( ! v.empty() ) + BOOST_TEST( v[0] == "text/html" ); + } + + // Multiple types sorted by quality + { + request req( method::get, "/" ); + req.set( field::accept, + "text/plain;q=0.5, " + "application/json, " + "text/html;q=0.9" ); + accepts ac( req ); + auto v = ac.types(); + BOOST_TEST( v.size() == 3 ); + if( v.size() >= 3 ) + { + BOOST_TEST( v[0] == "application/json" ); + BOOST_TEST( v[1] == "text/html" ); + BOOST_TEST( v[2] == "text/plain" ); + } + } + + // q=0 entries filtered out + { + request req( method::get, "/" ); + req.set( field::accept, + "text/html, text/plain;q=0" ); + accepts ac( req ); + auto v = ac.types(); + BOOST_TEST( v.size() == 1 ); + if( ! v.empty() ) + BOOST_TEST( v[0] == "text/html" ); + } + } + + void + testEncoding() + { + // No Accept-Encoding returns first offered + { + request req( method::get, "/" ); + accepts ac( req ); + BOOST_TEST( ac.encoding({ "gzip", "deflate" }) + == "gzip" ); + } + + // Accept-Encoding: gzip, deflate + { + request req( method::get, "/" ); + req.set( field::accept_encoding, + "gzip, deflate" ); + accepts ac( req ); + BOOST_TEST( ac.encoding({ "gzip", "deflate" }) + == "gzip" ); + BOOST_TEST( ac.encoding({ "deflate" }) + == "deflate" ); + BOOST_TEST( ac.encoding({ "br" }).empty() ); + } + + // Accept-Encoding with quality + { + request req( method::get, "/" ); + req.set( field::accept_encoding, + "gzip;q=0.5, br" ); + accepts ac( req ); + BOOST_TEST( ac.encoding({ "gzip", "br" }) + == "br" ); + } + + // Wildcard + { + request req( method::get, "/" ); + req.set( field::accept_encoding, "*" ); + accepts ac( req ); + BOOST_TEST( ac.encoding({ "br" }) + == "br" ); + } + } + + void + testEncodings() + { + // No header + { + request req( method::get, "/" ); + accepts ac( req ); + BOOST_TEST( ac.encodings().empty() ); + } + + // Sorted by quality + { + request req( method::get, "/" ); + req.set( field::accept_encoding, + "deflate;q=0.5, gzip, br;q=0.8" ); + accepts ac( req ); + auto v = ac.encodings(); + BOOST_TEST( v.size() == 3 ); + if( v.size() >= 3 ) + { + BOOST_TEST( v[0] == "gzip" ); + BOOST_TEST( v[1] == "br" ); + BOOST_TEST( v[2] == "deflate" ); + } + } + } + + void + testCharset() + { + // No header returns first offered + { + request req( method::get, "/" ); + accepts ac( req ); + BOOST_TEST( ac.charset({ "utf-8", "iso-8859-1" }) + == "utf-8" ); + } + + // Accept-Charset: utf-8, iso-8859-1;q=0.2, utf-7;q=0.5 + { + request req( method::get, "/" ); + req.set( field::accept_charset, + "utf-8, iso-8859-1;q=0.2, utf-7;q=0.5" ); + accepts ac( req ); + BOOST_TEST( ac.charset({ "utf-8", "utf-7" }) + == "utf-8" ); + BOOST_TEST( ac.charset({ "utf-7", "iso-8859-1" }) + == "utf-7" ); + } + } + + void + testCharsets() + { + request req( method::get, "/" ); + req.set( field::accept_charset, + "utf-8, iso-8859-1;q=0.2, utf-7;q=0.5" ); + accepts ac( req ); + auto v = ac.charsets(); + BOOST_TEST( v.size() == 3 ); + if( v.size() >= 3 ) + { + BOOST_TEST( v[0] == "utf-8" ); + BOOST_TEST( v[1] == "utf-7" ); + BOOST_TEST( v[2] == "iso-8859-1" ); + } + } + + void + testLanguage() + { + // No header returns first offered + { + request req( method::get, "/" ); + accepts ac( req ); + BOOST_TEST( ac.language({ "en", "fr" }) + == "en" ); + } + + // Accept-Language: en;q=0.8, es, pt + { + request req( method::get, "/" ); + req.set( field::accept_language, + "en;q=0.8, es, pt" ); + accepts ac( req ); + BOOST_TEST( ac.language({ "es", "en" }) + == "es" ); + BOOST_TEST( ac.language({ "en" }) + == "en" ); + } + + // Prefix matching: Accept-Language: en + // should match en-US + { + request req( method::get, "/" ); + req.set( field::accept_language, "en" ); + accepts ac( req ); + BOOST_TEST( ac.language({ "en-US" }) + == "en-US" ); + } + + // Prefix matching: Accept-Language: en-US + // should match en + { + request req( method::get, "/" ); + req.set( field::accept_language, "en-US" ); + accepts ac( req ); + BOOST_TEST( ac.language({ "en" }) + == "en" ); + } + + // Exact match has higher specificity than prefix + { + request req( method::get, "/" ); + req.set( field::accept_language, "en" ); + accepts ac( req ); + // "en" matches exactly (s=4), "en-US" only via prefix (s=1) + BOOST_TEST( ac.language({ "en-US", "en" }) + == "en" ); + } + } + + void + testLanguages() + { + request req( method::get, "/" ); + req.set( field::accept_language, + "en;q=0.8, es, pt" ); + accepts ac( req ); + auto v = ac.languages(); + BOOST_TEST( v.size() == 3 ); + if( v.size() >= 3 ) + { + BOOST_TEST( v[0] == "es" ); + BOOST_TEST( v[1] == "pt" ); + BOOST_TEST( v[2] == "en" ); + } + } + + void + run() + { + testType(); + testTypes(); + testEncoding(); + testEncodings(); + testCharset(); + testCharsets(); + testLanguage(); + testLanguages(); + } +}; + +TEST_SUITE( + accepts_test, + "boost.http.server.accepts"); + +} // http +} // boost diff --git a/test/unit/server/flat_router.cpp b/test/unit/server/any_router.cpp similarity index 87% rename from test/unit/server/flat_router.cpp rename to test/unit/server/any_router.cpp index 2fa1c8be..0458d82d 100644 --- a/test/unit/server/flat_router.cpp +++ b/test/unit/server/any_router.cpp @@ -1,5 +1,5 @@ // -// Copyright (c) 2026 Vinnie Falco (vinnie dot falco at gmail dot com) +// Copyright (c) 2026 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 http://www.boost.org/LICENSE_1_0.txt) @@ -8,11 +8,6 @@ // // Test that header file is self-contained. -#include - -// Full functional tests are in beast2/test/unit/server/router.cpp - -#include #include #include @@ -21,10 +16,10 @@ namespace boost { namespace http { -struct flat_router_test +struct any_router_test { - using params = route_params_base; - using test_router = basic_router; + using params = route_params; + using test_router = router; void testCopyConstruction() { @@ -36,15 +31,15 @@ struct flat_router_test co_return route_result{}; }); - flat_router fr1(std::move(r)); - flat_router fr2(fr1); + router<> ar1(r); + router<> ar2(ar1); params req; - capy::test::run_blocking()(fr1.dispatch( + capy::test::run_blocking()(ar1.dispatch( http::method::get, urls::url_view("/"), req)); BOOST_TEST_EQ(*counter, 1); - capy::test::run_blocking()(fr2.dispatch( + capy::test::run_blocking()(ar2.dispatch( http::method::get, urls::url_view("/"), req)); BOOST_TEST_EQ(*counter, 2); } @@ -59,23 +54,23 @@ struct flat_router_test co_return route_result{}; }); - flat_router fr1(std::move(r)); + router<> ar1(r); test_router r2; r2.all("/", [](params&) -> route_task { co_return route_result{}; }); - flat_router fr2(std::move(r2)); + router<> ar2(r2); - fr2 = fr1; + ar2 = ar1; params req; - capy::test::run_blocking()(fr1.dispatch( + capy::test::run_blocking()(ar1.dispatch( http::method::get, urls::url_view("/"), req)); BOOST_TEST_EQ(*counter, 1); - capy::test::run_blocking()(fr2.dispatch( + capy::test::run_blocking()(ar2.dispatch( http::method::get, urls::url_view("/"), req)); BOOST_TEST_EQ(*counter, 2); } @@ -90,12 +85,12 @@ struct flat_router_test co_return route_result{}; }); - flat_router fr1; // default construct - flat_router fr2(std::move(r)); - fr1 = fr2; // assign to default-constructed + router<> ar1; // default construct + router<> ar2(r); + ar1 = ar2; // assign to default-constructed params req; - capy::test::run_blocking()(fr1.dispatch( + capy::test::run_blocking()(ar1.dispatch( http::method::get, urls::url_view("/"), req)); BOOST_TEST_EQ(*counter, 1); } @@ -119,10 +114,8 @@ struct flat_router_test co_return route_done; }); - flat_router fr(std::move(r)); - params req; - capy::test::run_blocking()(fr.dispatch( + capy::test::run_blocking()(r.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); @@ -153,10 +146,8 @@ struct flat_router_test co_return route_done; }); - flat_router fr(std::move(r)); - params req; - capy::test::run_blocking()(fr.dispatch( + capy::test::run_blocking()(r.dispatch( http::method::options, urls::url_view("/test"), req)); BOOST_TEST(explicit_called); BOOST_TEST(!fallback_called); @@ -179,10 +170,8 @@ struct flat_router_test co_return route_done; }); - flat_router fr(std::move(r)); - params req; - capy::test::run_blocking()(fr.dispatch( + capy::test::run_blocking()(r.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); @@ -213,10 +202,8 @@ struct flat_router_test co_return route_done; }); - flat_router fr(std::move(r)); - params req; - capy::test::run_blocking()(fr.dispatch( + capy::test::run_blocking()(r.dispatch( http::method::options, urls::url_view("*"), req)); // Should contain all registered methods BOOST_TEST(captured_allow.find("GET") != std::string::npos); @@ -255,10 +242,8 @@ struct flat_router_test co_return route_done; }); - flat_router fr(std::move(r)); - params req; - capy::test::run_blocking()(fr.dispatch( + capy::test::run_blocking()(r.dispatch( http::method::get, urls::url_view("/users/123"), req)); BOOST_TEST(handler_called); @@ -280,10 +265,8 @@ struct flat_router_test co_return route_done; }); - flat_router fr(std::move(r)); - params req; - capy::test::run_blocking()(fr.dispatch( + capy::test::run_blocking()(r.dispatch( http::method::get, urls::url_view("/users/42/posts/99"), req)); BOOST_TEST_EQ(captured_user, "42"); @@ -303,10 +286,8 @@ struct flat_router_test co_return route_done; }); - flat_router fr(std::move(r)); - params req; - capy::test::run_blocking()(fr.dispatch( + capy::test::run_blocking()(r.dispatch( http::method::get, urls::url_view("/files/a/b/c.txt"), req)); BOOST_TEST_EQ(captured_path, "a/b/c.txt"); @@ -326,12 +307,10 @@ struct flat_router_test co_return route_done; }); - flat_router fr(std::move(r)); - // With optional group { params req; - capy::test::run_blocking()(fr.dispatch( + capy::test::run_blocking()(r.dispatch( http::method::get, urls::url_view("/api/v2"), req)); BOOST_TEST_EQ(call_count, 1); BOOST_TEST_EQ(captured_version, "2"); @@ -341,7 +320,7 @@ struct flat_router_test { params req; captured_version.clear(); - capy::test::run_blocking()(fr.dispatch( + capy::test::run_blocking()(r.dispatch( http::method::get, urls::url_view("/api"), req)); BOOST_TEST_EQ(call_count, 2); BOOST_TEST(captured_version.empty()); @@ -361,10 +340,8 @@ struct flat_router_test co_return route_done; }); - flat_router fr(std::move(r)); - params req; - capy::test::run_blocking()(fr.dispatch( + capy::test::run_blocking()(r.dispatch( http::method::get, urls::url_view("/items/abc-def-123"), req)); BOOST_TEST_EQ(captured_id, "abc-def-123"); @@ -382,12 +359,10 @@ struct flat_router_test co_return route_done; }); - flat_router fr(std::move(r)); - // Wrong path { params req; - capy::test::run_blocking()(fr.dispatch( + capy::test::run_blocking()(r.dispatch( http::method::get, urls::url_view("/posts/123"), req)); BOOST_TEST(!handler_called); } @@ -408,10 +383,8 @@ struct flat_router_test co_return route_done; }); - flat_router fr(std::move(r)); - params req; - capy::test::run_blocking()(fr.dispatch( + capy::test::run_blocking()(r.dispatch( http::method::get, urls::url_view("/john/myrepo/blob/main/src/index.js"), req)); @@ -437,12 +410,10 @@ struct flat_router_test co_return route_done; }); - flat_router fr(std::move(r)); - // With extension { params req; - capy::test::run_blocking()(fr.dispatch( + capy::test::run_blocking()(r.dispatch( http::method::get, urls::url_view("/file.txt"), req)); BOOST_TEST_EQ(call_count, 1); BOOST_TEST_EQ(captured_ext, "txt"); @@ -452,7 +423,7 @@ struct flat_router_test { params req; captured_ext.clear(); - capy::test::run_blocking()(fr.dispatch( + capy::test::run_blocking()(r.dispatch( http::method::get, urls::url_view("/file"), req)); BOOST_TEST_EQ(call_count, 2); BOOST_TEST(captured_ext.empty()); @@ -467,17 +438,15 @@ struct flat_router_test 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( + capy::test::run_blocking()(r.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( + capy::test::run_blocking()(r.dispatch( http::method::get, urls::url_view("/b"), req)); BOOST_TEST_EQ(req.params.size(), 0u); } @@ -494,11 +463,9 @@ struct flat_router_test 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( + capy::test::run_blocking()(r.dispatch( http::method::get, urls::url_view("/users/john%20doe"), req)); BOOST_TEST_EQ(captured_name, "john doe"); @@ -522,10 +489,8 @@ struct flat_router_test co_return route_done; }); - flat_router fr(std::move(r)); - params req; - capy::test::run_blocking()(fr.dispatch( + capy::test::run_blocking()(r.dispatch( http::method::get, urls::url_view("/api/v1/users"), req)); BOOST_TEST_EQ(captured_base, "/api"); @@ -550,10 +515,8 @@ struct flat_router_test return r2; }()); - flat_router fr(std::move(r)); - params req; - capy::test::run_blocking()(fr.dispatch( + capy::test::run_blocking()(r.dispatch( http::method::get, urls::url_view("/api/v1/users"), req)); BOOST_TEST_EQ(captured_base, "/api/v1"); @@ -582,10 +545,8 @@ struct flat_router_test return r2; }()); - flat_router fr(std::move(r)); - params req; - capy::test::run_blocking()(fr.dispatch( + capy::test::run_blocking()(r.dispatch( http::method::get, urls::url_view("/api/v1/users/123"), req)); BOOST_TEST_EQ(captured_base, "/api/v1/users"); @@ -618,10 +579,8 @@ struct flat_router_test return r2; }()); - flat_router fr(std::move(r)); - params req; - capy::test::run_blocking()(fr.dispatch( + capy::test::run_blocking()(r.dispatch( http::method::get, urls::url_view("/a/b/c/d/e/f"), req)); BOOST_TEST_EQ(captured_base, "/a/b/c/d"); @@ -650,10 +609,8 @@ struct flat_router_test return r2; }()); - flat_router fr(std::move(r)); - params req; - capy::test::run_blocking()(fr.dispatch( + capy::test::run_blocking()(r.dispatch( http::method::get, urls::url_view("/api/v1/users/42"), req)); BOOST_TEST_EQ(captured_base, "/api/v1/users/42"); @@ -685,10 +642,8 @@ struct flat_router_test return r2; }()); - flat_router fr(std::move(r)); - params req; - capy::test::run_blocking()(fr.dispatch( + capy::test::run_blocking()(r.dispatch( http::method::get, urls::url_view("/very/long/path/prefix/that/exceeds/small/string/optimization/tail"), req)); @@ -783,13 +738,11 @@ struct flat_router_test 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( + capy::test::run_blocking()(r.dispatch( http::method::get, urls::url_view("/Api"), req)); BOOST_TEST(handler_called); } @@ -798,7 +751,7 @@ struct flat_router_test { params req; handler_called = false; - capy::test::run_blocking()(fr.dispatch( + capy::test::run_blocking()(r.dispatch( http::method::get, urls::url_view("/api"), req)); BOOST_TEST(!handler_called); } @@ -816,13 +769,11 @@ struct flat_router_test 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( + capy::test::run_blocking()(r.dispatch( http::method::get, urls::url_view("/api"), req)); BOOST_TEST(handler_called); } @@ -831,7 +782,7 @@ struct flat_router_test { params req; handler_called = false; - capy::test::run_blocking()(fr.dispatch( + capy::test::run_blocking()(r.dispatch( http::method::get, urls::url_view("/API"), req)); BOOST_TEST(handler_called); } @@ -849,13 +800,11 @@ struct flat_router_test 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( + capy::test::run_blocking()(r.dispatch( http::method::get, urls::url_view("/api/users"), req)); BOOST_TEST(handler_called); } @@ -864,7 +813,7 @@ struct flat_router_test { params req; handler_called = false; - capy::test::run_blocking()(fr.dispatch( + capy::test::run_blocking()(r.dispatch( http::method::get, urls::url_view("/api/users/"), req)); BOOST_TEST(!handler_called); } @@ -882,13 +831,11 @@ struct flat_router_test 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( + capy::test::run_blocking()(r.dispatch( http::method::get, urls::url_view("/api/users"), req)); BOOST_TEST(handler_called); } @@ -897,7 +844,7 @@ struct flat_router_test { params req; handler_called = false; - capy::test::run_blocking()(fr.dispatch( + capy::test::run_blocking()(r.dispatch( http::method::get, urls::url_view("/api/users/"), req)); BOOST_TEST(handler_called); } @@ -919,10 +866,8 @@ struct flat_router_test co_return route_done; }); - flat_router fr(std::move(r)); - params req; - capy::test::run_blocking()(fr.dispatch( + capy::test::run_blocking()(r.dispatch( http::method::get, urls::url_view("/path:literal"), req)); BOOST_TEST(handler_called); BOOST_TEST_EQ(req.params.size(), 0u); @@ -940,10 +885,8 @@ struct flat_router_test co_return route_done; }); - flat_router fr(std::move(r)); - params req; - capy::test::run_blocking()(fr.dispatch( + capy::test::run_blocking()(r.dispatch( http::method::get, urls::url_view("/path*star"), req)); BOOST_TEST(handler_called); } @@ -960,11 +903,9 @@ struct flat_router_test co_return route_done; }); - flat_router fr(std::move(r)); - // Use percent-encoded braces: { = %7B, } = %7D params req; - capy::test::run_blocking()(fr.dispatch( + capy::test::run_blocking()(r.dispatch( http::method::get, urls::url_view("/path%7Bbrace%7D"), req)); BOOST_TEST(handler_called); } @@ -981,10 +922,8 @@ struct flat_router_test co_return route_done; }); - flat_router fr(std::move(r)); - params req; - capy::test::run_blocking()(fr.dispatch( + capy::test::run_blocking()(r.dispatch( http::method::get, urls::url_view("/path%5Cslash"), req)); BOOST_TEST(handler_called); } @@ -1005,10 +944,8 @@ struct flat_router_test co_return route_done; }); - flat_router fr(std::move(r)); - params req; - capy::test::run_blocking()(fr.dispatch( + capy::test::run_blocking()(r.dispatch( http::method::get, urls::url_view("/items/123"), req)); BOOST_TEST_EQ(captured_value, "123"); } @@ -1025,10 +962,8 @@ struct flat_router_test co_return route_done; }); - flat_router fr(std::move(r)); - params req; - capy::test::run_blocking()(fr.dispatch( + capy::test::run_blocking()(r.dispatch( http::method::get, urls::url_view("/files/a/b/c.txt"), req)); BOOST_TEST_EQ(captured_value, "a/b/c.txt"); } @@ -1050,10 +985,8 @@ struct flat_router_test co_return route_done; }); - flat_router fr(std::move(r)); - params req; - capy::test::run_blocking()(fr.dispatch( + capy::test::run_blocking()(r.dispatch( http::method::get, urls::url_view("/LAX-JFK"), req)); BOOST_TEST_EQ(from_val, "LAX"); BOOST_TEST_EQ(to_val, "JFK"); @@ -1069,10 +1002,8 @@ struct flat_router_test co_return route_done; }); - flat_router fr(std::move(r)); - params req; - capy::test::run_blocking()(fr.dispatch( + capy::test::run_blocking()(r.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"); @@ -1091,10 +1022,8 @@ struct flat_router_test co_return route_done; }); - flat_router fr(std::move(r)); - params req; - capy::test::run_blocking()(fr.dispatch( + capy::test::run_blocking()(r.dispatch( http::method::get, urls::url_view("/a//b"), req)); BOOST_TEST(handler_called); } @@ -1111,10 +1040,8 @@ struct flat_router_test co_return route_done; }); - flat_router fr(std::move(r)); - params req; - capy::test::run_blocking()(fr.dispatch( + capy::test::run_blocking()(r.dispatch( http::method::get, urls::url_view("/"), req)); BOOST_TEST(handler_called); } @@ -1131,12 +1058,10 @@ struct flat_router_test co_return route_done; }); - flat_router fr(std::move(r)); - // All levels { params req; - capy::test::run_blocking()(fr.dispatch( + capy::test::run_blocking()(r.dispatch( http::method::get, urls::url_view("/a/b/c"), req)); BOOST_TEST_EQ(call_count, 1); } @@ -1144,7 +1069,7 @@ struct flat_router_test // Two levels { params req; - capy::test::run_blocking()(fr.dispatch( + capy::test::run_blocking()(r.dispatch( http::method::get, urls::url_view("/a/b"), req)); BOOST_TEST_EQ(call_count, 2); } @@ -1152,7 +1077,7 @@ struct flat_router_test // One level { params req; - capy::test::run_blocking()(fr.dispatch( + capy::test::run_blocking()(r.dispatch( http::method::get, urls::url_view("/a"), req)); BOOST_TEST_EQ(call_count, 3); } @@ -1170,12 +1095,10 @@ struct flat_router_test co_return route_done; }); - flat_router fr(std::move(r)); - // Both groups { params req; - capy::test::run_blocking()(fr.dispatch( + capy::test::run_blocking()(r.dispatch( http::method::get, urls::url_view("/a/b"), req)); BOOST_TEST_EQ(call_count, 1); } @@ -1183,7 +1106,7 @@ struct flat_router_test // First only { params req; - capy::test::run_blocking()(fr.dispatch( + capy::test::run_blocking()(r.dispatch( http::method::get, urls::url_view("/a"), req)); BOOST_TEST_EQ(call_count, 2); } @@ -1191,7 +1114,7 @@ struct flat_router_test // Second only { params req; - capy::test::run_blocking()(fr.dispatch( + capy::test::run_blocking()(r.dispatch( http::method::get, urls::url_view("/b"), req)); BOOST_TEST_EQ(call_count, 3); } @@ -1199,7 +1122,7 @@ struct flat_router_test // Neither { params req; - capy::test::run_blocking()(fr.dispatch( + capy::test::run_blocking()(r.dispatch( http::method::get, urls::url_view(""), req)); BOOST_TEST_EQ(call_count, 4); } @@ -1217,11 +1140,9 @@ struct flat_router_test co_return route_done; }); - flat_router fr(std::move(r)); - // Empty param value - should not match params req; - capy::test::run_blocking()(fr.dispatch( + capy::test::run_blocking()(r.dispatch( http::method::get, urls::url_view("/users//posts"), req)); BOOST_TEST(!handler_called); } @@ -1238,11 +1159,9 @@ struct flat_router_test co_return route_done; }); - flat_router fr(std::move(r)); - // Empty wildcard value - should not match params req; - capy::test::run_blocking()(fr.dispatch( + capy::test::run_blocking()(r.dispatch( http::method::get, urls::url_view("/files/"), req)); BOOST_TEST(!handler_called); } @@ -1316,8 +1235,8 @@ struct flat_router_test }; TEST_SUITE( - flat_router_test, - "boost.http.server.flat_router"); + any_router_test, + "boost.http.server.any_router"); } // http } // boost diff --git a/test/unit/server/cors.cpp b/test/unit/server/cors.cpp index 69073c70..cbe1f5f1 100644 --- a/test/unit/server/cors.cpp +++ b/test/unit/server/cors.cpp @@ -1,5 +1,5 @@ // -// Copyright (c) 2025 Vinnie Falco (vinnie dot falco at gmail dot com) +// 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 http://www.boost.org/LICENSE_1_0.txt) @@ -9,14 +9,22 @@ // Test that header file is self-contained. #include +#include #include "src/rfc/detail/rules.hpp" +#include +#include +#include +#include +#include + #include "test_suite.hpp" namespace boost { namespace http { #if 0 +// DO NOT REMOVE THIS class field_item { public: @@ -88,8 +96,59 @@ struct list struct cors_test { + void + testPreflight() + { + http::cors_options opts; + opts.preFlightContinue = true; + + http::router r; + r.use(http::cors(opts)); + r.use([](http::route_params& rp) -> http::route_task { + auto [ec] = co_await rp.send("ok"); + if(ec) + co_return http::route_error(ec); + co_return http::route_done; + }); + auto parser_cfg = http::make_parser_config( + http::parser_config{true}); + auto serializer_cfg = http::make_serializer_config( + http::serializer_config{}); + + capy::test::fuse f; + auto [client, server] = + capy::test::make_stream_pair(f); + + http::http_worker worker( + server, std::move(r), + parser_cfg, serializer_cfg); + + client.provide( + "OPTIONS /test HTTP/1.1\r\n" + "Host: localhost\r\n" + "Connection: close\r\n" + "\r\n"); + client.close(); + + capy::test::run_blocking()( + worker.do_http_session()); + + auto response = client.data(); + BOOST_TEST(response.find("200") != + std::string_view::npos); + BOOST_TEST(response.find( + "Access-Control-Allow-Origin: *") != + std::string_view::npos); + BOOST_TEST(response.find( + "Access-Control-Allow-Methods: " + "GET,HEAD,PUT,PATCH,POST,DELETE") != + std::string_view::npos); + } + void run() { + testPreflight(); + #if 0 list v({ field::access_control_allow_origin, diff --git a/test/unit/server/http_worker.cpp b/test/unit/server/http_worker.cpp new file mode 100644 index 00000000..7864d0d0 --- /dev/null +++ b/test/unit/server/http_worker.cpp @@ -0,0 +1,31 @@ +// +// Copyright (c) 2026 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 http://www.boost.org/LICENSE_1_0.txt) +// +// Official repository: https://github.com/cppalliance/http +// + +// Test that header file is self-contained. +#include + +#include "test_helpers.hpp" + +namespace boost { +namespace http { + +struct http_worker_test +{ + void + run() + { + } +}; + +TEST_SUITE( + http_worker_test, + "boost.http.http_worker"); + +} // http +} // boost diff --git a/test/unit/server/route_handler.cpp b/test/unit/server/route_handler.cpp index d44a531c..7b236c97 100644 --- a/test/unit/server/route_handler.cpp +++ b/test/unit/server/route_handler.cpp @@ -1,5 +1,5 @@ // -// Copyright (c) 2025 Vinnie Falco (vinnie dot falco at gmail dot com) +// 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 http://www.boost.org/LICENSE_1_0.txt) diff --git a/test/unit/server/router.cpp b/test/unit/server/router.cpp index d9d896c0..ecccea2e 100644 --- a/test/unit/server/router.cpp +++ b/test/unit/server/router.cpp @@ -1,5 +1,5 @@ // -// Copyright (c) 2025 Vinnie Falco (vinnie dot falco at gmail dot com) +// 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 http://www.boost.org/LICENSE_1_0.txt) @@ -8,11 +8,9 @@ // // Test that header file is self-contained. -#include -#include #include -#include +#include #include #include "test_suite.hpp" @@ -21,8 +19,28 @@ namespace http { struct router_test { - using params = route_params_base; - using test_router = basic_router; + using params = route_params; + using test_router = router; + + struct derived_params : params {}; + + struct my_transform + { + template + T operator()(T&& t) const + { return std::forward(t); } + }; + + struct base_ht + { + template + T operator()(T&& t) const + { return std::forward(t); } + }; + + struct derived_ht : base_ht {}; + + struct A { int value = 0; }; //-------------------------------------------- // Simple handlers - no destructor verification @@ -106,11 +124,10 @@ struct router_test core::string_view url, route_result rv0 = route_done) { - flat_router fr(std::move(r)); params req; route_result rv; capy::test::run_blocking([&](route_result res) { rv = res; })( - fr.dispatch(http::method::get, urls::url_view(url), req)); + r.dispatch(http::method::get, urls::url_view(url), req)); BOOST_TEST(rv.what() == rv0.what()); } @@ -120,11 +137,10 @@ struct router_test core::string_view url, route_result rv0 = route_done) { - flat_router fr(std::move(r)); params req; route_result rv; capy::test::run_blocking([&](route_result res) { rv = res; })( - fr.dispatch(verb, urls::url_view(url), req)); + r.dispatch(verb, urls::url_view(url), req)); BOOST_TEST(rv.what() == rv0.what()); } @@ -134,11 +150,10 @@ struct router_test core::string_view url, route_result rv0 = route_done) { - flat_router fr(std::move(r)); params req; route_result rv; capy::test::run_blocking([&](route_result res) { rv = res; })( - fr.dispatch(verb, urls::url_view(url), req)); + r.dispatch(verb, urls::url_view(url), req)); BOOST_TEST(rv.what() == rv0.what()); } @@ -352,8 +367,7 @@ struct router_test r.use("/a", make_deep(make_deep, detail::router_base::max_path_depth - 2)); // Should not throw - flat_router fr(std::move(r)); - (void)fr; + (void)r; } // max_path_depth + 1 should throw @@ -411,10 +425,9 @@ struct router_test { test_router r; r.use(h_next); - flat_router fr(std::move(r)); params req; BOOST_TEST_THROWS( - capy::test::run_blocking()(fr.dispatch( + capy::test::run_blocking()(r.dispatch( http::method::unknown, urls::url_view("/"), req)), std::invalid_argument); } @@ -423,10 +436,9 @@ struct router_test { test_router r; r.use(h_next); - flat_router fr(std::move(r)); params req; BOOST_TEST_THROWS( - capy::test::run_blocking()(fr.dispatch( + capy::test::run_blocking()(r.dispatch( "", urls::url_view("/"), req)), std::invalid_argument); } @@ -444,6 +456,108 @@ struct router_test { test_router r; r.add(GET, "/auth/login", h_next); check(r, GET, "/auth%2flogin", route_next); } } + void testCrossTypeConstruction() + { + // HT constructible from OtherHT (identity -> identity) + { + router r1; + r1.use(h_send); + router r2(std::move(r1)); + check(r2, "/"); + } + + // different P, same default HT + { + router r1; + r1.use([](derived_params&) -> route_task + { co_return route_result{}; }); + router r2(std::move(r1)); + (void)r2; + } + + // HT not constructible from OtherHT, but default constructible + { + router r1; + r1.use(h_send); + router r2(std::move(r1)); + (void)r2; + } + + // HT constructible from OtherHT (derived -> base) + { + router r1; + r1.use(h_send); + router r2(std::move(r1)); + (void)r2; + } + + // both P and HT differ + { + router r1; + r1.use([](derived_params&) -> route_task + { co_return route_result{}; }); + router r2(std::move(r1)); + (void)r2; + } + } + + void testDynamicTransform() + { + // A&& parameter with A present + { + test_router base; + auto r = base.with_transform( + detail::dynamic_transform{}); + r.use([](params& p) -> route_result + { + p.route_data.insert(A{42}); + return route_next; + }); + r.use([](params&) -> route_result + { + return route_next; + }); + r.use([](params&, A&& a) -> route_result + { + BOOST_TEST_EQ(a.value, 42); + return route_done; + }); + check(base, "/"); + } + + // A* parameter with A present + { + test_router base; + auto r = base.with_transform( + detail::dynamic_transform{}); + r.use([](params& p) -> route_result + { + p.route_data.insert(A{99}); + return route_next; + }); + r.use([](params&, A* a) -> route_result + { + BOOST_TEST(a != nullptr); + BOOST_TEST_EQ(a->value, 99); + return route_done; + }); + check(base, "/"); + } + + // A* parameter with A absent + { + test_router base; + auto r = base.with_transform( + detail::dynamic_transform{}); + r.use([](params&, A* a) -> route_result + { + BOOST_TEST(a == nullptr); + return route_done; + }); + check(base, "/"); + } + } + void run() { testUse(); @@ -454,6 +568,8 @@ struct router_test testOptions(); testDispatch(); testPathDecoding(); + testCrossTypeConstruction(); + testDynamicTransform(); } }; diff --git a/test/unit/server/router_types.cpp b/test/unit/server/router_types.cpp index fc399680..3e225efb 100644 --- a/test/unit/server/router_types.cpp +++ b/test/unit/server/router_types.cpp @@ -8,8 +8,8 @@ // // Test that header file is self-contained. -#include -#include +#include +#include #include "test_suite.hpp" diff --git a/test/unit/server/serve_static.cpp b/test/unit/server/serve_static.cpp index 159c1d81..cb926ba4 100644 --- a/test/unit/server/serve_static.cpp +++ b/test/unit/server/serve_static.cpp @@ -1,5 +1,5 @@ // -// Copyright (c) 2025 Vinnie Falco (vinnie dot falco at gmail dot com) +// 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 http://www.boost.org/LICENSE_1_0.txt) diff --git a/test/unit/server/statuses.cpp b/test/unit/server/statuses.cpp index 9287d3ad..529b91a1 100644 --- a/test/unit/server/statuses.cpp +++ b/test/unit/server/statuses.cpp @@ -1,5 +1,5 @@ // -// Copyright (c) 2025 Vinnie Falco (vinnie dot falco at gmail dot com) +// 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 http://www.boost.org/LICENSE_1_0.txt) diff --git a/test/unit/status.cpp b/test/unit/status.cpp index cd6e5692..610e7d57 100644 --- a/test/unit/status.cpp +++ b/test/unit/status.cpp @@ -1,5 +1,5 @@ // -// Copyright (c) 2021 Vinnie Falco (vinnie dot falco at gmail dot com) +// Copyright (c) 2021 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 http://www.boost.org/LICENSE_1_0.txt) diff --git a/test/unit/test_helpers.cpp b/test/unit/test_helpers.cpp index 062c9b80..f0614c90 100644 --- a/test/unit/test_helpers.cpp +++ b/test/unit/test_helpers.cpp @@ -1,5 +1,5 @@ // -// Copyright (c) 2021 Vinnie Falco (vinnie dot falco at gmail dot com) +// Copyright (c) 2021 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 http://www.boost.org/LICENSE_1_0.txt) diff --git a/test/unit/test_helpers.hpp b/test/unit/test_helpers.hpp index f3867bef..0cb918db 100644 --- a/test/unit/test_helpers.hpp +++ b/test/unit/test_helpers.hpp @@ -1,5 +1,5 @@ // -// Copyright (c) 2021 Vinnie Falco (vinnie dot falco at gmail dot com) +// Copyright (c) 2021 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 http://www.boost.org/LICENSE_1_0.txt) diff --git a/test/unit/zlib/compression_level.cpp b/test/unit/zlib/compression_level.cpp index 461f0ab6..3f2a6aab 100644 --- a/test/unit/zlib/compression_level.cpp +++ b/test/unit/zlib/compression_level.cpp @@ -1,5 +1,5 @@ // -// Copyright (c) 2025 Vinnie Falco (vinnie dot falco at gmail dot com) +// 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 http://www.boost.org/LICENSE_1_0.txt) diff --git a/test/unit/zlib/compression_method.cpp b/test/unit/zlib/compression_method.cpp index a9250889..787004f8 100644 --- a/test/unit/zlib/compression_method.cpp +++ b/test/unit/zlib/compression_method.cpp @@ -1,5 +1,5 @@ // -// Copyright (c) 2025 Vinnie Falco (vinnie dot falco at gmail dot com) +// 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 http://www.boost.org/LICENSE_1_0.txt) diff --git a/test/unit/zlib/compression_strategy.cpp b/test/unit/zlib/compression_strategy.cpp index 1e7daf52..c90e22c0 100644 --- a/test/unit/zlib/compression_strategy.cpp +++ b/test/unit/zlib/compression_strategy.cpp @@ -1,5 +1,5 @@ // -// Copyright (c) 2025 Vinnie Falco (vinnie dot falco at gmail dot com) +// 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 http://www.boost.org/LICENSE_1_0.txt) diff --git a/test/unit/zlib/data_type.cpp b/test/unit/zlib/data_type.cpp index 708788a9..6dc66e16 100644 --- a/test/unit/zlib/data_type.cpp +++ b/test/unit/zlib/data_type.cpp @@ -1,5 +1,5 @@ // -// Copyright (c) 2025 Vinnie Falco (vinnie dot falco at gmail dot com) +// 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 http://www.boost.org/LICENSE_1_0.txt) diff --git a/test/unit/zlib/deflate.cpp b/test/unit/zlib/deflate.cpp index 87b82f24..7d92b80b 100644 --- a/test/unit/zlib/deflate.cpp +++ b/test/unit/zlib/deflate.cpp @@ -1,5 +1,5 @@ // -// Copyright (c) 2025 Vinnie Falco (vinnie dot falco at gmail dot com) +// 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 http://www.boost.org/LICENSE_1_0.txt) diff --git a/test/unit/zlib/flush.cpp b/test/unit/zlib/flush.cpp index 12611fca..702f7462 100644 --- a/test/unit/zlib/flush.cpp +++ b/test/unit/zlib/flush.cpp @@ -1,5 +1,5 @@ // -// Copyright (c) 2025 Vinnie Falco (vinnie dot falco at gmail dot com) +// 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 http://www.boost.org/LICENSE_1_0.txt) diff --git a/test/unit/zlib/inflate.cpp b/test/unit/zlib/inflate.cpp index 015001d7..28aeba49 100644 --- a/test/unit/zlib/inflate.cpp +++ b/test/unit/zlib/inflate.cpp @@ -1,5 +1,5 @@ // -// Copyright (c) 2025 Vinnie Falco (vinnie dot falco at gmail dot com) +// 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 http://www.boost.org/LICENSE_1_0.txt) diff --git a/test/unit/zlib/stream.cpp b/test/unit/zlib/stream.cpp index 51162c43..f8108bbe 100644 --- a/test/unit/zlib/stream.cpp +++ b/test/unit/zlib/stream.cpp @@ -1,5 +1,5 @@ // -// Copyright (c) 2025 Vinnie Falco (vinnie dot falco at gmail dot com) +// 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 http://www.boost.org/LICENSE_1_0.txt) diff --git a/test/unit/zlib/zlib_error.cpp b/test/unit/zlib/zlib_error.cpp index ed3f8867..9a902aa2 100644 --- a/test/unit/zlib/zlib_error.cpp +++ b/test/unit/zlib/zlib_error.cpp @@ -1,5 +1,5 @@ // -// Copyright (c) 2025 Vinnie Falco (vinnie dot falco at gmail dot com) +// 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 http://www.boost.org/LICENSE_1_0.txt)