diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index a353ccf..9a0384b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -21,15 +21,11 @@ jobs: - name: Checkout Repository uses: actions/checkout@v4 - - name: Setup vcpkg - uses: lukka/run-vcpkg@v11 - - name: Configure CMake shell: bash run: | cmake -B build \ - -DCMAKE_BUILD_TYPE=${{ matrix.build_type }} \ - -DCMAKE_TOOLCHAIN_FILE="$VCPKG_ROOT/scripts/buildsystems/vcpkg.cmake" + -DCMAKE_BUILD_TYPE=${{ matrix.build_type }} - name: Build Project shell: bash diff --git a/include/cpp_request/headers.hpp b/include/cpp_request/headers.hpp new file mode 100644 index 0000000..79b26e8 --- /dev/null +++ b/include/cpp_request/headers.hpp @@ -0,0 +1,35 @@ +#pragma once + +#include +#include +#include +#include + +namespace cpp_request { + +class Headers { +public: + struct Field { + std::string name; + std::string value; + }; + + using const_iterator = std::vector::const_iterator; + + void add(std::string_view name, std::string_view value); + void set(std::string_view name, std::string_view value); + + [[nodiscard]] bool contains(std::string_view name) const noexcept; + [[nodiscard]] std::string_view get(std::string_view name) const noexcept; + + [[nodiscard]] std::size_t size() const noexcept { return fields_.size(); } + [[nodiscard]] bool empty() const noexcept { return fields_.empty(); } + + [[nodiscard]] const_iterator begin() const noexcept { return fields_.begin(); } + [[nodiscard]] const_iterator end() const noexcept { return fields_.end(); } + +private: + std::vector fields_; +}; + +} // namespace cpp_request diff --git a/include/cpp_request/request.hpp b/include/cpp_request/request.hpp new file mode 100644 index 0000000..f0c9841 --- /dev/null +++ b/include/cpp_request/request.hpp @@ -0,0 +1,39 @@ +#pragma once + +#include + +#include + +namespace cpp_request { + +enum class Method { + Get, + Head, + Post, + Put, + Patch, + Delete +}; + +class Request { +public: + Request(Method method, std::string_view url) noexcept + : method_(method), url_(url) {} + + [[nodiscard]] Method method() const noexcept { return method_; } + [[nodiscard]] std::string_view url() const noexcept { return url_; } + + [[nodiscard]] Headers& headers() noexcept { return headers_; } + [[nodiscard]] const Headers& headers() const noexcept { return headers_; } + + void set_body(std::string_view body) noexcept { body_ = body; } + [[nodiscard]] std::string_view body() const noexcept { return body_; } + +private: + Method method_; + std::string_view url_; + Headers headers_; + std::string_view body_; +}; + +} // namespace cpp_request diff --git a/src/CMakeLists.txt b/src/CMakeLists.txt index a370cc9..8e864dc 100644 --- a/src/CMakeLists.txt +++ b/src/CMakeLists.txt @@ -1,6 +1,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}/platform/native_socket.cpp ${CMAKE_CURRENT_SOURCE_DIR}/platform/network_runtime.cpp ${CMAKE_CURRENT_SOURCE_DIR}/platform/socket_mode.cpp diff --git a/src/headers.cpp b/src/headers.cpp new file mode 100644 index 0000000..94eddaf --- /dev/null +++ b/src/headers.cpp @@ -0,0 +1,88 @@ +#include + +#include +#include +#include + +namespace cpp_request { +namespace { + +constexpr char ascii_lower(char ch) noexcept { + if (ch >= 'A' && ch <= 'Z') { + return static_cast(ch + ('a' - 'A')); + } + return ch; +} + +bool ascii_iequals(std::string_view lhs, std::string_view rhs) noexcept { + if (lhs.size() != rhs.size()) { + return false; + } + + for (std::size_t index = 0; index < lhs.size(); ++index) { + if (ascii_lower(lhs[index]) != ascii_lower(rhs[index])) { + return false; + } + } + return true; +} + +} // namespace + +void Headers::add(std::string_view name, std::string_view value) { + fields_.push_back(Field{std::string{name}, std::string{value}}); +} + +void Headers::set(std::string_view name, std::string_view value) { + std::string owned_name{name}; + std::string owned_value{value}; + + const auto first = std::find_if( + fields_.begin(), + fields_.end(), + [&owned_name](const Field& field) { + return ascii_iequals(field.name, owned_name); + }); + + if (first == fields_.end()) { + fields_.push_back(Field{std::move(owned_name), std::move(owned_value)}); + return; + } + + first->name = owned_name; + first->value = std::move(owned_value); + + fields_.erase( + std::remove_if( + std::next(first), + fields_.end(), + [&owned_name](const Field& field) { + return ascii_iequals(field.name, owned_name); + }), + fields_.end()); +} + +bool Headers::contains(std::string_view name) const noexcept { + return std::any_of( + fields_.begin(), + fields_.end(), + [name](const Field& field) { + return ascii_iequals(field.name, name); + }); +} + +std::string_view Headers::get(std::string_view name) const noexcept { + const auto found = std::find_if( + fields_.begin(), + fields_.end(), + [name](const Field& field) { + return ascii_iequals(field.name, name); + }); + + if (found == fields_.end()) { + return {}; + } + return found->value; +} + +} // namespace cpp_request diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index 35aa12f..e07818a 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -3,6 +3,8 @@ find_package(Threads REQUIRED) add_executable(cpp_request_tests ${CMAKE_CURRENT_SOURCE_DIR}/url_test.cpp + ${CMAKE_CURRENT_SOURCE_DIR}/headers_test.cpp + ${CMAKE_CURRENT_SOURCE_DIR}/request_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/headers_test.cpp b/tests/headers_test.cpp new file mode 100644 index 0000000..e87ef7d --- /dev/null +++ b/tests/headers_test.cpp @@ -0,0 +1,85 @@ +#include + +#include + +#include +#include +#include + +namespace { + +using cpp_request::Headers; + +TEST(HeadersTest, AddPreservesDuplicatesAndInsertionOrder) { + Headers headers; + headers.add("Set-Cookie", "a=1"); + headers.add("X-Trace", "first"); + headers.add("set-cookie", "b=2"); + + ASSERT_EQ(headers.size(), 3U); + + auto it = headers.begin(); + EXPECT_EQ(it->name, "Set-Cookie"); + EXPECT_EQ(it->value, "a=1"); + ++it; + EXPECT_EQ(it->name, "X-Trace"); + EXPECT_EQ(it->value, "first"); + ++it; + EXPECT_EQ(it->name, "set-cookie"); + EXPECT_EQ(it->value, "b=2"); +} + +TEST(HeadersTest, LookupIsAsciiCaseInsensitiveAndReturnsFirstValue) { + Headers headers; + headers.add("Content-Type", "text/plain"); + headers.add("content-type", "application/json"); + + EXPECT_TRUE(headers.contains("CONTENT-TYPE")); + EXPECT_EQ(headers.get("content-TYPE"), "text/plain"); + EXPECT_FALSE(headers.contains("Accept")); + EXPECT_TRUE(headers.get("Accept").empty()); +} + +TEST(HeadersTest, SetReplacesAllMatchingFields) { + Headers headers; + headers.add("X-Test", "one"); + headers.add("Other", "keep"); + headers.add("x-test", "two"); + + headers.set("X-TEST", "final"); + + EXPECT_EQ(headers.size(), 2U); + EXPECT_EQ(headers.get("x-test"), "final"); + + std::size_t matches = 0; + for (const auto& field : headers) { + if (field.name == "X-TEST") { + ++matches; + } + } + EXPECT_EQ(matches, 1U); +} + +TEST(HeadersTest, InsertedFieldsOwnTheirStorage) { + Headers headers; + std::string name = "X-Owned"; + std::string value = "original"; + + headers.add(name, value); + + name.assign("changed"); + value.assign("mutated"); + + EXPECT_TRUE(headers.contains("X-Owned")); + EXPECT_EQ(headers.get("X-Owned"), "original"); +} + +TEST(HeadersTest, SetAddsFieldWhenMissing) { + Headers headers; + headers.set("Accept", "application/json"); + + ASSERT_EQ(headers.size(), 1U); + EXPECT_EQ(headers.get("accept"), "application/json"); +} + +} // namespace diff --git a/tests/request_test.cpp b/tests/request_test.cpp new file mode 100644 index 0000000..d3cd08f --- /dev/null +++ b/tests/request_test.cpp @@ -0,0 +1,66 @@ +#include + +#include + +#include + +namespace { + +using cpp_request::Method; +using cpp_request::Request; + +TEST(RequestTest, StoresMethodAndBorrowedUrl) { + std::string url = "http://example.com/items"; + Request request{Method::Get, url}; + + EXPECT_EQ(request.method(), Method::Get); + EXPECT_EQ(request.url(), url); + EXPECT_EQ(request.url().data(), url.data()); +} + +TEST(RequestTest, BodyIsBorrowedWithoutCopy) { + std::string body = R"({"name":"zuu"})"; + Request request{Method::Post, "http://example.com/items"}; + + request.set_body(body); + + EXPECT_EQ(request.body(), body); + EXPECT_EQ(request.body().data(), body.data()); +} + +TEST(RequestTest, OwnsHeaderStorageIndependentlyFromBorrowedRequestData) { + std::string header_name = "Content-Type"; + std::string header_value = "application/json"; + + Request request{Method::Post, "http://example.com/items"}; + request.headers().add(header_name, header_value); + + header_name.assign("changed"); + header_value.assign("changed"); + + EXPECT_EQ(request.headers().get("content-type"), "application/json"); +} + +TEST(RequestTest, ConstAccessorsExposeRequestState) { + Request mutable_request{Method::Patch, "http://example.com/item/1"}; + mutable_request.set_body("patch-body"); + mutable_request.headers().add("X-Test", "value"); + + const Request& request = mutable_request; + + EXPECT_EQ(request.method(), Method::Patch); + EXPECT_EQ(request.url(), "http://example.com/item/1"); + EXPECT_EQ(request.body(), "patch-body"); + EXPECT_EQ(request.headers().get("x-test"), "value"); +} + +TEST(RequestTest, SupportsAllV1Methods) { + EXPECT_EQ((Request{Method::Get, "http://example.com"}.method()), Method::Get); + EXPECT_EQ((Request{Method::Head, "http://example.com"}.method()), Method::Head); + EXPECT_EQ((Request{Method::Post, "http://example.com"}.method()), Method::Post); + EXPECT_EQ((Request{Method::Put, "http://example.com"}.method()), Method::Put); + EXPECT_EQ((Request{Method::Patch, "http://example.com"}.method()), Method::Patch); + EXPECT_EQ((Request{Method::Delete, "http://example.com"}.method()), Method::Delete); +} + +} // namespace