diff --git a/docs/guides/streaming.md b/docs/guides/streaming.md new file mode 100644 index 0000000000..9cba015702 --- /dev/null +++ b/docs/guides/streaming.md @@ -0,0 +1,123 @@ +A response body whose size is not known in advance, or which is simply too large +to fit in memory, can be produced on demand and sent using +[chunked transfer encoding](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Transfer-Encoding). + +Call `#!cpp response.set_chunked_content_provider(, )` with a +callable that produces the body one piece at a time. Crow sets +`Transfer-Encoding: chunked`, omits `Content-Length`, and calls the provider +repeatedly while writing the response. + +## The provider + +```cpp +bool provider(std::string& chunk); +``` + +Fill `chunk` with the next piece of the body and return `#!cpp true` while more +data is coming, `#!cpp false` on the last invocation. Leaving `chunk` empty is +allowed as an occasional occurrence and sends nothing. A provider that has no +data yet should block until data is available (or finish the transfer): the +provider is called again immediately, so returning `#!cpp true` with an empty +chunk in a tight loop spins the connection thread needlessly. + +### Example + +```cpp +auto app = crow::SimpleApp(); + +CROW_ROUTE(app, "/numbers") +([](const crow::request&, crow::response& res) { + int remaining = 100; + res.set_chunked_content_provider( + [remaining](std::string& chunk) mutable -> bool { + if (remaining == 0) + return false; + chunk = std::to_string(100 - remaining) + '\n'; + --remaining; + return true; + }, + "text/plain"); + res.end(); +}); +``` + +## Aborting the transfer + +A provider that discovers midway that the body cannot be finished (the source of +the data failed, for example) should not let the response end normally: without +`Content-Length`, the terminating frame is the only thing that tells the client +the body is complete. For this case the provider can return +`#!cpp crow::chunk_result` instead of `#!cpp bool`: + +```cpp +crow::chunk_result provider(std::string& chunk); +``` + +Return `#!cpp crow::chunk_result::more` while more data is coming, +`#!cpp crow::chunk_result::done` on the last invocation, or +`#!cpp crow::chunk_result::abort` to stop the transfer. On `abort` Crow closes +the connection without sending the terminating frame, so the client sees a +truncated body instead of a seemingly complete one. + +```cpp +CROW_ROUTE(app, "/file") +([](const crow::request&, crow::response& res) { + auto file = open_source_somehow(); + res.set_chunked_content_provider( + [file](std::string& chunk) mutable -> crow::chunk_result { + if (!file->read(chunk)) + return crow::chunk_result::abort; // reading failed: truncate the body + return chunk.empty() ? crow::chunk_result::done : crow::chunk_result::more; + }, + "application/octet-stream"); + res.end(); +}); +``` + +## Completion handler + +To find out how the transfer ended (to release the source of the data, or to log +a failure), set a handler that is called once after the body has been written: + +```cpp +res.set_chunked_completion_handler([](bool clean) { + if (!clean) + CROW_LOG_WARNING << "chunked transfer did not finish cleanly"; +}); +``` + +`clean` is `#!cpp true` when the provider finished normally +(`#!cpp crow::chunk_result::done`, or `#!cpp false` from the `bool` provider) +and every write succeeded; it is `#!cpp false` when the provider aborted or a +write error occurred. The handler runs on the connection's thread, after the +last write and before the response is finalized. For a `HEAD` request the +provider is never called, but the handler still runs (with `clean == true`) +when the response ends, so it is a reliable place to release the source of +the data. The handler should not throw: an exception that escapes it is +logged and swallowed. + +## Notes + +!!! note + + The provider runs on the connection's thread while the response is being + written, so a provider that blocks keeps that thread busy for the whole + transfer. + +!!! note + + The connection deadline is cancelled for the duration of the transfer. + Without that, a body that takes longer to produce than the timeout would be + cut short by the connection being closed. + +!!! note + + A response to a `HEAD` request never calls the provider: the headers are sent + and the body is skipped. The completion handler still runs, with + `clean == true`. + +!!! note + + A write error in the middle of the transfer is treated like `abort` as far + as the connection is concerned: the terminating frame is not sent and the + connection is closed instead of being reused for keep-alive. diff --git a/include/crow/http_connection.h b/include/crow/http_connection.h index 2ebf72a956..bf2e5b3d1d 100644 --- a/include/crow/http_connection.h +++ b/include/crow/http_connection.h @@ -275,6 +275,10 @@ namespace crow { do_write_static(); } + else if (res.is_chunked_type()) + { + do_write_chunked(); + } else { do_write_general(); @@ -331,6 +335,158 @@ namespace crow parser_.clear(); } + /// Format a chunk size the way chunked transfer encoding wants it: lowercase hex. + static std::string chunk_size_to_hex(std::size_t value) + { + static const char digits[] = "0123456789abcdef"; + if (value == 0) + { + return "0"; + } + std::string out; + while (value != 0) + { + out.insert(out.begin(), digits[value & 0xF]); + value >>= 4; + } + return out; + } + + void do_write_chunked() + { + error_code ec; + asio::write(adaptor_.socket(), buffers_, ec); // Write the response start / headers + if (ec) + { + CROW_LOG_ERROR << ec << " - buffer write error happened while sending response start / headers. Writing stopped premature."; + } + + // Producing the body may take arbitrarily long, so the connection must not be + // closed by the deadline while chunks are still on their way. + cancel_deadline_timer(); + + // do_write_sync() clears the response on every write, so the provider and the + // completion handler have to be taken out of it before the loop starts. + auto provider = std::move(res.chunk_provider_ex_); + res.chunk_provider_ex_ = nullptr; + if (!provider && res.chunk_provider_) + { + provider = [bool_provider = std::move(res.chunk_provider_)](std::string& chunk) { + return bool_provider(chunk) ? response::chunk_result::more : response::chunk_result::done; + }; + } + res.chunk_provider_ = nullptr; + auto completion_handler = std::move(res.chunk_complete_); + res.chunk_complete_ = nullptr; + + std::string chunk; + std::string chunk_header; + std::vector buffers{3}; + auto result = provider ? response::chunk_result::more : response::chunk_result::done; + while (result == response::chunk_result::more && !ec) + { + chunk.clear(); + // An exception from the provider must not escape into the Asio stack; it is + // treated as an abort: no terminating frame, forced close, completion(false). + try + { + result = provider(chunk); + } + catch (const std::exception& e) + { + CROW_LOG_ERROR << "An uncaught exception occurred in the chunk provider: " << e.what(); + result = response::chunk_result::abort; + } + catch (...) + { + CROW_LOG_ERROR << "An uncaught exception occurred in the chunk provider."; + result = response::chunk_result::abort; + } + if (result == response::chunk_result::abort || chunk.empty()) + { + continue; + } + + chunk_header = chunk_size_to_hex(chunk.size()); + chunk_header += crlf; + buffers[0] = asio::const_buffer(chunk_header.data(), chunk_header.size()); + buffers[1] = asio::const_buffer(chunk.data(), chunk.size()); + buffers[2] = asio::const_buffer(crlf.data(), crlf.size()); + ec = do_write_sync(buffers); + if (ec) + { + CROW_LOG_ERROR << ec << " - buffer write error happened while sending a chunk. Writing stopped premature."; + } + } + + // The terminating frame marks the body as complete, so it is only sent when the + // provider finished cleanly and every previous write succeeded. + if (result == response::chunk_result::done && !ec) + { + static const std::string last_chunk = "0\r\n\r\n"; + std::vector tail{1}; + tail[0] = asio::const_buffer(last_chunk.data(), last_chunk.size()); + ec = do_write_sync(tail); + if (ec) + { + CROW_LOG_ERROR << ec << " - buffer write error happened while sending the last chunk."; + } + } + + // A write failure leaves the message framing just as incomplete as an explicit + // abort (the terminating frame never made it out), so the connection policy is + // the same for both: force the close and never reuse the socket for keep-alive. + const bool aborted = (result == response::chunk_result::abort); + const bool force_close = aborted || static_cast(ec); + if (force_close) + { + // Close the connection forcefully, without the terminating frame, so that the + // client sees a truncated body instead of a seemingly complete one. + adaptor_.shutdown_readwrite(); + adaptor_.close(); + CROW_LOG_DEBUG << this << " from write (chunked, " << (aborted ? "aborted" : "write error") << ")"; + } + + if (completion_handler) + { + // An exception from the handler must not escape into the Asio stack or skip + // the cleanup below; it is logged and swallowed. + try + { + completion_handler(result == response::chunk_result::done && !ec); + } + catch (const std::exception& e) + { + CROW_LOG_ERROR << "An uncaught exception occurred in the chunked completion handler: " << e.what(); + } + catch (...) + { + CROW_LOG_ERROR << "An uncaught exception occurred in the chunked completion handler."; + } + } + + if (close_connection_ && !force_close) + { + adaptor_.shutdown_readwrite(); + adaptor_.close(); + CROW_LOG_DEBUG << this << " from write (chunked)"; + } + + res.end(); + res.clear(); + buffers_.clear(); + parser_.clear(); + + // The deadline was cancelled for the duration of the transfer, so a kept-alive + // connection has to be put back into reading state explicitly. + if (!force_close && !close_connection_ && need_to_start_read_after_complete_) + { + need_to_start_read_after_complete_ = false; + start_deadline(); + do_read(); + } + } + void do_write_general() { error_code ec; diff --git a/include/crow/http_response.h b/include/crow/http_response.h index 71a9e0c7a9..4c3a92f7c4 100644 --- a/include/crow/http_response.h +++ b/include/crow/http_response.h @@ -35,6 +35,14 @@ namespace crow class Router; + /// Outcome of a single chunk provider invocation. + enum class chunk_result + { + more, ///< The chunk is valid and more data is coming. + done, ///< The chunk is valid and it is the last one; the terminating frame is sent. + abort ///< The body cannot be finished; the connection is closed without the terminating frame. + }; + /// HTTP response struct response { @@ -56,6 +64,36 @@ namespace crow bool skip_body = false; ///< Whether this is a response to a HEAD request. bool manual_length_header = false; ///< Whether Crow should automatically add a "Content-Length" header. + /// Provider of the response body, called repeatedly until it returns false. + + /// + /// The provider fills the given string with the next chunk of the body and returns + /// `true` while more data is coming, `false` on its last invocation. Leaving the + /// string empty is allowed as an occasional occurrence and sends no chunk; a provider + /// that has no data yet should block until data is available (or finish), since + /// returning empty chunks in a tight loop spins the connection thread needlessly. + using chunk_provider_t = std::function; + + /// Outcome of a single chunk provider invocation; see crow::chunk_result. + using chunk_result = crow::chunk_result; + + /// Provider of the response body, called repeatedly until it returns `done` or `abort`. + + /// + /// The provider fills the given string with the next chunk of the body and returns + /// a chunk_result describing how to proceed. Leaving the string empty is allowed + /// as an occasional occurrence and sends no chunk; a provider that has no data yet + /// should block until data is available (or return `done`/`abort`), since returning + /// `more` with empty chunks in a tight loop spins the connection thread needlessly. + using chunk_provider_ex_t = std::function; + + /// Handler called once after the chunked body has been written (or writing has stopped). + + /// + /// `clean` is `true` when the provider finished with `chunk_result::done` and every + /// write succeeded, `false` when the provider aborted or a write error occurred. + using chunk_complete_t = std::function; + /// Set the value of an existing header in the response. void set_header(std::string key, std::string value) { @@ -183,6 +221,16 @@ namespace crow headers = std::move(r.headers); completed_ = r.completed_; file_info = std::move(r.file_info); +#ifdef CROW_ENABLE_COMPRESSION + compressed = r.compressed; +#endif + // skip_body is deliberately not copied: it marks the request side (the router + // sets it on the connection's response before the handler runs for a HEAD + // request), so a handler assigning a freshly built response must not reset it. + manual_length_header = r.manual_length_header; + chunk_provider_ = std::move(r.chunk_provider_); + chunk_provider_ex_ = std::move(r.chunk_provider_ex_); + chunk_complete_ = std::move(r.chunk_complete_); return *this; } @@ -199,6 +247,9 @@ namespace crow headers.clear(); completed_ = false; file_info = static_file_info{}; + chunk_provider_ = nullptr; + chunk_provider_ex_ = nullptr; + chunk_complete_ = nullptr; } /// Return a "Temporary Redirect" response. @@ -254,9 +305,40 @@ namespace crow completed_ = true; if (skip_body) { - set_header("Content-Length", std::to_string(body.size())); - body = ""; - manual_length_header = true; + if (is_chunked_type()) + { + // A response to HEAD must carry the same header fields a GET would + // produce; with a chunk provider the body length is unknown, so + // "Transfer-Encoding: chunked" is kept and "Content-Length" is not + // set (RFC 7230 forbids sending both at once). The body itself is + // skipped, so the provider is dropped without being called. The + // completion handler is still invoked (with clean == true) so that + // it remains the single release point for the data source no matter + // which method the client used. + chunk_provider_ = nullptr; + chunk_provider_ex_ = nullptr; + body = ""; + manual_length_header = true; + if (chunk_complete_) + { + auto completion_handler = std::move(chunk_complete_); + chunk_complete_ = nullptr; + try + { + completion_handler(true); + } + catch (...) + { + CROW_LOG_ERROR << "An uncaught exception occurred in the chunked completion handler."; + } + } + } + else + { + set_header("Content-Length", std::to_string(body.size())); + body = ""; + manual_length_header = true; + } } if (complete_request_handler_) { @@ -286,6 +368,68 @@ namespace crow return file_info.path.size(); } + /// Check whether the response body is produced by a chunk provider. + bool is_chunked_type() const + { + return static_cast(chunk_provider_) || static_cast(chunk_provider_ex_); + } + + /// Send the response body in chunks produced on demand, without holding it in memory. + + /// + /// The body is sent using `Transfer-Encoding: chunked`, so its size need not be known + /// in advance, which makes it suitable for bodies of arbitrary or unknown length. The + /// provider runs on the connection thread while the response is being written. The + /// provider should not throw: an exception that escapes it is logged and treated as + /// an abort (the connection is closed without the terminating frame). + void set_chunked_content_provider(chunk_provider_t provider, std::string content_type = "") + { + set_chunked_content_provider( + [provider = std::move(provider)](std::string& chunk) { + return provider(chunk) ? chunk_result::more : chunk_result::done; + }, + std::move(content_type)); + } + + /// Send the response body in chunks produced on demand, without holding it in memory. + + /// + /// Same as the `chunk_provider_t` overload, except that the provider can also return + /// `chunk_result::abort` to close the connection without the terminating frame, so + /// that the client sees a truncated body instead of a seemingly complete one. + /// Any previously set "Content-Length" header is removed: chunked transfer encoding + /// and "Content-Length" must not be sent together. A previously configured static + /// file or string body is discarded for the same reason: a response has exactly one + /// body source, and the one configured last wins. + void set_chunked_content_provider(chunk_provider_ex_t provider, std::string content_type = "") + { + chunk_provider_ex_ = std::move(provider); + file_info = static_file_info{}; + body.clear(); + manual_length_header = true; + headers.erase("Content-Length"); + set_header("Transfer-Encoding", "chunked"); + if (!content_type.empty()) + { + set_header("Content-Type", std::move(content_type)); + } + } + + /// Set a handler called once after the chunked body has been written (or writing has stopped). + + /// + /// The handler runs on the connection thread before the response is finalized. Its + /// `clean` argument is `true` when the provider finished normally (`chunk_result::done`, + /// or `false` from the `chunk_provider_t` overload) and every write succeeded. For a + /// HEAD request the body is skipped and the provider is never called, but the handler + /// still runs (with `clean == true`) when the response ends, so it remains a reliable + /// place to release the source of the data. The handler should not throw: an exception + /// that escapes it is logged and swallowed. + void set_chunked_completion_handler(chunk_complete_t handler) + { + chunk_complete_ = std::move(handler); + } + /// This constains metadata (coming from the `stat` command) related to any static files associated with this response. /// @@ -308,6 +452,15 @@ namespace crow /// the content_type may be specified explicitly. void set_static_file_info_unsafe(std::string path, std::string content_type = "") { + // A response has exactly one body source: installing the file drops a + // previously configured chunk provider together with its framing header, + // otherwise "Transfer-Encoding: chunked" and "Content-Length" would be + // sent side by side while the raw file bytes go out unframed. + chunk_provider_ = nullptr; + chunk_provider_ex_ = nullptr; + chunk_complete_ = nullptr; + headers.erase("Transfer-Encoding"); + manual_length_header = false; file_info.path = path; file_info.statResult = stat(file_info.path.c_str(), &file_info.statbuf); #ifdef CROW_ENABLE_COMPRESSION @@ -460,5 +613,8 @@ namespace crow std::function complete_request_handler_; std::function is_alive_helper_; static_file_info file_info; + chunk_provider_t chunk_provider_; + chunk_provider_ex_t chunk_provider_ex_; + chunk_complete_t chunk_complete_; }; } // namespace crow diff --git a/mkdocs.yml b/mkdocs.yml index e5013dbb99..d2aa3e8ee6 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -70,6 +70,7 @@ nav: - SBOM Generation: guides/sbom.md - SSL: guides/ssl.md - Static Files: guides/static.md + - Streaming: guides/streaming.md - Blueprints: guides/blueprints.md - Compression: guides/compression.md - Websockets: guides/websockets.md diff --git a/tests/unittest.cpp b/tests/unittest.cpp index 1e60242bd2..58dcf9a9f8 100644 --- a/tests/unittest.cpp +++ b/tests/unittest.cpp @@ -3,6 +3,7 @@ #include #include +#include #include #include #include @@ -2069,6 +2070,421 @@ TEST_CASE("stream_response") runTest.join(); } // stream_response +TEST_CASE("chunked_response") +{ + SimpleApp app; + + CROW_ROUTE(app, "/chunks") + ([](const crow::request&, crow::response& res) { + int remaining = 3; + res.set_chunked_content_provider( + [remaining](std::string& chunk) mutable -> bool { + if (remaining == 0) + return false; + chunk = "part" + std::to_string(4 - remaining); + --remaining; + return true; + }, + "text/plain"); + res.end(); + }); + + auto _ = app.bindaddr(LOCALHOST_ADDRESS).port(45451).run_async(); + app.wait_for_server_start(); + + HttpClient client(LOCALHOST_ADDRESS, 45451); + client.send("GET /chunks HTTP/1.1\r\nHost: localhost\r\n\r\n"); + + std::string response; + while (response.size() < 5 || response.compare(response.size() - 5, 5, "0\r\n\r\n") != 0) + response += client.receive(); + + CHECK(response.find("Transfer-Encoding: chunked") != std::string::npos); + CHECK(response.find("Content-Length") == std::string::npos); + CHECK(response.find("Content-Type: text/plain") != std::string::npos); + CHECK(response.find("5\r\npart1\r\n") != std::string::npos); + CHECK(response.find("5\r\npart2\r\n") != std::string::npos); + CHECK(response.find("5\r\npart3\r\n") != std::string::npos); + + // The connection is kept alive after a chunked response: a second request on + // the same connection is served, so the connection went back to reading state. + client.send("GET /chunks HTTP/1.1\r\nHost: localhost\r\n\r\n"); + std::string second; + while (second.size() < 5 || second.compare(second.size() - 5, 5, "0\r\n\r\n") != 0) + second += client.receive(); + CHECK(second.find("Transfer-Encoding: chunked") != std::string::npos); + CHECK(second.find("5\r\npart1\r\n") != std::string::npos); + CHECK(second.find("5\r\npart3\r\n") != std::string::npos); + + app.stop(); +} // chunked_response + +TEST_CASE("chunked_response_no_data") +{ + SimpleApp app; + + CROW_ROUTE(app, "/empty") + ([](const crow::request&, crow::response& res) { + int calls = 0; + res.set_chunked_content_provider([calls](std::string& chunk) mutable -> bool { + chunk.clear(); + return ++calls < 3; // three calls producing nothing at all + }); + res.end(); + }); + + auto _ = app.bindaddr(LOCALHOST_ADDRESS).port(45451).run_async(); + app.wait_for_server_start(); + + HttpClient client(LOCALHOST_ADDRESS, 45451); + client.send("GET /empty HTTP/1.1\r\nHost: localhost\r\n\r\n"); + + std::string response; + while (response.size() < 5 || response.compare(response.size() - 5, 5, "0\r\n\r\n") != 0) + response += client.receive(); + + CHECK(response.find("Transfer-Encoding: chunked") != std::string::npos); + CHECK(response.find("Content-Length") == std::string::npos); + + app.stop(); +} // chunked_response_no_data + +TEST_CASE("chunked_response_large_body") +{ + SimpleApp app; + + const size_t chunk_count = 64; + const size_t chunk_size = 1024; + + CROW_ROUTE(app, "/large") + ([chunk_count, chunk_size](const crow::request&, crow::response& res) { + size_t remaining = chunk_count; + res.set_chunked_content_provider([remaining, chunk_size](std::string& chunk) mutable -> bool { + if (remaining == 0) + return false; + chunk.assign(chunk_size, 'x'); + --remaining; + return true; + }); + res.end(); + }); + + auto _ = app.bindaddr(LOCALHOST_ADDRESS).port(45451).run_async(); + app.wait_for_server_start(); + + HttpClient client(LOCALHOST_ADDRESS, 45451); + client.send("GET /large HTTP/1.1\r\nHost: localhost\r\n\r\n"); + + std::string response; + while (response.size() < 5 || response.compare(response.size() - 5, 5, "0\r\n\r\n") != 0) + response += client.receive(); + + CHECK(response.find("Transfer-Encoding: chunked") != std::string::npos); + + // decode the chunked body: every frame is "\r\n\r\n", + // the terminating frame has size zero + auto header_end = response.find("\r\n\r\n"); + REQUIRE(header_end != std::string::npos); + std::string chunked_body = response.substr(header_end + 4); + size_t seen = 0; + size_t total = 0; + std::string::size_type pos = 0; + while (true) + { + auto size_end = chunked_body.find("\r\n", pos); + REQUIRE(size_end != std::string::npos); + size_t size = std::stoul(chunked_body.substr(pos, size_end - pos), nullptr, 16); + if (size == 0) + break; + ++seen; + total += size; + pos = size_end + 2 + size + 2; // past the size line, the data and its trailing CRLF + REQUIRE(pos <= chunked_body.size()); + } + CHECK(seen == chunk_count); + CHECK(total == chunk_count * chunk_size); + + app.stop(); +} // chunked_response_large_body + +TEST_CASE("chunked_response_head_request") +{ + SimpleApp app; + + auto completion_clean = std::make_shared>(); + + CROW_ROUTE(app, "/chunks").methods("GET"_method, "HEAD"_method)([completion_clean](const crow::request&, crow::response& res) { + res.set_chunked_content_provider([](std::string& chunk) -> bool { + chunk = "body"; + return false; + }); + res.set_chunked_completion_handler([completion_clean](bool clean) { + completion_clean->set_value(clean); + }); + res.end(); + }); + + auto _ = app.bindaddr(LOCALHOST_ADDRESS).port(45451).run_async(); + app.wait_for_server_start(); + + std::string response = HttpClient::request(LOCALHOST_ADDRESS, 45451, "HEAD /chunks HTTP/1.1\r\nHost: localhost\r\n\r\n"); + + // Same header fields as a GET would produce: the body length is unknown, so + // "Transfer-Encoding: chunked" is announced and "Content-Length" is absent. + CHECK(response.find("Transfer-Encoding: chunked") != std::string::npos); + CHECK(response.find("Content-Length") == std::string::npos); + + // The body itself is skipped entirely. + auto header_end = response.find("\r\n\r\n"); + REQUIRE(header_end != std::string::npos); + CHECK(response.substr(header_end + 4).empty()); + CHECK(response.find("body") == std::string::npos); + + // The provider is never called, but the completion handler still runs (with + // clean == true): it stays the single release point for the source of the data. + auto completion = completion_clean->get_future(); + REQUIRE(completion.wait_for(std::chrono::seconds(5)) == std::future_status::ready); + CHECK(completion.get() == true); + + app.stop(); +} // chunked_response_head_request + +TEST_CASE("chunked_response_abort") +{ + SimpleApp app; + + CROW_ROUTE(app, "/abort") + ([](const crow::request&, crow::response& res) { + int calls = 0; + res.set_chunked_content_provider( + [calls](std::string& chunk) mutable -> crow::chunk_result { + if (++calls < 3) + { + chunk = "part" + std::to_string(calls); + return crow::chunk_result::more; + } + return crow::chunk_result::abort; + }, + "text/plain"); + res.end(); + }); + + auto _ = app.bindaddr(LOCALHOST_ADDRESS).port(45451).run_async(); + app.wait_for_server_start(); + + HttpClient client(LOCALHOST_ADDRESS, 45451); + client.send("GET /abort HTTP/1.1\r\nHost: localhost\r\n\r\n"); + + // The server closes the connection without the terminating frame, so reading + // past the truncated body eventually throws (end of file). + std::string response; + try + { + while (true) + response += client.receive(); + } + catch (const std::exception&) + { + } + + CHECK(response.find("Transfer-Encoding: chunked") != std::string::npos); + + // the body is truncated: the produced chunks are there, the terminating frame is not + auto body_start = response.find("\r\n\r\n"); + REQUIRE(body_start != std::string::npos); + std::string chunked_body = response.substr(body_start + 4); + CHECK(chunked_body.find("5\r\npart1\r\n") != std::string::npos); + CHECK(chunked_body.find("5\r\npart2\r\n") != std::string::npos); + CHECK(chunked_body.find("0\r\n\r\n") == std::string::npos); + + app.stop(); +} // chunked_response_abort + +TEST_CASE("chunked_response_completion_handler") +{ + SimpleApp app; + + auto done_clean = std::make_shared>(); + auto abort_clean = std::make_shared>(); + + CROW_ROUTE(app, "/done") + ([done_clean](const crow::request&, crow::response& res) { + res.set_chunked_content_provider([](std::string& chunk) { + chunk = "body"; + return crow::chunk_result::done; + }); + res.set_chunked_completion_handler([done_clean](bool clean) { + done_clean->set_value(clean); + }); + res.end(); + }); + + CROW_ROUTE(app, "/abort") + ([abort_clean](const crow::request&, crow::response& res) { + res.set_chunked_content_provider([](std::string&) { + return crow::chunk_result::abort; + }); + res.set_chunked_completion_handler([abort_clean](bool clean) { + abort_clean->set_value(clean); + }); + res.end(); + }); + + auto _ = app.bindaddr(LOCALHOST_ADDRESS).port(45451).run_async(); + app.wait_for_server_start(); + + { + HttpClient client(LOCALHOST_ADDRESS, 45451); + client.send("GET /done HTTP/1.1\r\nHost: localhost\r\n\r\n"); + std::string response; + while (response.size() < 5 || response.compare(response.size() - 5, 5, "0\r\n\r\n") != 0) + response += client.receive(); + } + CHECK(done_clean->get_future().get() == true); + + { + HttpClient client(LOCALHOST_ADDRESS, 45451); + client.send("GET /abort HTTP/1.1\r\nHost: localhost\r\n\r\n"); + try + { + while (true) + client.receive(); + } + catch (const std::exception&) + { + } + } + CHECK(abort_clean->get_future().get() == false); + + app.stop(); +} // chunked_response_completion_handler + +TEST_CASE("chunked_response_throwing_completion_handler") +{ + SimpleApp app; + + CROW_ROUTE(app, "/throwing") + ([](const crow::request&, crow::response& res) { + res.set_chunked_content_provider([](std::string& chunk) { + chunk = "body"; + return crow::chunk_result::done; + }); + res.set_chunked_completion_handler([](bool) { + throw std::runtime_error("completion failed"); + }); + res.end(); + }); + + auto _ = app.bindaddr(LOCALHOST_ADDRESS).port(45451).run_async(); + app.wait_for_server_start(); + + // An exception from the completion handler must not skip the connection + // cleanup: the response is still delivered in full and the server survives. + HttpClient client(LOCALHOST_ADDRESS, 45451); + client.send("GET /throwing HTTP/1.1\r\nHost: localhost\r\n\r\n"); + std::string response; + while (response.size() < 5 || response.compare(response.size() - 5, 5, "0\r\n\r\n") != 0) + response += client.receive(); + CHECK(response.find("Transfer-Encoding: chunked") != std::string::npos); + + // The connection stays usable for the next request. + client.send("GET /throwing HTTP/1.1\r\nHost: localhost\r\n\r\n"); + std::string second; + while (second.size() < 5 || second.compare(second.size() - 5, 5, "0\r\n\r\n") != 0) + second += client.receive(); + CHECK(second.find("Transfer-Encoding: chunked") != std::string::npos); + + app.stop(); +} // chunked_response_throwing_completion_handler + +TEST_CASE("chunked_provider_excludes_other_body_sources") +{ + // A response has exactly one body source; whichever is configured last wins. + + // A chunk provider discards a previously configured static file and string body. + { + response res; + res.set_static_file_info("tests/img/cat.jpg"); + res.body = "leftover"; + res.set_chunked_content_provider([](std::string&) { return crow::chunk_result::done; }); + + CHECK(res.is_chunked_type()); + CHECK(!res.is_static_type()); + CHECK(res.body.empty()); + CHECK(res.get_header_value("Content-Length").empty()); + CHECK(res.get_header_value("Transfer-Encoding") == "chunked"); + } + + // A static file discards a previously configured chunk provider and its framing header. + { + response res; + res.set_chunked_content_provider([](std::string&) { return crow::chunk_result::done; }); + res.set_static_file_info("tests/img/cat.jpg"); + + CHECK(!res.is_chunked_type()); + CHECK(res.is_static_type()); + CHECK(res.get_header_value("Transfer-Encoding").empty()); + CHECK(!res.get_header_value("Content-Length").empty()); + } +} // chunked_provider_excludes_other_body_sources + +TEST_CASE("chunked_response_throwing_provider") +{ + SimpleApp app; + + auto throw_clean = std::make_shared>(); + + CROW_ROUTE(app, "/throw") + ([throw_clean](const crow::request&, crow::response& res) { + int calls = 0; + res.set_chunked_content_provider( + [calls](std::string& chunk) mutable -> bool { + if (++calls < 3) + { + chunk = "part" + std::to_string(calls); + return true; + } + throw std::runtime_error("provider failed"); + }, + "text/plain"); + res.set_chunked_completion_handler([throw_clean](bool clean) { + throw_clean->set_value(clean); + }); + res.end(); + }); + + auto _ = app.bindaddr(LOCALHOST_ADDRESS).port(45451).run_async(); + app.wait_for_server_start(); + + HttpClient client(LOCALHOST_ADDRESS, 45451); + client.send("GET /throw HTTP/1.1\r\nHost: localhost\r\n\r\n"); + + // The exception is treated as an abort: the connection is closed without the + // terminating frame, so reading past the truncated body eventually throws. + std::string response; + try + { + while (true) + response += client.receive(); + } + catch (const std::exception&) + { + } + + CHECK(response.find("Transfer-Encoding: chunked") != std::string::npos); + + auto body_start = response.find("\r\n\r\n"); + REQUIRE(body_start != std::string::npos); + std::string chunked_body = response.substr(body_start + 4); + CHECK(chunked_body.find("5\r\npart1\r\n") != std::string::npos); + CHECK(chunked_body.find("5\r\npart2\r\n") != std::string::npos); + CHECK(chunked_body.find("0\r\n\r\n") == std::string::npos); + + CHECK(throw_clean->get_future().get() == false); + + app.stop(); +} // chunked_response_throwing_provider + #ifdef CROW_ENABLE_COMPRESSION TEST_CASE("zlib_compression") {