diff --git a/include/cpp_request/client.hpp b/include/cpp_request/client.hpp new file mode 100644 index 0000000..2628587 --- /dev/null +++ b/include/cpp_request/client.hpp @@ -0,0 +1,62 @@ +#pragma once + +#include +#include + +#include +#include +#include + +namespace cpp_request { + +class Client final { +public: + Client() noexcept = default; + + [[nodiscard]] Result request(const Request& request); + + [[nodiscard]] Result get(std::string_view url); + [[nodiscard]] Result head(std::string_view url); + [[nodiscard]] Result post( + std::string_view url, + std::string_view body = {}); + [[nodiscard]] Result put( + std::string_view url, + std::string_view body = {}); + [[nodiscard]] Result patch( + std::string_view url, + std::string_view body = {}); + [[nodiscard]] Result del(std::string_view url); + + void set_connect_timeout(std::chrono::milliseconds timeout) noexcept { + connect_timeout_ = timeout; + } + + void set_read_timeout(std::chrono::milliseconds timeout) noexcept { + read_timeout_ = timeout; + } + + void set_write_timeout(std::chrono::milliseconds timeout) noexcept { + write_timeout_ = timeout; + } + +private: + std::chrono::milliseconds connect_timeout_{5000}; + std::chrono::milliseconds read_timeout_{30000}; + std::chrono::milliseconds write_timeout_{30000}; +}; + +[[nodiscard]] Result get(std::string_view url); +[[nodiscard]] Result head(std::string_view url); +[[nodiscard]] Result post( + std::string_view url, + std::string_view body = {}); +[[nodiscard]] Result put( + std::string_view url, + std::string_view body = {}); +[[nodiscard]] Result patch( + std::string_view url, + std::string_view body = {}); +[[nodiscard]] Result del(std::string_view url); + +} // namespace cpp_request diff --git a/src/CMakeLists.txt b/src/CMakeLists.txt index aa28d3e..9da39e2 100644 --- a/src/CMakeLists.txt +++ b/src/CMakeLists.txt @@ -2,6 +2,7 @@ add_library(internal ${CMAKE_CURRENT_SOURCE_DIR}/dummy.cpp ${CMAKE_CURRENT_SOURCE_DIR}/url.cpp ${CMAKE_CURRENT_SOURCE_DIR}/headers.cpp + ${CMAKE_CURRENT_SOURCE_DIR}/client.cpp ${CMAKE_CURRENT_SOURCE_DIR}/http/request_serializer.cpp ${CMAKE_CURRENT_SOURCE_DIR}/http/chunked_decoder.cpp ${CMAKE_CURRENT_SOURCE_DIR}/http/response_parser.cpp diff --git a/src/client.cpp b/src/client.cpp new file mode 100644 index 0000000..d7aab35 --- /dev/null +++ b/src/client.cpp @@ -0,0 +1,170 @@ +#include + +#include "http/request_serializer.hpp" +#include "http/response_parser.hpp" +#include "net/resolver.hpp" +#include "net/tcp_connection.hpp" + +#include +#include +#include +#include +#include + +namespace cpp_request { +namespace { + +[[nodiscard]] std::chrono::milliseconds remaining_timeout( + std::chrono::steady_clock::time_point deadline) noexcept { + const auto now = std::chrono::steady_clock::now(); + if (now >= deadline) { + return std::chrono::milliseconds{0}; + } + + auto remaining = std::chrono::duration_cast( + deadline - now); + if (remaining.count() <= 0) { + remaining = std::chrono::milliseconds{1}; + } + return remaining; +} + +[[nodiscard]] Request make_request( + Method method, + std::string_view url, + std::string_view body = {}) noexcept { + Request request{method, url}; + request.set_body(body); + return request; +} + +} // namespace + +Result Client::request(const Request& request_value) { + auto serialized_result = detail::http::serialize_request(request_value); + if (!serialized_result) { + return serialized_result.error(); + } + auto serialized = std::move(serialized_result).value(); + + auto endpoints_result = detail::net::Resolver::resolve( + serialized.url.host(), + serialized.url.port()); + if (!endpoints_result) { + return endpoints_result.error(); + } + + auto connection_result = detail::net::TcpConnection::connect( + endpoints_result.value(), + connect_timeout_); + if (!connection_result) { + return connection_result.error(); + } + auto connection = std::move(connection_result).value(); + + const auto write_deadline = std::chrono::steady_clock::now() + write_timeout_; + + auto head_write = connection.write_all( + serialized.head, + remaining_timeout(write_deadline)); + if (!head_write) { + return head_write.error(); + } + + if (!serialized.body.empty()) { + auto body_write = connection.write_all( + serialized.body, + remaining_timeout(write_deadline)); + if (!body_write) { + return body_write.error(); + } + } + + detail::http::ResponseParser parser{request_value.method()}; + std::array read_buffer{}; + + while (!parser.complete()) { + auto read_result = connection.read_some( + read_buffer.data(), + read_buffer.size(), + read_timeout_); + + if (!read_result) { + if (read_result.error().code == ErrorCode::ConnectionClosed) { + auto eof_result = parser.finish_eof(); + if (!eof_result) { + return eof_result.error(); + } + if (parser.complete()) { + break; + } + } + return read_result.error(); + } + + auto parse_result = parser.feed(std::string_view{ + read_buffer.data(), + read_result.value()}); + if (!parse_result) { + return parse_result.error(); + } + } + + return parser.take_response(); +} + +Result Client::get(std::string_view url) { + return request(make_request(Method::Get, url)); +} + +Result Client::head(std::string_view url) { + return request(make_request(Method::Head, url)); +} + +Result Client::post(std::string_view url, std::string_view body) { + return request(make_request(Method::Post, url, body)); +} + +Result Client::put(std::string_view url, std::string_view body) { + return request(make_request(Method::Put, url, body)); +} + +Result Client::patch(std::string_view url, std::string_view body) { + return request(make_request(Method::Patch, url, body)); +} + +Result Client::del(std::string_view url) { + return request(make_request(Method::Delete, url)); +} + +Result get(std::string_view url) { + Client client; + return client.get(url); +} + +Result head(std::string_view url) { + Client client; + return client.head(url); +} + +Result post(std::string_view url, std::string_view body) { + Client client; + return client.post(url, body); +} + +Result put(std::string_view url, std::string_view body) { + Client client; + return client.put(url, body); +} + +Result patch(std::string_view url, std::string_view body) { + Client client; + return client.patch(url, body); +} + +Result del(std::string_view url) { + Client client; + return client.del(url); +} + +} // namespace cpp_request diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index 0e15c25..13f6524 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -9,6 +9,7 @@ add_executable(cpp_request_tests ${CMAKE_CURRENT_SOURCE_DIR}/chunked_decoder_test.cpp ${CMAKE_CURRENT_SOURCE_DIR}/response_parser_test.cpp ${CMAKE_CURRENT_SOURCE_DIR}/response_upgrade_test.cpp + ${CMAKE_CURRENT_SOURCE_DIR}/client_test.cpp ${CMAKE_CURRENT_SOURCE_DIR}/native_socket_test.cpp ${CMAKE_CURRENT_SOURCE_DIR}/resolver_test.cpp ${CMAKE_CURRENT_SOURCE_DIR}/tcp_connection_test.cpp diff --git a/tests/client_test.cpp b/tests/client_test.cpp new file mode 100644 index 0000000..2983db9 --- /dev/null +++ b/tests/client_test.cpp @@ -0,0 +1,326 @@ +#include + +#include "platform/native_socket.hpp" +#include "platform/network_runtime.hpp" + +#include +#include +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#ifdef _WIN32 +#include +#else +#include +#include +#endif + +namespace { + +using cpp_request::Client; +using cpp_request::ErrorCode; +using cpp_request::Method; +using cpp_request::Request; +using cpp_request::detail::platform::NativeSocket; +using cpp_request::detail::platform::NativeSocketHandle; + +struct LoopbackServer { + NativeSocket listener; + std::uint16_t port{0}; +}; + +LoopbackServer make_loopback_server() { + const auto runtime = cpp_request::detail::platform::ensure_network_runtime(); + if (!runtime.ok) { + return {}; + } + + NativeSocket listener{::socket(AF_INET, SOCK_STREAM, IPPROTO_TCP)}; + if (!listener) { + return {}; + } + + sockaddr_in address{}; + address.sin_family = AF_INET; + address.sin_port = 0; + address.sin_addr.s_addr = htonl(INADDR_LOOPBACK); + + if (::bind( + listener.get(), + reinterpret_cast(&address), + static_cast(sizeof(address))) != 0) { + return {}; + } + + if (::listen(listener.get(), 1) != 0) { + return {}; + } + +#ifdef _WIN32 + int address_size = sizeof(address); +#else + socklen_t address_size = sizeof(address); +#endif + if (::getsockname( + listener.get(), + reinterpret_cast(&address), + &address_size) != 0) { + return {}; + } + + return { + std::move(listener), + ntohs(address.sin_port)}; +} + +NativeSocket accept_one(NativeSocketHandle listener) { + return NativeSocket{::accept(listener, nullptr, nullptr)}; +} + +int receive_native( + NativeSocketHandle socket, + char* buffer, + std::size_t capacity) { +#ifdef _WIN32 + return ::recv(socket, buffer, static_cast(capacity), 0); +#else + return static_cast(::recv(socket, buffer, capacity, 0)); +#endif +} + +int send_native( + NativeSocketHandle socket, + const char* data, + std::size_t size) { +#ifdef _WIN32 + return ::send(socket, data, static_cast(size), 0); +#else + return static_cast(::send(socket, data, size, 0)); +#endif +} + +bool send_all_native(NativeSocketHandle socket, std::string_view data) { + std::size_t written = 0; + while (written < data.size()) { + const int count = send_native( + socket, + data.data() + written, + data.size() - written); + if (count <= 0) { + return false; + } + written += static_cast(count); + } + return true; +} + +std::size_t parse_content_length(std::string_view headers) { + constexpr std::string_view kName = "Content-Length:"; + const std::size_t position = headers.find(kName); + if (position == std::string_view::npos) { + return 0; + } + + std::size_t begin = position + kName.size(); + while (begin < headers.size() && headers[begin] == ' ') { + ++begin; + } + + const std::size_t end = headers.find("\r\n", begin); + const std::string_view value = headers.substr(begin, end - begin); + std::size_t length = 0; + const auto parsed = std::from_chars( + value.data(), + value.data() + value.size(), + length, + 10); + return parsed.ec == std::errc{} && parsed.ptr == value.data() + value.size() + ? length + : 0; +} + +std::string receive_request(NativeSocketHandle socket) { + std::string request; + std::array buffer{}; + std::size_t expected_size = 0; + + for (;;) { + const int count = receive_native(socket, buffer.data(), buffer.size()); + if (count <= 0) { + break; + } + + request.append(buffer.data(), static_cast(count)); + const std::size_t head_end = request.find("\r\n\r\n"); + if (head_end != std::string::npos) { + expected_size = head_end + 4 + + parse_content_length(std::string_view{request}.substr(0, head_end + 2)); + if (request.size() >= expected_size) { + break; + } + } + } + + return request; +} + +std::string make_url(std::uint16_t port, std::string_view target = "/") { + return "http://127.0.0.1:" + std::to_string(port) + std::string{target}; +} + +void configure_test_timeouts(Client& client) { + client.set_connect_timeout(std::chrono::seconds{2}); + client.set_write_timeout(std::chrono::seconds{2}); + client.set_read_timeout(std::chrono::seconds{2}); +} + +TEST(ClientTest, ExecutesRequestEndToEnd) { + auto server = make_loopback_server(); + ASSERT_TRUE(server.listener); + + std::string received_request; + std::thread worker([&] { + NativeSocket peer = accept_one(server.listener.get()); + if (!peer) { + return; + } + received_request = receive_request(peer.get()); + (void)send_all_native( + peer.get(), + "HTTP/1.1 200 OK\r\n" + "Content-Length: 5\r\n" + "X-Server: loopback\r\n" + "\r\n" + "hello"); + }); + + const std::string url = make_url(server.port, "/hello?x=1"); + Request request{Method::Get, url}; + request.headers().add("X-Client", "cpp_request"); + + Client client; + configure_test_timeouts(client); + auto result = client.request(request); + + worker.join(); + + ASSERT_TRUE(result); + EXPECT_EQ(result.value().status_code(), 200); + EXPECT_EQ(result.value().body(), "hello"); + EXPECT_EQ(result.value().headers().get("x-server"), "loopback"); + EXPECT_NE(received_request.find("GET /hello?x=1 HTTP/1.1\r\n"), std::string::npos); + EXPECT_NE(received_request.find("X-Client: cpp_request\r\n"), std::string::npos); + EXPECT_NE( + received_request.find( + "Host: 127.0.0.1:" + std::to_string(server.port) + "\r\n"), + std::string::npos); +} + +TEST(ClientTest, SendsBodyAndDecodesChunkedResponse) { + auto server = make_loopback_server(); + ASSERT_TRUE(server.listener); + + std::string received_request; + std::thread worker([&] { + NativeSocket peer = accept_one(server.listener.get()); + if (!peer) { + return; + } + received_request = receive_request(peer.get()); + (void)send_all_native( + peer.get(), + "HTTP/1.1 201 Created\r\n" + "Transfer-Encoding: chunked\r\n" + "\r\n" + "4\r\nWiki\r\n" + "5\r\npedia\r\n" + "0\r\n\r\n"); + }); + + const std::string url = make_url(server.port, "/submit"); + Client client; + configure_test_timeouts(client); + auto result = client.post(url, "payload"); + + worker.join(); + + ASSERT_TRUE(result); + EXPECT_EQ(result.value().status_code(), 201); + EXPECT_EQ(result.value().body(), "Wikipedia"); + EXPECT_NE(received_request.find("POST /submit HTTP/1.1\r\n"), std::string::npos); + EXPECT_NE(received_request.find("Content-Length: 7\r\n"), std::string::npos); + ASSERT_GE(received_request.size(), 7U); + EXPECT_EQ(received_request.substr(received_request.size() - 7), "payload"); +} + +TEST(ClientTest, CompletesCloseDelimitedResponseOnPeerClose) { + auto server = make_loopback_server(); + ASSERT_TRUE(server.listener); + + std::thread worker([&] { + NativeSocket peer = accept_one(server.listener.get()); + if (!peer) { + return; + } + (void)receive_request(peer.get()); + (void)send_all_native( + peer.get(), + "HTTP/1.1 200 OK\r\n" + "Content-Type: text/plain\r\n" + "\r\n" + "close-delimited"); + peer.close(); + }); + + Client client; + configure_test_timeouts(client); + auto result = client.get(make_url(server.port)); + + worker.join(); + + ASSERT_TRUE(result); + EXPECT_EQ(result.value().body(), "close-delimited"); +} + +TEST(ClientTest, PropagatesProtocolErrors) { + auto server = make_loopback_server(); + ASSERT_TRUE(server.listener); + + std::thread worker([&] { + NativeSocket peer = accept_one(server.listener.get()); + if (!peer) { + return; + } + (void)receive_request(peer.get()); + (void)send_all_native(peer.get(), "HTTP/1.0 200 OK\r\n\r\n"); + }); + + Client client; + configure_test_timeouts(client); + auto result = client.get(make_url(server.port)); + + worker.join(); + + ASSERT_FALSE(result); + EXPECT_EQ(result.error().code, ErrorCode::InvalidStatusLine); +} + +TEST(ClientTest, RejectsUnsupportedSchemeBeforeNetworking) { + Client client; + auto result = client.get("https://example.com/"); + + ASSERT_FALSE(result); + EXPECT_EQ(result.error().code, ErrorCode::UnsupportedScheme); +} + +} // namespace