Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 1 addition & 5 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
35 changes: 35 additions & 0 deletions include/cpp_request/headers.hpp
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
#pragma once

#include <cstddef>
#include <string>
#include <string_view>
#include <vector>

namespace cpp_request {

class Headers {
public:
struct Field {
std::string name;
std::string value;
};

using const_iterator = std::vector<Field>::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<Field> fields_;
};

} // namespace cpp_request
39 changes: 39 additions & 0 deletions include/cpp_request/request.hpp
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
#pragma once

#include <string_view>

#include <cpp_request/headers.hpp>

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
1 change: 1 addition & 0 deletions src/CMakeLists.txt
Original file line number Diff line number Diff line change
@@ -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
Expand Down
88 changes: 88 additions & 0 deletions src/headers.cpp
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
#include <cpp_request/headers.hpp>

#include <algorithm>
#include <iterator>
#include <utility>

namespace cpp_request {
namespace {

constexpr char ascii_lower(char ch) noexcept {
if (ch >= 'A' && ch <= 'Z') {
return static_cast<char>(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
2 changes: 2 additions & 0 deletions tests/CMakeLists.txt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
85 changes: 85 additions & 0 deletions tests/headers_test.cpp
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
#include <gtest/gtest.h>

#include <cpp_request/headers.hpp>

#include <string>
#include <string_view>
#include <vector>

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
66 changes: 66 additions & 0 deletions tests/request_test.cpp
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
#include <gtest/gtest.h>

#include <cpp_request/request.hpp>

#include <string>

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
Loading