Selaa lähdekoodia

Add New Streaming API support (#2281)

yhirose 2 kuukautta sitten
vanhempi
commit
8bba34eebc
7 muutettua tiedostoa jossa 2182 lisäystä ja 194 poistoa
  1. 4 0
      .gitignore
  2. 315 0
      README-stream.md
  3. 25 0
      README.md
  4. 5 2
      example/Makefile
  5. 234 0
      example/ssecli-stream.cc
  6. 821 192
      httplib.h
  7. 778 0
      test/test.cc

+ 4 - 0
.gitignore

@@ -16,7 +16,11 @@ example/benchmark
 example/redirect
 !example/redirect.*
 example/ssecli
+!example/ssecli.*
+example/ssecli-stream
+!example/ssecli-stream.*
 example/ssesvr
+!example/ssesvr.*
 example/upload
 !example/upload.*
 example/one_time_request

+ 315 - 0
README-stream.md

@@ -0,0 +1,315 @@
+# cpp-httplib Streaming API
+
+This document describes the streaming extensions for cpp-httplib, providing an iterator-style API for handling HTTP responses incrementally with **true socket-level streaming**.
+
+> **Important Notes**:
+>
+> - **No Keep-Alive**: Each `stream::Get()` call uses a dedicated connection that is closed after the response is fully read. For connection reuse, use `Client::Get()`.
+> - **Single iteration only**: The `next()` method can only iterate through the body once.
+> - **Result is not thread-safe**: While `stream::Get()` can be called from multiple threads simultaneously, the returned `stream::Result` must be used from a single thread only.
+
+## Overview
+
+The streaming API allows you to process HTTP response bodies chunk by chunk using an iterator-style pattern. Data is read directly from the network socket, enabling low-memory processing of large responses. This is particularly useful for:
+
+- **LLM/AI streaming responses** (e.g., ChatGPT, Claude, Ollama)
+- **Server-Sent Events (SSE)**
+- **Large file downloads** with progress tracking
+- **Reverse proxy implementations**
+
+## Quick Start
+
+```cpp
+#include "httplib.h"
+
+int main() {
+    httplib::Client cli("http://localhost:8080");
+    
+    // Get streaming response
+    auto result = httplib::stream::Get(cli, "/stream");
+    
+    if (result) {
+        // Process response body in chunks
+        while (result.next()) {
+            std::cout.write(result.data(), result.size());
+        }
+    }
+    
+    return 0;
+}
+```
+
+## API Layers
+
+cpp-httplib provides multiple API layers for different use cases:
+
+```text
+┌─────────────────────────────────────────────┐
+│  SSEClient (planned)                        │  ← SSE-specific, parsed events
+│  - on_message(), on_event()                 │
+│  - Auto-reconnect, Last-Event-ID            │
+├─────────────────────────────────────────────┤
+│  stream::Get() / stream::Result             │  ← Iterator-based streaming
+│  - while (result.next()) { ... }            │
+├─────────────────────────────────────────────┤
+│  open_stream() / StreamHandle               │  ← General-purpose streaming
+│  - handle.read(buf, len)                    │
+├─────────────────────────────────────────────┤
+│  Client::Get()                              │  ← Traditional, full buffering
+└─────────────────────────────────────────────┘
+```
+
+| Use Case | Recommended API |
+|----------|----------------|
+| SSE with auto-reconnect | SSEClient (planned) or `ssecli-stream.cc` example |
+| LLM streaming (JSON Lines) | `stream::Get()` |
+| Large file download | `stream::Get()` or `open_stream()` |
+| Reverse proxy | `open_stream()` |
+| Small responses with Keep-Alive | `Client::Get()` |
+
+## API Reference
+
+### Low-Level API: `StreamHandle`
+
+The `StreamHandle` struct provides direct control over streaming responses. It takes ownership of the socket connection and reads data directly from the network.
+
+> **Note:** When using `open_stream()`, the connection is dedicated to streaming and **Keep-Alive is not supported**. For Keep-Alive connections, use `client.Get()` instead.
+
+```cpp
+// Open a stream (takes ownership of socket)
+httplib::Client cli("http://localhost:8080");
+auto handle = cli.open_stream("/path");
+
+// Check validity
+if (handle.is_valid()) {
+    // Access response headers immediately
+    int status = handle.response->status;
+    auto content_type = handle.response->get_header_value("Content-Type");
+    
+    // Read body incrementally
+    char buf[4096];
+    ssize_t n;
+    while ((n = handle.read(buf, sizeof(buf))) > 0) {
+        process(buf, n);
+    }
+}
+```
+
+#### StreamHandle Members
+
+| Member | Type | Description |
+|--------|------|-------------|
+| `response` | `std::unique_ptr<Response>` | HTTP response with headers |
+| `error` | `Error` | Error code if request failed |
+| `is_valid()` | `bool` | Returns true if response is valid |
+| `read(buf, len)` | `ssize_t` | Read up to `len` bytes directly from socket |
+| `get_read_error()` | `Error` | Get the last read error |
+| `has_read_error()` | `bool` | Check if a read error occurred |
+
+### High-Level API: `stream::Get()` and `stream::Result`
+
+The `httplib.h` header provides a more ergonomic iterator-style API.
+
+```cpp
+#include "httplib.h"
+
+httplib::Client cli("http://localhost:8080");
+
+// Simple GET
+auto result = httplib::stream::Get(cli, "/path");
+
+// GET with custom headers
+httplib::Headers headers = {{"Authorization", "Bearer token"}};
+auto result = httplib::stream::Get(cli, "/path", headers);
+
+// Process the response
+if (result) {
+    while (result.next()) {
+        process(result.data(), result.size());
+    }
+}
+
+// Or read entire body at once
+auto result2 = httplib::stream::Get(cli, "/path");
+if (result2) {
+    std::string body = result2.read_all();
+}
+```
+
+#### stream::Result Members
+
+| Member | Type | Description |
+|--------|------|-------------|
+| `operator bool()` | `bool` | Returns true if response is valid |
+| `is_valid()` | `bool` | Same as `operator bool()` |
+| `status()` | `int` | HTTP status code |
+| `headers()` | `const Headers&` | Response headers |
+| `get_header_value(key, def)` | `std::string` | Get header value (with optional default) |
+| `has_header(key)` | `bool` | Check if header exists |
+| `next()` | `bool` | Read next chunk, returns false when done |
+| `data()` | `const char*` | Pointer to current chunk data |
+| `size()` | `size_t` | Size of current chunk |
+| `read_all()` | `std::string` | Read entire remaining body into string |
+| `error()` | `Error` | Get the connection/request error |
+| `read_error()` | `Error` | Get the last read error |
+| `has_read_error()` | `bool` | Check if a read error occurred |
+
+## Usage Examples
+
+### Example 1: SSE (Server-Sent Events) Client
+
+```cpp
+#include "httplib.h"
+#include <iostream>
+
+int main() {
+    httplib::Client cli("http://localhost:1234");
+    
+    auto result = httplib::stream::Get(cli, "/events");
+    if (!result) { return 1; }
+    
+    while (result.next()) {
+        std::cout.write(result.data(), result.size());
+        std::cout.flush();
+    }
+    
+    return 0;
+}
+```
+
+For a complete SSE client with auto-reconnection and event parsing, see `example/ssecli-stream.cc`.
+
+### Example 2: LLM Streaming Response
+
+```cpp
+#include "httplib.h"
+#include <iostream>
+
+int main() {
+    httplib::Client cli("http://localhost:11434");  // Ollama
+    
+    auto result = httplib::stream::Get(cli, "/api/generate");
+    
+    if (result && result.status() == 200) {
+        while (result.next()) {
+            std::cout.write(result.data(), result.size());
+            std::cout.flush();
+        }
+    }
+    
+    // Check for connection errors
+    if (result.read_error() != httplib::Error::Success) {
+        std::cerr << "Connection lost\n";
+    }
+    
+    return 0;
+}
+```
+
+### Example 3: Large File Download with Progress
+
+```cpp
+#include "httplib.h"
+#include <fstream>
+#include <iostream>
+
+int main() {
+    httplib::Client cli("http://example.com");
+    auto result = httplib::stream::Get(cli, "/large-file.zip");
+    
+    if (!result || result.status() != 200) {
+        std::cerr << "Download failed\n";
+        return 1;
+    }
+    
+    std::ofstream file("download.zip", std::ios::binary);
+    size_t total = 0;
+    
+    while (result.next()) {
+        file.write(result.data(), result.size());
+        total += result.size();
+        std::cout << "\rDownloaded: " << (total / 1024) << " KB" << std::flush;
+    }
+    
+    std::cout << "\nComplete!\n";
+    return 0;
+}
+```
+
+### Example 4: Reverse Proxy Streaming
+
+```cpp
+#include "httplib.h"
+
+httplib::Server svr;
+
+svr.Get("/proxy/(.*)", [](const httplib::Request& req, httplib::Response& res) {
+    httplib::Client upstream("http://backend:8080");
+    auto handle = upstream.open_stream("/" + req.matches[1].str());
+    
+    if (!handle.is_valid()) {
+        res.status = 502;
+        return;
+    }
+    
+    res.status = handle.response->status;
+    res.set_chunked_content_provider(
+        handle.response->get_header_value("Content-Type"),
+        [handle = std::move(handle)](size_t, httplib::DataSink& sink) mutable {
+            char buf[8192];
+            auto n = handle.read(buf, sizeof(buf));
+            if (n > 0) {
+                sink.write(buf, static_cast<size_t>(n));
+                return true;
+            }
+            sink.done();
+            return true;
+        }
+    );
+});
+
+svr.listen("0.0.0.0", 3000);
+```
+
+## Comparison with Existing APIs
+
+| Feature | `Client::Get()` | `open_stream()` | `stream::Get()` |
+|---------|----------------|-----------------|----------------|
+| Headers available | After complete | Immediately | Immediately |
+| Body reading | All at once | Direct from socket | Iterator-based |
+| Memory usage | Full body in RAM | Minimal (controlled) | Minimal (controlled) |
+| Keep-Alive support | ✅ Yes | ❌ No | ❌ No |
+| Compression | Auto-handled | Auto-handled | Auto-handled |
+| Best for | Small responses, Keep-Alive | Low-level streaming | Easy streaming |
+
+## Features
+
+- **True socket-level streaming**: Data is read directly from the network socket
+- **Low memory footprint**: Only the current chunk is held in memory
+- **Compression support**: Automatic decompression for gzip, brotli, and zstd
+- **Chunked transfer**: Full support for chunked transfer encoding
+- **SSL/TLS support**: Works with HTTPS connections
+
+## Important Notes
+
+### Keep-Alive Behavior
+
+The streaming API (`stream::Get()` / `open_stream()`) takes ownership of the socket connection for the duration of the stream. This means:
+
+- **Keep-Alive is not supported** for streaming connections
+- The socket is closed when `StreamHandle` is destroyed
+- For Keep-Alive scenarios, use the standard `client.Get()` API instead
+
+```cpp
+// Use for streaming (no Keep-Alive)
+auto result = httplib::stream::Get(cli, "/large-stream");
+while (result.next()) { /* ... */ }
+
+// Use for Keep-Alive connections
+auto res = cli.Get("/api/data");  // Connection can be reused
+```
+
+## Related
+
+- [Issue #2269](https://github.com/yhirose/cpp-httplib/issues/2269) - Original feature request
+- [example/ssecli-stream.cc](./example/ssecli-stream.cc) - SSE client with auto-reconnection

+ 25 - 0
README.md

@@ -1188,6 +1188,31 @@ std::string decoded_component = httplib::decode_uri_component(encoded_component)
 
 Use `encode_uri()` for full URLs and `encode_uri_component()` for individual query parameters or path segments.
 
+Streaming API
+-------------
+
+Process large responses without loading everything into memory.
+
+```c++
+httplib::Client cli("localhost", 8080);
+
+auto result = httplib::stream::Get(cli, "/large-file");
+if (result) {
+  while (result.next()) {
+    process(result.data(), result.size());  // Process each chunk as it arrives
+  }
+}
+
+// Or read the entire body at once
+auto result2 = httplib::stream::Get(cli, "/file");
+if (result2) {
+  std::string body = result2.read_all();
+}
+```
+
+All HTTP methods are supported: `stream::Get`, `Post`, `Put`, `Patch`, `Delete`, `Head`, `Options`.
+
+See [README-stream.md](README-stream.md) for more details.
 
 Split httplib.h into .h and .cc
 -------------------------------

+ 5 - 2
example/Makefile

@@ -18,7 +18,7 @@ ZLIB_SUPPORT = -DCPPHTTPLIB_ZLIB_SUPPORT -lz
 BROTLI_DIR = $(PREFIX)/opt/brotli
 BROTLI_SUPPORT = -DCPPHTTPLIB_BROTLI_SUPPORT -I$(BROTLI_DIR)/include -L$(BROTLI_DIR)/lib -lbrotlicommon -lbrotlienc -lbrotlidec
 
-all: server client hello simplecli simplesvr upload redirect ssesvr ssecli benchmark one_time_request server_and_client accept_header
+all: server client hello simplecli simplesvr upload redirect ssesvr ssecli ssecli-stream benchmark one_time_request server_and_client accept_header
 
 server : server.cc ../httplib.h Makefile
 	$(CXX) -o server $(CXXFLAGS) server.cc $(OPENSSL_SUPPORT) $(ZLIB_SUPPORT) $(BROTLI_SUPPORT)
@@ -47,6 +47,9 @@ ssesvr : ssesvr.cc ../httplib.h Makefile
 ssecli : ssecli.cc ../httplib.h Makefile
 	$(CXX) -o ssecli $(CXXFLAGS) ssecli.cc $(OPENSSL_SUPPORT) $(ZLIB_SUPPORT) $(BROTLI_SUPPORT)
 
+ssecli-stream : ssecli-stream.cc ../httplib.h ../httplib.h Makefile
+	$(CXX) -o ssecli-stream $(CXXFLAGS) ssecli-stream.cc $(OPENSSL_SUPPORT) $(ZLIB_SUPPORT) $(BROTLI_SUPPORT)
+
 benchmark : benchmark.cc ../httplib.h Makefile
 	$(CXX) -o benchmark $(CXXFLAGS) benchmark.cc $(OPENSSL_SUPPORT) $(ZLIB_SUPPORT) $(BROTLI_SUPPORT)
 
@@ -64,4 +67,4 @@ pem:
 	openssl req -new -key key.pem | openssl x509 -days 3650 -req -signkey key.pem > cert.pem
 
 clean:
-	rm server client hello simplecli simplesvr upload redirect ssesvr ssecli benchmark one_time_request server_and_client accept_header *.pem
+	rm server client hello simplecli simplesvr upload redirect ssesvr ssecli ssecli-stream benchmark one_time_request server_and_client accept_header *.pem

+ 234 - 0
example/ssecli-stream.cc

@@ -0,0 +1,234 @@
+//
+//  ssecli-stream.cc
+//
+//  Copyright (c) 2025 Yuji Hirose. All rights reserved.
+//  MIT License
+//
+//  SSE (Server-Sent Events) client example using Streaming API
+//  with automatic reconnection support (similar to JavaScript's EventSource)
+//
+
+#include <httplib.h>
+
+#include <chrono>
+#include <iostream>
+#include <string>
+#include <thread>
+
+//------------------------------------------------------------------------------
+// SSE Event Parser
+//------------------------------------------------------------------------------
+// Parses SSE events from the stream according to the SSE specification.
+// SSE format:
+//   event: <event-type>     (optional, defaults to "message")
+//   data: <payload>         (can have multiple lines)
+//   id: <event-id>          (optional, used for reconnection)
+//   retry: <milliseconds>   (optional, reconnection interval)
+//   <blank line>            (signals end of event)
+//
+struct SSEEvent {
+  std::string event = "message"; // Event type (default: "message")
+  std::string data;              // Event payload
+  std::string id;                // Event ID for Last-Event-ID header
+
+  void clear() {
+    event = "message";
+    data.clear();
+    id.clear();
+  }
+};
+
+// Parse a single SSE field line (e.g., "data: hello")
+// Returns true if this line ends an event (blank line)
+bool parse_sse_line(const std::string &line, SSEEvent &event, int &retry_ms) {
+  // Blank line signals end of event
+  if (line.empty() || line == "\r") { return true; }
+
+  // Find the colon separator
+  auto colon_pos = line.find(':');
+  if (colon_pos == std::string::npos) {
+    // Line with no colon is treated as field name with empty value
+    return false;
+  }
+
+  std::string field = line.substr(0, colon_pos);
+  std::string value;
+
+  // Value starts after colon, skip optional single space
+  if (colon_pos + 1 < line.size()) {
+    size_t value_start = colon_pos + 1;
+    if (line[value_start] == ' ') { value_start++; }
+    value = line.substr(value_start);
+    // Remove trailing \r if present
+    if (!value.empty() && value.back() == '\r') { value.pop_back(); }
+  }
+
+  // Handle known fields
+  if (field == "event") {
+    event.event = value;
+  } else if (field == "data") {
+    // Multiple data lines are concatenated with newlines
+    if (!event.data.empty()) { event.data += "\n"; }
+    event.data += value;
+  } else if (field == "id") {
+    // Empty id is valid (clears the last event ID)
+    event.id = value;
+  } else if (field == "retry") {
+    // Parse retry interval in milliseconds
+    try {
+      retry_ms = std::stoi(value);
+    } catch (...) {
+      // Invalid retry value, ignore
+    }
+  }
+  // Unknown fields are ignored per SSE spec
+
+  return false;
+}
+
+//------------------------------------------------------------------------------
+// Main - SSE Client with Auto-Reconnection
+//------------------------------------------------------------------------------
+int main(void) {
+  // Configuration
+  const std::string host = "http://localhost:1234";
+  const std::string path = "/event1";
+
+  httplib::Client cli(host);
+
+  // State for reconnection (persists across connections)
+  std::string last_event_id; // Sent as Last-Event-ID header on reconnect
+  int retry_ms = 3000; // Reconnection delay (server can override via retry:)
+  int connection_count = 0;
+
+  std::cout << "SSE Client starting...\n";
+  std::cout << "Target: " << host << path << "\n";
+  std::cout << "Press Ctrl+C to exit\n\n";
+
+  //----------------------------------------------------------------------------
+  // Main reconnection loop
+  // This mimics JavaScript's EventSource behavior:
+  // - Automatically reconnects on connection failure
+  // - Sends Last-Event-ID header to resume from last received event
+  // - Respects server's retry interval
+  //----------------------------------------------------------------------------
+  while (true) {
+    connection_count++;
+    std::cout << "[Connection #" << connection_count << "] Connecting...\n";
+
+    // Build headers, including Last-Event-ID if we have one
+    httplib::Headers headers;
+    if (!last_event_id.empty()) {
+      headers.emplace("Last-Event-ID", last_event_id);
+      std::cout << "[Connection #" << connection_count
+                << "] Resuming from event ID: " << last_event_id << "\n";
+    }
+
+    // Open streaming connection
+    auto result = httplib::stream::Get(cli, path, headers);
+
+    //--------------------------------------------------------------------------
+    // Connection error handling
+    //--------------------------------------------------------------------------
+    if (!result) {
+      std::cerr << "[Connection #" << connection_count
+                << "] Failed: " << httplib::to_string(result.error()) << "\n";
+      std::cerr << "Reconnecting in " << retry_ms << "ms...\n\n";
+      std::this_thread::sleep_for(std::chrono::milliseconds(retry_ms));
+      continue;
+    }
+
+    if (result.status() != 200) {
+      std::cerr << "[Connection #" << connection_count
+                << "] HTTP error: " << result.status() << "\n";
+
+      // For certain errors, don't reconnect
+      if (result.status() == 204 || // No Content - server wants us to stop
+          result.status() == 404 || // Not Found
+          result.status() == 401 || // Unauthorized
+          result.status() == 403) { // Forbidden
+        std::cerr << "Permanent error, not reconnecting.\n";
+        return 1;
+      }
+
+      std::cerr << "Reconnecting in " << retry_ms << "ms...\n\n";
+      std::this_thread::sleep_for(std::chrono::milliseconds(retry_ms));
+      continue;
+    }
+
+    // Verify Content-Type (optional but recommended)
+    auto content_type = result.get_header_value("Content-Type");
+    if (content_type.find("text/event-stream") == std::string::npos) {
+      std::cerr << "[Warning] Content-Type is not text/event-stream: "
+                << content_type << "\n";
+    }
+
+    std::cout << "[Connection #" << connection_count << "] Connected!\n\n";
+
+    //--------------------------------------------------------------------------
+    // Event receiving loop
+    // Reads chunks from the stream and parses SSE events
+    //--------------------------------------------------------------------------
+    std::string buffer;
+    SSEEvent current_event;
+    int event_count = 0;
+
+    // Read data from stream using httplib::stream API
+    while (result.next()) {
+      buffer.append(result.data(), result.size());
+
+      // Process complete lines in the buffer
+      size_t line_start = 0;
+      size_t newline_pos;
+
+      while ((newline_pos = buffer.find('\n', line_start)) !=
+             std::string::npos) {
+        std::string line = buffer.substr(line_start, newline_pos - line_start);
+        line_start = newline_pos + 1;
+
+        // Parse the line and check if event is complete
+        bool event_complete = parse_sse_line(line, current_event, retry_ms);
+
+        if (event_complete && !current_event.data.empty()) {
+          // Event received - process it
+          event_count++;
+
+          std::cout << "--- Event #" << event_count << " ---\n";
+          std::cout << "Type: " << current_event.event << "\n";
+          std::cout << "Data: " << current_event.data << "\n";
+          if (!current_event.id.empty()) {
+            std::cout << "ID:   " << current_event.id << "\n";
+          }
+          std::cout << "\n";
+
+          // Update last_event_id for reconnection
+          // Note: Empty id clears the last event ID per SSE spec
+          if (!current_event.id.empty()) { last_event_id = current_event.id; }
+
+          current_event.clear();
+        }
+      }
+
+      // Keep unprocessed data in buffer
+      buffer.erase(0, line_start);
+    }
+
+    //--------------------------------------------------------------------------
+    // Connection ended - check why
+    //--------------------------------------------------------------------------
+    if (result.read_error() != httplib::Error::Success) {
+      std::cerr << "\n[Connection #" << connection_count
+                << "] Error: " << httplib::to_string(result.read_error())
+                << "\n";
+    } else {
+      std::cout << "\n[Connection #" << connection_count
+                << "] Stream ended normally\n";
+    }
+
+    std::cout << "Received " << event_count << " events in this connection\n";
+    std::cout << "Reconnecting in " << retry_ms << "ms...\n\n";
+    std::this_thread::sleep_for(std::chrono::milliseconds(retry_ms));
+  }
+
+  return 0;
+}

Tiedoston diff-näkymää rajattu, sillä se on liian suuri
+ 821 - 192
httplib.h


+ 778 - 0
test/test.cc

@@ -3198,6 +3198,33 @@ protected:
                      return true;
                    });
              })
+        .Get("/streamed-chunked-with-prohibited-trailer",
+             [&](const Request & /*req*/, Response &res) {
+               auto i = new int(0);
+               // Declare both a prohibited trailer (Content-Length) and an
+               // allowed one
+               res.set_header("Trailer", "Content-Length, X-Allowed");
+
+               res.set_chunked_content_provider(
+                   "text/plain",
+                   [i](size_t /*offset*/, DataSink &sink) {
+                     switch (*i) {
+                     case 0: sink.os << "123"; break;
+                     case 1: sink.os << "456"; break;
+                     case 2: sink.os << "789"; break;
+                     case 3: {
+                       sink.done_with_trailer(
+                           {{"Content-Length", "5"}, {"X-Allowed", "yes"}});
+                     } break;
+                     }
+                     (*i)++;
+                     return true;
+                   },
+                   [i](bool success) {
+                     EXPECT_TRUE(success);
+                     delete i;
+                   });
+             })
         .Get("/streamed-chunked2",
              [&](const Request & /*req*/, Response &res) {
                auto i = new int(0);
@@ -11686,3 +11713,754 @@ TEST(ServerRequestParsingTest, RequestWithoutContentLengthOrTransferEncoding) {
                            &resp));
   EXPECT_TRUE(resp.find("HTTP/1.1 200 OK") == 0);
 }
+
+//==============================================================================
+// open_stream() Tests
+//==============================================================================
+
+inline std::string read_all(ClientImpl::StreamHandle &handle) {
+  std::string result;
+  char buf[8192];
+  ssize_t n;
+  while ((n = handle.read(buf, sizeof(buf))) > 0) {
+    result.append(buf, static_cast<size_t>(n));
+  }
+  return result;
+}
+
+// Mock stream for unit tests
+class MockStream : public Stream {
+public:
+  std::string data;
+  size_t pos = 0;
+  ssize_t error_after = -1; // -1 = no error
+
+  explicit MockStream(const std::string &d, ssize_t err = -1)
+      : data(d), error_after(err) {}
+  bool is_readable() const override { return true; }
+  bool wait_readable() const override { return true; }
+  bool wait_writable() const override { return true; }
+  ssize_t read(char *ptr, size_t size) override {
+    if (error_after >= 0 && pos >= static_cast<size_t>(error_after)) return -1;
+    if (pos >= data.size()) return 0;
+    size_t limit =
+        error_after >= 0 ? static_cast<size_t>(error_after) : data.size();
+    size_t to_read = std::min(size, std::min(data.size() - pos, limit - pos));
+    std::memcpy(ptr, data.data() + pos, to_read);
+    pos += to_read;
+    return static_cast<ssize_t>(to_read);
+  }
+  ssize_t write(const char *, size_t) override { return -1; }
+  void get_remote_ip_and_port(std::string &ip, int &port) const override {
+    ip = "127.0.0.1";
+    port = 0;
+  }
+  void get_local_ip_and_port(std::string &ip, int &port) const override {
+    ip = "127.0.0.1";
+    port = 0;
+  }
+  socket_t socket() const override { return INVALID_SOCKET; }
+  time_t duration() const override { return 0; }
+};
+
+TEST(StreamHandleTest, Basic) {
+  ClientImpl::StreamHandle handle;
+  EXPECT_FALSE(handle.is_valid());
+  handle.response = detail::make_unique<Response>();
+  handle.error = Error::Connection;
+  EXPECT_FALSE(handle.is_valid());
+  handle.error = Error::Success;
+  EXPECT_TRUE(handle.is_valid());
+}
+
+TEST(BodyReaderTest, Basic) {
+  MockStream stream("Hello, World!");
+  detail::BodyReader reader;
+  reader.stream = &stream;
+  reader.content_length = 13;
+  char buf[32];
+  EXPECT_EQ(13, reader.read(buf, sizeof(buf)));
+  EXPECT_EQ(0, reader.read(buf, sizeof(buf)));
+  EXPECT_TRUE(reader.eof);
+}
+
+TEST(BodyReaderTest, NoStream) {
+  detail::BodyReader reader;
+  char buf[32];
+  EXPECT_EQ(-1, reader.read(buf, sizeof(buf)));
+  EXPECT_EQ(Error::Connection, reader.last_error);
+}
+
+TEST(BodyReaderTest, Error) {
+  MockStream stream("Hello, World!", 5);
+  detail::BodyReader reader;
+  reader.stream = &stream;
+  reader.content_length = 13;
+  char buf[32];
+  EXPECT_EQ(5, reader.read(buf, sizeof(buf)));
+  EXPECT_EQ(-1, reader.read(buf, sizeof(buf)));
+  EXPECT_EQ(Error::Read, reader.last_error);
+}
+
+// Memory buffer mode removed: StreamHandle reads only from socket streams.
+// Mock-based StreamHandle tests relying on private internals are removed.
+
+class OpenStreamTest : public ::testing::Test {
+protected:
+  void SetUp() override {
+    svr_.Get("/hello", [](const Request &, Response &res) {
+      res.set_content("Hello World!", "text/plain");
+    });
+    svr_.Get("/large", [](const Request &, Response &res) {
+      res.set_content(std::string(10000, 'X'), "text/plain");
+    });
+    svr_.Get("/chunked", [](const Request &, Response &res) {
+      res.set_chunked_content_provider("text/plain",
+                                       [](size_t offset, DataSink &sink) {
+                                         if (offset < 15) {
+                                           sink.write("chunk", 5);
+                                           return true;
+                                         }
+                                         sink.done();
+                                         return true;
+                                       });
+    });
+    svr_.Get("/compressible", [](const Request &, Response &res) {
+      res.set_chunked_content_provider("text/plain", [](size_t offset,
+                                                        DataSink &sink) {
+        if (offset < 100 * 1024) {
+          std::string chunk(std::min(size_t(8192), 100 * 1024 - offset), 'A');
+          sink.write(chunk.data(), chunk.size());
+          return true;
+        }
+        sink.done();
+        return true;
+      });
+    });
+    svr_.Get("/streamed-chunked-with-prohibited-trailer",
+             [](const Request & /*req*/, Response &res) {
+               auto i = new int(0);
+               res.set_header("Trailer", "Content-Length, X-Allowed");
+               res.set_chunked_content_provider(
+                   "text/plain",
+                   [i](size_t /*offset*/, DataSink &sink) {
+                     switch (*i) {
+                     case 0: sink.os << "123"; break;
+                     case 1: sink.os << "456"; break;
+                     case 2: sink.os << "789"; break;
+                     case 3: {
+                       sink.done_with_trailer(
+                           {{"Content-Length", "5"}, {"X-Allowed", "yes"}});
+                     } break;
+                     }
+                     (*i)++;
+                     return true;
+                   },
+                   [i](bool success) {
+                     EXPECT_TRUE(success);
+                     delete i;
+                   });
+             });
+    // Echo headers endpoint for header-related tests
+    svr_.Get("/echo-headers", [](const Request &req, Response &res) {
+      std::string body;
+      for (const auto &h : req.headers) {
+        body.append(h.first);
+        body.push_back(':');
+        body.append(h.second);
+        body.push_back('\n');
+      }
+      res.set_content(body, "text/plain");
+    });
+    svr_.Post("/echo-headers", [](const Request &req, Response &res) {
+      std::string body;
+      for (const auto &h : req.headers) {
+        body.append(h.first);
+        body.push_back(':');
+        body.append(h.second);
+        body.push_back('\n');
+      }
+      res.set_content(body, "text/plain");
+    });
+    thread_ = std::thread([this]() { svr_.listen("127.0.0.1", 8787); });
+    svr_.wait_until_ready();
+  }
+  void TearDown() override {
+    svr_.stop();
+    if (thread_.joinable()) thread_.join();
+  }
+  Server svr_;
+  std::thread thread_;
+};
+
+TEST_F(OpenStreamTest, Basic) {
+  Client cli("127.0.0.1", 8787);
+  auto handle = cli.open_stream("GET", "/hello");
+  EXPECT_TRUE(handle.is_valid());
+  EXPECT_EQ("Hello World!", read_all(handle));
+}
+
+TEST_F(OpenStreamTest, SmallBuffer) {
+  Client cli("127.0.0.1", 8787);
+  auto handle = cli.open_stream("GET", "/hello");
+  std::string result;
+  char buf[4];
+  ssize_t n;
+  while ((n = handle.read(buf, sizeof(buf))) > 0)
+    result.append(buf, static_cast<size_t>(n));
+  EXPECT_EQ("Hello World!", result);
+}
+
+TEST_F(OpenStreamTest, DefaultHeaders) {
+  Client cli("127.0.0.1", 8787);
+
+  // open_stream GET should include Host, User-Agent and Accept-Encoding
+  {
+    auto handle = cli.open_stream("GET", "/echo-headers");
+    ASSERT_TRUE(handle.is_valid());
+    auto body = read_all(handle);
+    EXPECT_NE(body.find("Host:127.0.0.1:8787"), std::string::npos);
+    EXPECT_NE(body.find("User-Agent:cpp-httplib/" CPPHTTPLIB_VERSION),
+              std::string::npos);
+    EXPECT_NE(body.find("Accept-Encoding:"), std::string::npos);
+  }
+
+  // open_stream POST with body and no explicit content_type should NOT add
+  // text/plain Content-Type (behavior differs from non-streaming path), but
+  // should include Content-Length
+  {
+    auto handle = cli.open_stream("POST", "/echo-headers", {}, {}, "hello", "");
+    ASSERT_TRUE(handle.is_valid());
+    auto body = read_all(handle);
+    EXPECT_EQ(body.find("Content-Type: text/plain"), std::string::npos);
+    EXPECT_NE(body.find("Content-Length:5"), std::string::npos);
+  }
+
+  // open_stream POST with explicit Content-Type should preserve it
+  {
+    auto handle = cli.open_stream("POST", "/echo-headers", {},
+                                  {{"Content-Type", "application/custom"}},
+                                  "{}", "application/custom");
+    ASSERT_TRUE(handle.is_valid());
+    auto body = read_all(handle);
+    EXPECT_NE(body.find("Content-Type:application/custom"), std::string::npos);
+  }
+
+  // User-specified User-Agent must not be overwritten for stream API
+  {
+    auto handle = cli.open_stream("GET", "/echo-headers", {},
+                                  {{"User-Agent", "MyAgent/1.2"}});
+    ASSERT_TRUE(handle.is_valid());
+    auto body = read_all(handle);
+    EXPECT_NE(body.find("User-Agent:MyAgent/1.2"), std::string::npos);
+  }
+}
+
+TEST_F(OpenStreamTest, Large) {
+  Client cli("127.0.0.1", 8787);
+  auto handle = cli.open_stream("GET", "/large");
+  EXPECT_EQ(10000u, read_all(handle).size());
+}
+
+TEST_F(OpenStreamTest, ConnectionError) {
+  Client cli("127.0.0.1", 9999);
+  auto handle = cli.open_stream("GET", "/hello");
+  EXPECT_FALSE(handle.is_valid());
+}
+
+TEST_F(OpenStreamTest, Chunked) {
+  Client cli("127.0.0.1", 8787);
+  auto handle = cli.open_stream("GET", "/chunked");
+  EXPECT_TRUE(handle.response && handle.response->get_header_value(
+                                     "Transfer-Encoding") == "chunked");
+  EXPECT_EQ("chunkchunkchunk", read_all(handle));
+}
+
+TEST_F(OpenStreamTest, ProhibitedTrailersAreIgnored_Stream) {
+  Client cli("127.0.0.1", 8787);
+  auto handle =
+      cli.open_stream("GET", "/streamed-chunked-with-prohibited-trailer");
+  ASSERT_TRUE(handle.is_valid());
+
+  // Consume body to allow trailers to be received/parsed
+  auto body = read_all(handle);
+
+  // Explicitly parse trailers (ensure trailers are available for assertion)
+  handle.parse_trailers_if_needed();
+  EXPECT_EQ(std::string("123456789"), body);
+
+  // The response should include a Trailer header declaring both names
+  ASSERT_TRUE(handle.response);
+  EXPECT_TRUE(handle.response->has_header("Trailer"));
+  EXPECT_EQ(std::string("Content-Length, X-Allowed"),
+            handle.response->get_header_value("Trailer"));
+
+  // Prohibited trailer must not be present
+  EXPECT_FALSE(handle.response->has_trailer("Content-Length"));
+  // Allowed trailer should be present
+  EXPECT_TRUE(handle.response->has_trailer("X-Allowed"));
+  EXPECT_EQ(std::string("yes"),
+            handle.response->get_trailer_value("X-Allowed"));
+
+  // Verify trailers are NOT present as regular headers
+  EXPECT_EQ(std::string(""),
+            handle.response->get_header_value("Content-Length"));
+  EXPECT_EQ(std::string(""), handle.response->get_header_value("X-Allowed"));
+}
+
+#ifdef CPPHTTPLIB_ZLIB_SUPPORT
+TEST_F(OpenStreamTest, Gzip) {
+  Client cli("127.0.0.1", 8787);
+  auto handle = cli.open_stream("GET", "/compressible", {},
+                                {{"Accept-Encoding", "gzip"}});
+  EXPECT_EQ("gzip", handle.response->get_header_value("Content-Encoding"));
+  EXPECT_EQ(100u * 1024u, read_all(handle).size());
+}
+#endif
+
+#ifdef CPPHTTPLIB_BROTLI_SUPPORT
+TEST_F(OpenStreamTest, Brotli) {
+  Client cli("127.0.0.1", 8787);
+  auto handle =
+      cli.open_stream("GET", "/compressible", {}, {{"Accept-Encoding", "br"}});
+  EXPECT_EQ("br", handle.response->get_header_value("Content-Encoding"));
+  EXPECT_EQ(100u * 1024u, read_all(handle).size());
+}
+#endif
+
+#ifdef CPPHTTPLIB_ZSTD_SUPPORT
+TEST_F(OpenStreamTest, Zstd) {
+  Client cli("127.0.0.1", 8787);
+  auto handle = cli.open_stream("GET", "/compressible", {},
+                                {{"Accept-Encoding", "zstd"}});
+  EXPECT_EQ("zstd", handle.response->get_header_value("Content-Encoding"));
+  EXPECT_EQ(100u * 1024u, read_all(handle).size());
+}
+#endif
+
+#ifdef CPPHTTPLIB_OPENSSL_SUPPORT
+class SSLOpenStreamTest : public ::testing::Test {
+protected:
+  SSLOpenStreamTest() : svr_("cert.pem", "key.pem") {}
+  void SetUp() override {
+    svr_.Get("/hello", [](const Request &, Response &res) {
+      res.set_content("Hello SSL World!", "text/plain");
+    });
+    svr_.Get("/chunked", [](const Request &, Response &res) {
+      res.set_chunked_content_provider("text/plain",
+                                       [](size_t offset, DataSink &sink) {
+                                         if (offset < 15) {
+                                           sink.write("chunk", 5);
+                                           return true;
+                                         }
+                                         sink.done();
+                                         return true;
+                                       });
+    });
+    svr_.Post("/echo", [](const Request &req, Response &res) {
+      res.set_content(req.body, req.get_header_value("Content-Type"));
+    });
+    svr_.Post("/chunked-response", [](const Request &req, Response &res) {
+      std::string body = req.body;
+      res.set_chunked_content_provider(
+          "text/plain", [body](size_t offset, DataSink &sink) {
+            if (offset < body.size()) {
+              sink.write(body.data() + offset, body.size() - offset);
+            }
+            sink.done();
+            return true;
+          });
+    });
+    thread_ = std::thread([this]() { svr_.listen("127.0.0.1", 8788); });
+    svr_.wait_until_ready();
+  }
+  void TearDown() override {
+    svr_.stop();
+    if (thread_.joinable()) thread_.join();
+  }
+  SSLServer svr_;
+  std::thread thread_;
+};
+
+TEST_F(SSLOpenStreamTest, Basic) {
+  SSLClient cli("127.0.0.1", 8788);
+  cli.enable_server_certificate_verification(false);
+  auto handle = cli.open_stream("GET", "/hello");
+  ASSERT_TRUE(handle.is_valid());
+  EXPECT_EQ("Hello SSL World!", read_all(handle));
+}
+
+TEST_F(SSLOpenStreamTest, Chunked) {
+  SSLClient cli("127.0.0.1", 8788);
+  cli.enable_server_certificate_verification(false);
+
+  auto handle = cli.open_stream("GET", "/chunked");
+
+  ASSERT_TRUE(handle.is_valid()) << "Error: " << static_cast<int>(handle.error);
+  EXPECT_TRUE(handle.response && handle.response->get_header_value(
+                                     "Transfer-Encoding") == "chunked");
+
+  auto body = read_all(handle);
+  EXPECT_EQ("chunkchunkchunk", body);
+}
+
+TEST_F(SSLOpenStreamTest, Post) {
+  SSLClient cli("127.0.0.1", 8788);
+  cli.enable_server_certificate_verification(false);
+
+  auto handle =
+      cli.open_stream("POST", "/echo", {}, {}, "Hello SSL POST", "text/plain");
+
+  ASSERT_TRUE(handle.is_valid()) << "Error: " << static_cast<int>(handle.error);
+  EXPECT_EQ(200, handle.response->status);
+
+  auto body = read_all(handle);
+  EXPECT_EQ("Hello SSL POST", body);
+}
+
+TEST_F(SSLOpenStreamTest, PostChunked) {
+  SSLClient cli("127.0.0.1", 8788);
+  cli.enable_server_certificate_verification(false);
+
+  auto handle = cli.open_stream("POST", "/chunked-response", {}, {},
+                                "Chunked SSL Data", "text/plain");
+
+  ASSERT_TRUE(handle.is_valid());
+  EXPECT_EQ(200, handle.response->status);
+
+  auto body = read_all(handle);
+  EXPECT_EQ("Chunked SSL Data", body);
+}
+#endif // CPPHTTPLIB_OPENSSL_SUPPORT
+
+//==============================================================================
+// Parity Tests: ensure streaming and non-streaming APIs produce identical
+// results for various scenarios.
+//==============================================================================
+
+TEST(ParityTest, GetVsOpenStream) {
+  Server svr;
+
+  const std::string path = "/parity";
+  const std::string content = "Parity test content: hello world";
+
+  svr.Get(path, [&](const Request & /*req*/, Response &res) {
+    res.set_content(content, "text/plain");
+  });
+
+  auto t = std::thread([&]() { svr.listen(HOST, PORT); });
+  auto se = detail::scope_exit([&] {
+    svr.stop();
+    t.join();
+    ASSERT_FALSE(svr.is_running());
+  });
+
+  svr.wait_until_ready();
+
+  Client cli(HOST, PORT);
+
+  // Non-stream path
+  auto r1 = cli.Get(path);
+  ASSERT_TRUE(r1);
+  EXPECT_EQ(StatusCode::OK_200, r1->status);
+
+  // Stream path
+  auto h = cli.open_stream("GET", path);
+  ASSERT_TRUE(h.is_valid());
+
+  EXPECT_EQ(r1->body, read_all(h));
+}
+
+// Helper to compress data with provided compressor type T
+template <typename Compressor>
+static std::string compress_payload_for_parity(const std::string &in) {
+  std::string out;
+  Compressor compressor;
+  bool ok = compressor.compress(in.data(), in.size(), /*last=*/true,
+                                [&](const char *data, size_t n) {
+                                  out.append(data, n);
+                                  return true;
+                                });
+  EXPECT_TRUE(ok);
+  return out;
+}
+
+// Helper function for compression parity tests
+template <typename Compressor>
+static void test_compression_parity(const std::string &original,
+                                    const std::string &path,
+                                    const std::string &encoding) {
+  const std::string compressed =
+      compress_payload_for_parity<Compressor>(original);
+
+  Server svr;
+
+  svr.Get(path, [&](const Request & /*req*/, Response &res) {
+    res.set_content(compressed, "application/octet-stream");
+    res.set_header("Content-Encoding", encoding);
+  });
+
+  auto t = std::thread([&] { svr.listen("localhost", 1234); });
+  auto se = detail::scope_exit([&] {
+    svr.stop();
+    t.join();
+    ASSERT_FALSE(svr.is_running());
+  });
+
+  svr.wait_until_ready();
+
+  Client cli("localhost", 1234);
+
+  // Non-streaming
+  {
+    auto res = cli.Get(path);
+    ASSERT_TRUE(res);
+    EXPECT_EQ(StatusCode::OK_200, res->status);
+    EXPECT_EQ(original, res->body);
+  }
+
+  // Streaming
+  {
+    auto h = cli.open_stream("GET", path);
+    ASSERT_TRUE(h.is_valid());
+    EXPECT_EQ(original, read_all(h));
+  }
+}
+
+#ifdef CPPHTTPLIB_ZLIB_SUPPORT
+TEST(ParityTest, Gzip) {
+  test_compression_parity<detail::gzip_compressor>(
+      "The quick brown fox jumps over the lazy dog", "/parity-gzip", "gzip");
+}
+#endif
+
+#ifdef CPPHTTPLIB_BROTLI_SUPPORT
+TEST(ParityTest, Brotli) {
+  test_compression_parity<detail::brotli_compressor>(
+      "Hello, brotli parity test payload", "/parity-br", "br");
+}
+#endif
+
+#ifdef CPPHTTPLIB_ZSTD_SUPPORT
+TEST(ParityTest, Zstd) {
+  test_compression_parity<detail::zstd_compressor>(
+      "Zstandard parity test payload", "/parity-zstd", "zstd");
+}
+#endif
+
+//==============================================================================
+// New Stream API Tests
+//==============================================================================
+
+inline std::string read_body(httplib::stream::Result &result) {
+  std::string body;
+  while (result.next()) {
+    body.append(result.data(), result.size());
+  }
+  return body;
+}
+
+TEST(ClientConnectionTest, Basic) {
+  httplib::ClientConnection conn;
+  EXPECT_FALSE(conn.is_open());
+  conn.sock = 1;
+  EXPECT_TRUE(conn.is_open());
+  httplib::ClientConnection conn2(std::move(conn));
+  EXPECT_EQ(INVALID_SOCKET, conn.sock);
+  conn2.sock = INVALID_SOCKET;
+}
+
+// Unified test server for all stream::* tests
+class StreamApiTest : public ::testing::Test {
+protected:
+  void SetUp() override {
+    svr_.Get("/hello", [](const httplib::Request &, httplib::Response &res) {
+      res.set_content("Hello World!", "text/plain");
+    });
+    svr_.Get("/echo-params",
+             [](const httplib::Request &req, httplib::Response &res) {
+               std::string r;
+               for (const auto &p : req.params) {
+                 if (!r.empty()) r += "&";
+                 r += p.first + "=" + p.second;
+               }
+               res.set_content(r, "text/plain");
+             });
+    svr_.Post("/echo", [](const httplib::Request &req, httplib::Response &res) {
+      res.set_content(req.body, req.get_header_value("Content-Type"));
+    });
+    svr_.Post("/echo-headers",
+              [](const httplib::Request &req, httplib::Response &res) {
+                std::string r;
+                for (const auto &h : req.headers)
+                  r += h.first + ": " + h.second + "\n";
+                res.set_content(r, "text/plain");
+              });
+    svr_.Post("/echo-params",
+              [](const httplib::Request &req, httplib::Response &res) {
+                std::string r = "params:";
+                for (const auto &p : req.params)
+                  r += p.first + "=" + p.second + ";";
+                res.set_content(r + " body:" + req.body, "text/plain");
+              });
+    svr_.Post("/large", [](const httplib::Request &, httplib::Response &res) {
+      res.set_content(std::string(100 * 1024, 'X'), "application/octet-stream");
+    });
+    svr_.Put("/echo", [](const httplib::Request &req, httplib::Response &res) {
+      res.set_content("PUT:" + req.body, "text/plain");
+    });
+    svr_.Patch("/echo",
+               [](const httplib::Request &req, httplib::Response &res) {
+                 res.set_content("PATCH:" + req.body, "text/plain");
+               });
+    svr_.Delete(
+        "/resource", [](const httplib::Request &req, httplib::Response &res) {
+          res.set_content(req.body.empty() ? "Deleted" : "Deleted:" + req.body,
+                          "text/plain");
+        });
+    svr_.Get("/head-test",
+             [](const httplib::Request &, httplib::Response &res) {
+               res.set_content("body for HEAD", "text/plain");
+             });
+    svr_.Options("/options",
+                 [](const httplib::Request &, httplib::Response &res) {
+                   res.set_header("Allow", "GET, POST, PUT, DELETE, OPTIONS");
+                 });
+    thread_ = std::thread([this]() { svr_.listen("localhost", 8790); });
+    svr_.wait_until_ready();
+  }
+  void TearDown() override {
+    svr_.stop();
+    if (thread_.joinable()) thread_.join();
+  }
+  httplib::Server svr_;
+  std::thread thread_;
+};
+
+// stream::Get tests
+TEST_F(StreamApiTest, GetBasic) {
+  httplib::Client cli("localhost", 8790);
+  auto result = httplib::stream::Get(cli, "/hello");
+  ASSERT_TRUE(result.is_valid());
+  EXPECT_EQ(200, result.status());
+  EXPECT_EQ("Hello World!", read_body(result));
+}
+
+TEST_F(StreamApiTest, GetWithParams) {
+  httplib::Client cli("localhost", 8790);
+  httplib::Params params{{"foo", "bar"}};
+  auto result = httplib::stream::Get(cli, "/echo-params", params);
+  ASSERT_TRUE(result.is_valid());
+  EXPECT_TRUE(read_body(result).find("foo=bar") != std::string::npos);
+}
+
+TEST_F(StreamApiTest, GetConnectionError) {
+  httplib::Client cli("localhost", 9999);
+  EXPECT_FALSE(httplib::stream::Get(cli, "/hello").is_valid());
+}
+
+TEST_F(StreamApiTest, Get404) {
+  httplib::Client cli("localhost", 8790);
+  auto result = httplib::stream::Get(cli, "/nonexistent");
+  EXPECT_TRUE(result.is_valid());
+  EXPECT_EQ(404, result.status());
+}
+
+// stream::Post tests
+TEST_F(StreamApiTest, PostBasic) {
+  httplib::Client cli("localhost", 8790);
+  auto result = httplib::stream::Post(cli, "/echo", R"({"key":"value"})",
+                                      "application/json");
+  ASSERT_TRUE(result.is_valid());
+  EXPECT_EQ("application/json", result.get_header_value("Content-Type"));
+  EXPECT_EQ(R"({"key":"value"})", read_body(result));
+}
+
+TEST_F(StreamApiTest, PostWithHeaders) {
+  httplib::Client cli("localhost", 8790);
+  httplib::Headers headers{{"X-Custom", "value"}};
+  auto result = httplib::stream::Post(cli, "/echo-headers", headers, "body",
+                                      "text/plain");
+  EXPECT_TRUE(read_body(result).find("X-Custom: value") != std::string::npos);
+}
+
+TEST_F(StreamApiTest, PostWithParams) {
+  httplib::Client cli("localhost", 8790);
+  httplib::Params params{{"k", "v"}};
+  auto result =
+      httplib::stream::Post(cli, "/echo-params", params, "data", "text/plain");
+  auto body = read_body(result);
+  EXPECT_TRUE(body.find("k=v") != std::string::npos);
+  EXPECT_TRUE(body.find("body:data") != std::string::npos);
+}
+
+TEST_F(StreamApiTest, PostLarge) {
+  httplib::Client cli("localhost", 8790);
+  auto result = httplib::stream::Post(cli, "/large", "", "text/plain");
+  size_t total = 0;
+  while (result.next()) {
+    total += result.size();
+  }
+  EXPECT_EQ(100u * 1024u, total);
+}
+
+// stream::Put/Patch tests
+TEST_F(StreamApiTest, PutAndPatch) {
+  httplib::Client cli("localhost", 8790);
+  auto put = httplib::stream::Put(cli, "/echo", "test", "text/plain");
+  EXPECT_EQ("PUT:test", read_body(put));
+  auto patch = httplib::stream::Patch(cli, "/echo", "test", "text/plain");
+  EXPECT_EQ("PATCH:test", read_body(patch));
+}
+
+// stream::Delete tests
+TEST_F(StreamApiTest, Delete) {
+  httplib::Client cli("localhost", 8790);
+  auto del1 = httplib::stream::Delete(cli, "/resource");
+  EXPECT_EQ("Deleted", read_body(del1));
+  auto del2 = httplib::stream::Delete(cli, "/resource", "data", "text/plain");
+  EXPECT_EQ("Deleted:data", read_body(del2));
+}
+
+// stream::Head/Options tests
+TEST_F(StreamApiTest, HeadAndOptions) {
+  httplib::Client cli("localhost", 8790);
+  auto head = httplib::stream::Head(cli, "/head-test");
+  EXPECT_TRUE(head.is_valid());
+  EXPECT_FALSE(head.get_header_value("Content-Length").empty());
+
+  auto opts = httplib::stream::Options(cli, "/options");
+  EXPECT_EQ("GET, POST, PUT, DELETE, OPTIONS", opts.get_header_value("Allow"));
+}
+
+// SSL stream::* tests
+#ifdef CPPHTTPLIB_OPENSSL_SUPPORT
+class SSLStreamApiTest : public ::testing::Test {
+protected:
+  void SetUp() override {
+    svr_.Get("/hello", [](const httplib::Request &, httplib::Response &res) {
+      res.set_content("Hello SSL!", "text/plain");
+    });
+    svr_.Post("/echo", [](const httplib::Request &req, httplib::Response &res) {
+      res.set_content(req.body, "text/plain");
+    });
+    thread_ = std::thread([this]() { svr_.listen("127.0.0.1", 8803); });
+    svr_.wait_until_ready();
+  }
+  void TearDown() override {
+    svr_.stop();
+    if (thread_.joinable()) thread_.join();
+  }
+  httplib::SSLServer svr_{"cert.pem", "key.pem"};
+  std::thread thread_;
+};
+
+TEST_F(SSLStreamApiTest, GetAndPost) {
+  httplib::SSLClient cli("127.0.0.1", 8803);
+  cli.enable_server_certificate_verification(false);
+  auto get = httplib::stream::Get(cli, "/hello");
+  EXPECT_EQ("Hello SSL!", read_body(get));
+  auto post = httplib::stream::Post(cli, "/echo", "test", "text/plain");
+  EXPECT_EQ("test", read_body(post));
+}
+#endif

Kaikkia tiedostoja ei voida näyttää, sillä liian monta tiedostoa muuttui tässä diffissä