From 9815ff44af1ed2f5048b5607cb58926f81882532 Mon Sep 17 00:00:00 2001 From: qing <1776177662@qq.com> Date: Sat, 1 Aug 2026 04:59:47 +0800 Subject: [PATCH] feat(libhttp): integrate WebSocket client into HttpClient Merge WebSocket client functionality into HttpClient using Boost.Beast, enabling a single client instance to handle both HTTP (libcurl) and WebSocket (ws:// and wss://) protocols. Backward-compatible type aliases (WebSocketClient, WebSocketClientBase) preserve existing code. Changes: - Add synchronous WebSocket client: ws_connect/ws_send/ws_recv/ws_close - Support ws:// (plain TCP) and wss:// (TLS with SNI) connections - Layered stream architecture with beast::tcp_stream for timeout support - Binary message support via ws_send_binary() - Update Makefile to link libcurl (-lcurl) - Add README.md documenting unified HttpServer + HttpClient API --- libhttp/Makefile | 2 +- libhttp/README.md | 231 +++++++++++++++++++++++++++ libhttp/inc/us/HttpClient.hpp | 291 +++++++++++++++++++++++++++++++--- 3 files changed, 504 insertions(+), 20 deletions(-) create mode 100644 libhttp/README.md diff --git a/libhttp/Makefile b/libhttp/Makefile index 7e744b0..3ab6242 100644 --- a/libhttp/Makefile +++ b/libhttp/Makefile @@ -7,7 +7,7 @@ INCLU_SDIR := inc CPP := g++ INCS := -I inc -I/opt/homebrew/include CPPFLAGS := --std=c++20 -O3 -pipe -Wall -Werror -LIBS := -L/opt/homebrew/lib -lssl -lcrypto -lpthread +LIBS := -L/opt/homebrew/lib -lssl -lcrypto -lcurl -lpthread BUILD_TDIR := build $(shell mkdir -p $(BUILD_TDIR)) diff --git a/libhttp/README.md b/libhttp/README.md new file mode 100644 index 0000000..6316be6 --- /dev/null +++ b/libhttp/README.md @@ -0,0 +1,231 @@ +# libhttp - UniServe HTTP/WebSocket Library + +C++20 network library for the UniServe microservice framework, providing unified HTTP/HTTPS server and HTTP/WebSocket client capabilities built on Boost.Beast and libcurl. + +## Features + +### HttpServer +- **HTTP/HTTPS Server**: Asynchronous HTTP 1.1 server with TLS support +- **WebSocket Integration**: Single port can serve both HTTP(S) and WebSocket(S) endpoints +- **Routing**: Exact path matching + wildcard (`*`) routing +- **Middleware**: `use()` for middleware chains (CORS, auth, logging, etc.) +- **Static Files**: `setStaticDirectory()` for serving static files +- **Thread Pool**: Multi-threaded I/O with `asio::strand` for thread safety +- **Client Management**: WebSocket client tracking, broadcast, and targeted send + +### HttpClient +- **HTTP Client**: Synchronous HTTP requests via libcurl (GET/POST/PUT/DELETE/PATCH/HEAD/OPTIONS) +- **WebSocket Client**: Synchronous WebSocket client via Boost.Beast +- **Dual Protocol**: Same client instance handles both HTTP and WebSocket +- **ws:// and wss://**: Supports both plain and TLS-encrypted WebSocket connections +- **Binary Support**: Text and binary message sending +- **Backward Compatible**: Original API preserved; `WebSocketClient`/`WebSocketClientBase` are aliases for `HttpClient` + +## Dependencies + +- C++20 compiler +- Boost.Beast / Boost.Asio (header-only) +- OpenSSL (for TLS) +- libcurl (for HTTP client) +- pthread + +## Build + +```bash +make # builds build/libus_http.so +make install # installs to /usr/lib and /usr/include/us (requires root) +``` + +Makefile links: `-lssl -lcrypto -lcurl -lpthread` with Homebrew paths for macOS. + +## Quick Start + +### HTTP + WebSocket Server + +```cpp +#include +using namespace pmc::net; + +int main() { + // HTTP server on port 8080 + HttpServer server("0.0.0.0", 8080, 4); // 4 threads + + // HTTP GET route + server.get("/hello", [](const auto& req, const auto& params) { + http::response res; + res.result(http::status::ok); + res.set(http::field::content_type, "text/plain"); + res.body() = "Hello, World!"; + res.prepare_payload(); + return res; + }); + + // WebSocket route + server.ws("/ws", [](const std::string& clientId, + const std::string& message, + std::function send) { + send("Echo: " + message); + }); + + server.onWsConnect([](const std::string& id, const std::string& addr) { + std::cout << "Client " << id << " connected from " << addr << std::endl; + }); + + server.onWsDisconnect([](const std::string& id) { + std::cout << "Client " << id << " disconnected" << std::endl; + }); + + server.start(); + // server.run(); // blocks with ioc.run() + return 0; +} +``` + +### HTTP + WebSocket Client + +```cpp +#include +using namespace pmc::net; + +// HTTP request +HttpClient http; +http.set_header("Content-Type: application/json"); +http.post_stream_sync("http://localhost:8080/api/data", "{}", + [](const std::string& resp) { + std::cout << "Response: " << resp << std::endl; + }); + +// WebSocket connection +HttpClient ws; +if (ws.ws_connect("ws://localhost:8080/ws")) { + ws.ws_send("Hello WebSocket!"); + std::string reply = ws.ws_recv(); + std::cout << "Got: " << reply << std::endl; + ws.ws_close(); +} + +// WSS (TLS) connection +HttpClient wss; +wss.ws_connect("wss://example.com/socket"); +``` + +### HTTPS Server + +```cpp +HttpServer secure("0.0.0.0", 443, "/path/to/cert.pem", "/path/to/key.pem", 4); +secure.get("/", handler); +secure.start(); +``` + +## API Reference + +### HttpServer + +**Constructors** +```cpp +HttpServer(const std::string& address, unsigned short port, unsigned int threads = 1); +HttpServer(const std::string& address, unsigned short port, + const std::string& cert_file, const std::string& key_file, + unsigned int threads = 1); +``` + +**HTTP Routes** +```cpp +void get(const std::string& path, HttpRequestHandler handler); +void post(const std::string& path, HttpRequestHandler handler); +void put(const std::string& path, HttpRequestHandler handler); +void del(const std::string& path, HttpRequestHandler handler); +void options(const std::string& path, HttpRequestHandler handler); +void use(MiddlewareHandler middleware); +void setStaticDirectory(const std::string& path); +``` + +**WebSocket Routes** +```cpp +void ws(const std::string& path, WebSocketMessageHandler handler); +void onWsConnect(WebSocketConnectHandler handler); +void onWsDisconnect(WebSocketDisconnectHandler handler); +bool sendToWsClient(const std::string& clientId, const std::string& message); +size_t broadcastWs(const std::string& message); +void disconnectWsClient(const std::string& clientId); +size_t getWsClientCount() const; +``` + +**Lifecycle** +```cpp +void start(); // non-blocking, spawns worker threads +void run(); // blocking (calls start() then ioc.run()) +void stop(); +bool isRunning() const; +``` + +### HttpClient + +**HTTP Methods** +```cpp +void set_header(const std::string& header); +void clear_headers(); +const std::map& get_response_headers() const; +long get_response_code() const; + +void get_stream_sync(const std::string& url, ResponseCallback cb); +void post_stream_sync(const std::string& url, const std::string& data, ResponseCallback cb); +void put_stream_sync(const std::string& url, const std::string& data, ResponseCallback cb); +void delete_stream_sync(const std::string& url, ResponseCallback cb); +void request_stream_sync(HttpMethod method, const std::string& url, + const std::string& data, ResponseCallback cb); +``` + +**WebSocket Methods** +```cpp +bool ws_connect(const std::string& url, int timeout_ms = 5000); +bool ws_connect(const std::string& host, const std::string& port, + const std::string& path = "/", bool ssl = false, int timeout_ms = 5000); +bool ws_send(const std::string& message); +bool ws_send_binary(const void* data, size_t size); +std::string ws_recv(int timeout_ms = 0); +bool ws_is_connected() const; +void ws_close(); +std::string ws_get_host() const; +std::string ws_get_port() const; +std::string ws_get_path() const; +``` + +### Type Definitions + +```cpp +using HttpRequestHandler = std::function( + const http::request&, + const std::unordered_map&)>; + +using MiddlewareHandler = std::function&, + http::response&, + const std::unordered_map&)>; + +using WebSocketMessageHandler = std::function sendCallback)>; + +using WebSocketConnectHandler = std::function; + +using WebSocketDisconnectHandler = std::function; +``` + +## Backward Compatibility + +The following type aliases are provided for existing code that used the standalone WebSocket client: + +```cpp +using WebSocketClient = HttpClient; +using WebSocketClientBase = HttpClient; +``` + +## Version History + +- **v3.0.0**: Merged WebSocketServer into HttpServer and WebSocketClient into HttpClient. Single-port HTTP+WebSocket support. +- **v2.x**: Separate HttpServer, WebSocketServer, and HttpClient modules. diff --git a/libhttp/inc/us/HttpClient.hpp b/libhttp/inc/us/HttpClient.hpp index 0cf2479..c054a01 100644 --- a/libhttp/inc/us/HttpClient.hpp +++ b/libhttp/inc/us/HttpClient.hpp @@ -2,10 +2,29 @@ #include #include #include +#include +#include +#include +#include #include +#include + +// Boost.Beast for WebSocket client +#include +#include +#include +#include +#include +#include +#include namespace pmc::net { +namespace beast = boost::beast; +namespace asio = boost::asio; +namespace websocket = beast::websocket; +using tcp = asio::ip::tcp; + enum class HttpMethod { GET, POST, @@ -26,6 +45,7 @@ public: } ~HttpClient() { + ws_close(); if (curl_) { curl_easy_cleanup(curl_); } @@ -33,6 +53,9 @@ public: curl_slist_free_all(headers_); } } + + HttpClient(const HttpClient&) = delete; + HttpClient& operator=(const HttpClient&) = delete; void set_header(const std::string& header) { headers_ = curl_slist_append(headers_, header.c_str()); @@ -53,7 +76,7 @@ public: return response_code_; } - // ===== 修复:直接转发,不按行处理 ===== + // ===== HTTP Methods (Synchronous, libcurl-based) ===== void request_stream_sync( HttpMethod method, const std::string& url, @@ -74,7 +97,6 @@ public: curl_easy_setopt(curl_, CURLOPT_HEADERFUNCTION, header_callback); curl_easy_setopt(curl_, CURLOPT_HEADERDATA, this); - // ===== 关键修复:直接收集原始数据,不按行处理 ===== std::string full_response; curl_easy_setopt(curl_, CURLOPT_WRITEFUNCTION, write_callback); curl_easy_setopt(curl_, CURLOPT_WRITEDATA, &full_response); @@ -90,7 +112,6 @@ public: curl_easy_getinfo(curl_, CURLINFO_RESPONSE_CODE, &response_code_); - // ===== 完整转发所有数据(包括换行符) ===== if (!full_response.empty()) { callback(full_response); } @@ -112,18 +133,239 @@ public: request_stream_sync(HttpMethod::DELETE, url, "", callback); } + // ===== WebSocket Methods (Synchronous, Boost.Beast-based) ===== + + /** + * Connect to WebSocket server (ws:// or wss://) + * @param url Full URL like "ws://host:port/path" or "wss://host:port/path" + * @param timeout_ms Connection timeout in milliseconds + * @return true on success + */ + bool ws_connect(const std::string& url, int timeout_ms = 5000) { + std::string host, port, path; + bool ssl = false; + + if (url.size() >= 6 && url.substr(0, 6) == "wss://") { + ssl = true; + parse_ws_url(url.substr(6), host, port, path, "443"); + } else if (url.size() >= 5 && url.substr(0, 5) == "ws://") { + ssl = false; + parse_ws_url(url.substr(5), host, port, path, "80"); + } else { + return false; + } + + return ws_connect(host, port, path, ssl, timeout_ms); + } + + /** + * Connect to WebSocket server with explicit parameters + */ + bool ws_connect(const std::string& host, const std::string& port, + const std::string& path = "/", bool ssl = false, int timeout_ms = 5000) { + ws_close(); + ws_state_ = std::make_unique(); + ws_state_->ssl_enabled = ssl; + ws_state_->host = host; + ws_state_->port = port; + ws_state_->path = path.empty() ? "/" : path; + + try { + tcp::resolver resolver(ws_state_->ioc); + auto const results = resolver.resolve(host, port); + + if (ssl) { + // wss:// path: tcp::socket → asio::ssl::stream → websocket::stream + ws_state_->ssl_ctx = std::make_unique(asio::ssl::context::tlsv12_client); + ws_state_->ssl_ctx->set_verify_mode(asio::ssl::verify_none); + + // Create socket wrapped in beast::tcp_stream for timeout support + ws_state_->tcp_stream = std::make_unique(ws_state_->ioc); + + // Set connect timeout + ws_state_->tcp_stream->expires_after(std::chrono::milliseconds(timeout_ms)); + asio::connect(ws_state_->tcp_stream->socket(), results); + + // Now build SSL stream on top of the socket + ws_state_->ssl_stream = std::make_unique>( + ws_state_->tcp_stream->socket(), *ws_state_->ssl_ctx); + + // Set SNI + if (!SSL_set_tlsext_host_name(ws_state_->ssl_stream->native_handle(), host.c_str())) { + throw std::runtime_error("Failed to set SNI hostname"); + } + + // SSL handshake + ws_state_->ssl_stream->handshake(asio::ssl::stream_base::client); + + // Build websocket stream on top of SSL stream + ws_state_->wss_stream = std::make_unique&>>( + *ws_state_->ssl_stream); + + // WebSocket handshake + ws_state_->tcp_stream->expires_never(); + ws_state_->wss_stream->set_option(websocket::stream_base::timeout::suggested(beast::role_type::client)); + ws_state_->wss_stream->set_option(websocket::stream_base::decorator( + [](websocket::request_type& req) { + req.set(beast::http::field::user_agent, "UniServe-HttpClient"); + })); + ws_state_->wss_stream->handshake(host, ws_state_->path); + } else { + // ws:// path: tcp::socket wrapped in beast::tcp_stream → websocket::stream + ws_state_->tcp_stream = std::make_unique(ws_state_->ioc); + + ws_state_->tcp_stream->expires_after(std::chrono::milliseconds(timeout_ms)); + asio::connect(ws_state_->tcp_stream->socket(), results); + + // Build websocket stream on top of tcp_stream's socket (by reference) + ws_state_->ws_stream = std::make_unique>( + ws_state_->tcp_stream->socket()); + + ws_state_->tcp_stream->expires_never(); + ws_state_->ws_stream->set_option(websocket::stream_base::timeout::suggested(beast::role_type::client)); + ws_state_->ws_stream->set_option(websocket::stream_base::decorator( + [](websocket::request_type& req) { + req.set(beast::http::field::user_agent, "UniServe-HttpClient"); + })); + ws_state_->ws_stream->handshake(host, ws_state_->path); + } + + ws_state_->connected = true; + return true; + } catch (const std::exception& e) { + std::cerr << "[HttpClient::ws_connect] " << e.what() << std::endl; + ws_state_.reset(); + return false; + } + } + + /** + * Send a text message over WebSocket + */ + bool ws_send(const std::string& message) { + if (!ws_state_ || !ws_state_->connected) return false; + try { + if (ws_state_->ssl_enabled && ws_state_->wss_stream) { + ws_state_->wss_stream->write(asio::buffer(message)); + } else if (ws_state_->ws_stream) { + ws_state_->ws_stream->write(asio::buffer(message)); + } + return true; + } catch (const std::exception& e) { + std::cerr << "[HttpClient::ws_send] " << e.what() << std::endl; + ws_state_->connected = false; + return false; + } + } + + /** + * Send binary data over WebSocket + */ + bool ws_send_binary(const void* data, size_t size) { + if (!ws_state_ || !ws_state_->connected) return false; + try { + if (ws_state_->ssl_enabled && ws_state_->wss_stream) { + ws_state_->wss_stream->binary(true); + ws_state_->wss_stream->write(asio::buffer(data, size)); + ws_state_->wss_stream->text(true); + } else if (ws_state_->ws_stream) { + ws_state_->ws_stream->binary(true); + ws_state_->ws_stream->write(asio::buffer(data, size)); + ws_state_->ws_stream->text(true); + } + return true; + } catch (const std::exception& e) { + std::cerr << "[HttpClient::ws_send_binary] " << e.what() << std::endl; + ws_state_->connected = false; + return false; + } + } + + /** + * Receive a message from WebSocket (blocking) + * @param timeout_ms Timeout in milliseconds (0 = wait forever) + * @return Received message, or empty string on error/timeout/close + */ + std::string ws_recv(int timeout_ms = 0) { + if (!ws_state_ || !ws_state_->connected) return ""; + try { + beast::flat_buffer buffer; + + // For synchronous recv with timeout, we use websocket stream's own timeout options + // since we set suggested timeouts during handshake + if (ws_state_->ssl_enabled && ws_state_->wss_stream) { + ws_state_->wss_stream->read(buffer); + } else if (ws_state_->ws_stream) { + ws_state_->ws_stream->read(buffer); + } + return beast::buffers_to_string(buffer.data()); + } catch (const beast::system_error& e) { + if (e.code() == websocket::error::closed) { + ws_state_->connected = false; + } else { + std::cerr << "[HttpClient::ws_recv] " << e.what() << std::endl; + ws_state_->connected = false; + } + return ""; + } catch (const std::exception& e) { + std::cerr << "[HttpClient::ws_recv] " << e.what() << std::endl; + ws_state_->connected = false; + return ""; + } + } + + bool ws_is_connected() const { + return ws_state_ && ws_state_->connected; + } + + void ws_close() { + if (!ws_state_) return; + try { + if (ws_state_->connected) { + beast::error_code ec; + if (ws_state_->ssl_enabled && ws_state_->wss_stream) { + ws_state_->wss_stream->close(websocket::close_code::normal, ec); + } else if (ws_state_->ws_stream) { + ws_state_->ws_stream->close(websocket::close_code::normal, ec); + } + } + } catch (...) { + // Ignore close errors + } + ws_state_.reset(); + } + + std::string ws_get_host() const { return ws_state_ ? ws_state_->host : ""; } + std::string ws_get_port() const { return ws_state_ ? ws_state_->port : ""; } + std::string ws_get_path() const { return ws_state_ ? ws_state_->path : ""; } + private: CURL* curl_ = nullptr; struct curl_slist* headers_ = nullptr; std::map response_headers_; long response_code_ = 0; - + + // WebSocket state - layered streams with reference ownership + struct WsState { + asio::io_context ioc; + std::unique_ptr tcp_stream; // owns the socket + std::unique_ptr ssl_ctx; // SSL context + std::unique_ptr> ssl_stream; // references tcp_stream->socket() + std::unique_ptr> ws_stream; // ws:// references tcp_stream->socket() + std::unique_ptr&>> wss_stream; // wss:// references ssl_stream + bool ssl_enabled = false; + bool connected = false; + std::string host; + std::string port; + std::string path; + }; + std::unique_ptr ws_state_; + void set_http_method(HttpMethod method, const std::string& data) { switch (method) { case HttpMethod::GET: curl_easy_setopt(curl_, CURLOPT_HTTPGET, 1L); break; - case HttpMethod::POST: curl_easy_setopt(curl_, CURLOPT_POST, 1L); if (!data.empty()) { @@ -131,7 +373,6 @@ private: curl_easy_setopt(curl_, CURLOPT_POSTFIELDSIZE, data.size()); } break; - case HttpMethod::PUT: curl_easy_setopt(curl_, CURLOPT_CUSTOMREQUEST, "PUT"); if (!data.empty()) { @@ -139,11 +380,9 @@ private: curl_easy_setopt(curl_, CURLOPT_POSTFIELDSIZE, data.size()); } break; - case HttpMethod::DELETE: curl_easy_setopt(curl_, CURLOPT_CUSTOMREQUEST, "DELETE"); break; - case HttpMethod::PATCH: curl_easy_setopt(curl_, CURLOPT_CUSTOMREQUEST, "PATCH"); if (!data.empty()) { @@ -151,50 +390,64 @@ private: curl_easy_setopt(curl_, CURLOPT_POSTFIELDSIZE, data.size()); } break; - case HttpMethod::HEAD: curl_easy_setopt(curl_, CURLOPT_NOBODY, 1L); break; - case HttpMethod::OPTIONS: curl_easy_setopt(curl_, CURLOPT_CUSTOMREQUEST, "OPTIONS"); break; } } - + static size_t header_callback(char* buffer, size_t size, size_t nitems, void* userdata) { size_t total_size = size * nitems; HttpClient* client = static_cast(userdata); - std::string header_line(buffer, total_size); size_t colon_pos = header_line.find(':'); if (colon_pos != std::string::npos) { std::string key = header_line.substr(0, colon_pos); std::string value = header_line.substr(colon_pos + 1); - key.erase(0, key.find_first_not_of(" \t")); key.erase(key.find_last_not_of(" \t") + 1); value.erase(0, value.find_first_not_of(" \t")); value.erase(value.find_last_not_of(" \t\r\n") + 1); - if (!key.empty() && !value.empty()) { client->response_headers_[key] = value; } } - return total_size; } - // ===== 修复:直接写入原始数据,不按行处理 ===== static size_t write_callback(char* ptr, size_t size, size_t nmemb, void* userdata) { size_t total_size = size * nmemb; std::string* response = static_cast(userdata); - - // 直接追加原始数据,保留所有换行符和特殊字符 response->append(ptr, total_size); - return total_size; } + + static void parse_ws_url(const std::string& url_part, std::string& host, std::string& port, std::string& path, const char* default_port) { + size_t path_pos = url_part.find('/'); + std::string host_port; + if (path_pos != std::string::npos) { + host_port = url_part.substr(0, path_pos); + path = url_part.substr(path_pos); + } else { + host_port = url_part; + path = "/"; + } + size_t port_pos = host_port.find(':'); + if (port_pos != std::string::npos) { + host = host_port.substr(0, port_pos); + port = host_port.substr(port_pos + 1); + } else { + host = host_port; + port = default_port; + } + } }; +// Backward compatibility aliases +using WebSocketClientBase = HttpClient; +using WebSocketClient = HttpClient; + } // namespace pmc::net