diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 45e8481..a353ccf 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -21,12 +21,22 @@ 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 }} + cmake -B build \ + -DCMAKE_BUILD_TYPE=${{ matrix.build_type }} \ + -DCMAKE_TOOLCHAIN_FILE="$VCPKG_ROOT/scripts/buildsystems/vcpkg.cmake" - name: Build Project shell: bash run: | - cmake --build build --config ${{ matrix.build_type }} \ No newline at end of file + cmake --build build --config ${{ matrix.build_type }} + + - name: Run Tests + shell: bash + run: | + ctest --test-dir build --build-config ${{ matrix.build_type }} --output-on-failure diff --git a/CMakeLists.txt b/CMakeLists.txt index b4f6b00..1a2b51e 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -6,17 +6,20 @@ project( LANGUAGES CXX C ) -set(CMAKE_CXX_STANDARD 11) +set(CMAKE_CXX_STANDARD 17) set(CMAKE_CXX_STANDARD_REQUIRED ON) set(CMAKE_CXX_EXTENSIONS OFF) set(CMAKE_EXPORT_COMPILE_COMMANDS ON) +include(CTest) include(cmake/modules/output.cmake) include(cmake/modules/warning.cmake) include(cmake/modules/sanitizing.cmake) include(cmake/modules/optimizing.cmake) add_subdirectory(${CMAKE_SOURCE_DIR}/src) -add_subdirectory(${CMAKE_SOURCE_DIR}/tests) +if(BUILD_TESTING) + add_subdirectory(${CMAKE_SOURCE_DIR}/tests) +endif() add_subdirectory(${CMAKE_SOURCE_DIR}/benchmarks) add_subdirectory(${CMAKE_SOURCE_DIR}/examples) \ No newline at end of file diff --git a/CMakePresets.json b/CMakePresets.json index 27f80b7..8e82de0 100644 --- a/CMakePresets.json +++ b/CMakePresets.json @@ -9,10 +9,9 @@ "cacheVariables": { "CMAKE_C_COMPILER": "clang", "CMAKE_CXX_COMPILER": "clang++", - "CMAKE_CXX_STANDARD": "23", + "CMAKE_CXX_STANDARD": "17", "CMAKE_CXX_STANDARD_REQUIRED": "ON", "CMAKE_CXX_EXTENSIONS": "OFF", - "CMAKE_CXX_FLAGS": "-Wno-c2y-extensions", "CMAKE_TOOLCHAIN_FILE": "$env{VCPKG_ROOT}/scripts/buildsystems/vcpkg.cmake" } }, { @@ -40,4 +39,4 @@ "configurePreset": "release" } ] -} \ No newline at end of file +} diff --git a/include/cpp_request/error.hpp b/include/cpp_request/error.hpp new file mode 100644 index 0000000..7b178d6 --- /dev/null +++ b/include/cpp_request/error.hpp @@ -0,0 +1,73 @@ +#pragma once + +#include + +namespace cpp_request { + +enum class ErrorCode { + InvalidUrl, + UnsupportedScheme, + InvalidPort, + + ResolveFailed, + SocketCreateFailed, + ConnectFailed, + ConnectTimeout, + WriteFailed, + WriteTimeout, + ReadFailed, + ReadTimeout, + ConnectionClosed, + + MalformedResponse, + InvalidStatusLine, + InvalidHeader, + InvalidContentLength, + InvalidChunkSize, + InvalidChunkFraming, + ConflictingMessageFraming, + UnexpectedEof, + + RedirectLimitExceeded, + MissingRedirectLocation, + UnsupportedRedirectScheme, + + Unknown +}; + +struct Error { + ErrorCode code; + int native_code{0}; +}; + +[[nodiscard]] constexpr std::string_view error_message(ErrorCode code) noexcept { + switch (code) { + case ErrorCode::InvalidUrl: return "invalid URL"; + case ErrorCode::UnsupportedScheme: return "unsupported URL scheme"; + case ErrorCode::InvalidPort: return "invalid port"; + case ErrorCode::ResolveFailed: return "name resolution failed"; + case ErrorCode::SocketCreateFailed: return "socket creation failed"; + case ErrorCode::ConnectFailed: return "connection failed"; + case ErrorCode::ConnectTimeout: return "connection timed out"; + case ErrorCode::WriteFailed: return "write failed"; + case ErrorCode::WriteTimeout: return "write timed out"; + case ErrorCode::ReadFailed: return "read failed"; + case ErrorCode::ReadTimeout: return "read timed out"; + case ErrorCode::ConnectionClosed: return "connection closed"; + case ErrorCode::MalformedResponse: return "malformed HTTP response"; + case ErrorCode::InvalidStatusLine: return "invalid HTTP status line"; + case ErrorCode::InvalidHeader: return "invalid HTTP header"; + case ErrorCode::InvalidContentLength: return "invalid Content-Length"; + case ErrorCode::InvalidChunkSize: return "invalid chunk size"; + case ErrorCode::InvalidChunkFraming: return "invalid chunk framing"; + case ErrorCode::ConflictingMessageFraming: return "conflicting HTTP message framing"; + case ErrorCode::UnexpectedEof: return "unexpected end of stream"; + case ErrorCode::RedirectLimitExceeded: return "redirect limit exceeded"; + case ErrorCode::MissingRedirectLocation: return "redirect location missing"; + case ErrorCode::UnsupportedRedirectScheme: return "unsupported redirect scheme"; + case ErrorCode::Unknown: return "unknown error"; + } + return "unknown error"; +} + +} // namespace cpp_request diff --git a/include/cpp_request/result.hpp b/include/cpp_request/result.hpp new file mode 100644 index 0000000..c88f635 --- /dev/null +++ b/include/cpp_request/result.hpp @@ -0,0 +1,58 @@ +#pragma once + +#include +#include +#include +#include + +#include + +namespace cpp_request { + +template +class [[nodiscard]] Result { +public: + Result(T value) + : storage_(std::move(value)) {} + + Result(Error error) + : storage_(error) {} + + [[nodiscard]] bool has_value() const noexcept { + return std::holds_alternative(storage_); + } + + [[nodiscard]] explicit operator bool() const noexcept { + return has_value(); + } + + T& value() & noexcept { + assert(has_value()); + return std::get(storage_); + } + + const T& value() const& noexcept { + assert(has_value()); + return std::get(storage_); + } + + T&& value() && noexcept { + assert(has_value()); + return std::get(std::move(storage_)); + } + + Error& error() & noexcept { + assert(!has_value()); + return std::get(storage_); + } + + const Error& error() const& noexcept { + assert(!has_value()); + return std::get(storage_); + } + +private: + std::variant storage_; +}; + +} // namespace cpp_request diff --git a/include/cpp_request/url.hpp b/include/cpp_request/url.hpp new file mode 100644 index 0000000..74dc3b4 --- /dev/null +++ b/include/cpp_request/url.hpp @@ -0,0 +1,45 @@ +#pragma once + +#include +#include +#include +#include + +#include + +namespace cpp_request { + +class Url { +public: + [[nodiscard]] static Result parse(std::string_view input); + + [[nodiscard]] std::string_view scheme() const noexcept; + [[nodiscard]] std::string_view host() const noexcept; + [[nodiscard]] std::string_view path() const noexcept; + [[nodiscard]] std::string_view query() const noexcept; + [[nodiscard]] std::string_view target() const noexcept; + + [[nodiscard]] std::uint16_t port() const noexcept { return port_; } + [[nodiscard]] bool has_explicit_port() const noexcept { return has_explicit_port_; } + [[nodiscard]] bool host_is_ipv6_literal() const noexcept { return host_is_ipv6_literal_; } + +private: + std::string storage_; + + std::size_t scheme_begin_{0}; + std::size_t scheme_size_{0}; + std::size_t host_begin_{0}; + std::size_t host_size_{0}; + std::size_t path_begin_{0}; + std::size_t path_size_{0}; + std::size_t query_begin_{0}; + std::size_t query_size_{0}; + std::size_t target_begin_{0}; + std::size_t target_size_{0}; + + std::uint16_t port_{80}; + bool has_explicit_port_{false}; + bool host_is_ipv6_literal_{false}; +}; + +} // namespace cpp_request diff --git a/src/CMakeLists.txt b/src/CMakeLists.txt index b33cffb..e57a175 100644 --- a/src/CMakeLists.txt +++ b/src/CMakeLists.txt @@ -1,5 +1,6 @@ -add_library(internal +add_library(internal ${CMAKE_CURRENT_SOURCE_DIR}/dummy.cpp + ${CMAKE_CURRENT_SOURCE_DIR}/url.cpp ) add_library(${PROJECT_NAME}::internal ALIAS internal) @@ -9,4 +10,6 @@ target_include_directories(internal ${CMAKE_SOURCE_DIR}/include PRIVATE ${CMAKE_SOURCE_DIR}/src -) \ No newline at end of file +) + +target_compile_features(internal PUBLIC cxx_std_17) diff --git a/src/url.cpp b/src/url.cpp new file mode 100644 index 0000000..8eae0b9 --- /dev/null +++ b/src/url.cpp @@ -0,0 +1,204 @@ +#include + +#include +#include +#include +#include + +namespace cpp_request { +namespace { + +[[nodiscard]] bool ascii_iequals(std::string_view lhs, std::string_view rhs) noexcept { + if (lhs.size() != rhs.size()) { + return false; + } + + for (std::size_t i = 0; i < lhs.size(); ++i) { + const auto a = static_cast(lhs[i]); + const auto b = static_cast(rhs[i]); + if (std::tolower(a) != std::tolower(b)) { + return false; + } + } + return true; +} + +[[nodiscard]] bool contains_forbidden_url_byte(std::string_view input) noexcept { + for (const unsigned char ch : input) { + if (ch <= 0x20 || ch == 0x7f) { + return true; + } + } + return false; +} + +[[nodiscard]] Result parse_port(std::string_view text) noexcept { + if (text.empty()) { + return Error{ErrorCode::InvalidPort}; + } + + unsigned int value = 0; + const char* const begin = text.data(); + const char* const end = text.data() + text.size(); + const auto parsed = std::from_chars(begin, end, value, 10); + + if (parsed.ec != std::errc{} || parsed.ptr != end || value == 0 || + value > std::numeric_limits::max()) { + return Error{ErrorCode::InvalidPort}; + } + + return static_cast(value); +} + +} // namespace + +Result Url::parse(std::string_view input) { + if (input.empty() || contains_forbidden_url_byte(input)) { + return Error{ErrorCode::InvalidUrl}; + } + + const std::size_t scheme_separator = input.find("://"); + if (scheme_separator == std::string_view::npos || scheme_separator == 0) { + return Error{ErrorCode::InvalidUrl}; + } + + const std::string_view input_scheme = input.substr(0, scheme_separator); + if (!ascii_iequals(input_scheme, "http")) { + return Error{ErrorCode::UnsupportedScheme}; + } + + const std::size_t fragment = input.find('#'); + const std::string_view without_fragment = input.substr(0, fragment); + + Url result; + result.storage_.assign(without_fragment.data(), without_fragment.size()); + std::transform( + result.storage_.begin(), + result.storage_.begin() + static_cast(scheme_separator), + result.storage_.begin(), + [](unsigned char ch) { return static_cast(std::tolower(ch)); }); + + result.scheme_begin_ = 0; + result.scheme_size_ = scheme_separator; + + const std::size_t authority_begin = scheme_separator + 3; + if (authority_begin >= result.storage_.size()) { + return Error{ErrorCode::InvalidUrl}; + } + + std::size_t authority_end = result.storage_.find_first_of("/?", authority_begin); + if (authority_end == std::string::npos) { + authority_end = result.storage_.size(); + } + if (authority_end == authority_begin) { + return Error{ErrorCode::InvalidUrl}; + } + + const std::string_view authority{ + result.storage_.data() + authority_begin, + authority_end - authority_begin}; + + if (authority.find('@') != std::string_view::npos) { + return Error{ErrorCode::InvalidUrl}; + } + + if (authority.front() == '[') { + const std::size_t closing = authority.find(']'); + if (closing == std::string_view::npos || closing == 1) { + return Error{ErrorCode::InvalidUrl}; + } + + result.host_is_ipv6_literal_ = true; + result.host_begin_ = authority_begin + 1; + result.host_size_ = closing - 1; + + const std::string_view remainder = authority.substr(closing + 1); + if (!remainder.empty()) { + if (remainder.front() != ':') { + return Error{ErrorCode::InvalidUrl}; + } + auto port = parse_port(remainder.substr(1)); + if (!port) { + return port.error(); + } + result.port_ = port.value(); + result.has_explicit_port_ = true; + } + } else { + const std::size_t colon = authority.rfind(':'); + if (colon != std::string_view::npos) { + if (authority.find(':') != colon) { + return Error{ErrorCode::InvalidUrl}; + } + + const std::string_view host = authority.substr(0, colon); + if (host.empty()) { + return Error{ErrorCode::InvalidUrl}; + } + + auto port = parse_port(authority.substr(colon + 1)); + if (!port) { + return port.error(); + } + + result.host_begin_ = authority_begin; + result.host_size_ = host.size(); + result.port_ = port.value(); + result.has_explicit_port_ = true; + } else { + result.host_begin_ = authority_begin; + result.host_size_ = authority.size(); + } + } + + if (result.host_size_ == 0) { + return Error{ErrorCode::InvalidUrl}; + } + + if (authority_end == result.storage_.size()) { + result.storage_.push_back('/'); + } else if (result.storage_[authority_end] == '?') { + result.storage_.insert(authority_end, 1, '/'); + } + + const std::size_t path_begin = authority_end; + const std::size_t query_marker = result.storage_.find('?', path_begin); + + result.path_begin_ = path_begin; + if (query_marker == std::string::npos) { + result.path_size_ = result.storage_.size() - path_begin; + result.query_begin_ = result.storage_.size(); + result.query_size_ = 0; + } else { + result.path_size_ = query_marker - path_begin; + result.query_begin_ = query_marker + 1; + result.query_size_ = result.storage_.size() - result.query_begin_; + } + + result.target_begin_ = path_begin; + result.target_size_ = result.storage_.size() - path_begin; + + return result; +} + +std::string_view Url::scheme() const noexcept { + return {storage_.data() + scheme_begin_, scheme_size_}; +} + +std::string_view Url::host() const noexcept { + return {storage_.data() + host_begin_, host_size_}; +} + +std::string_view Url::path() const noexcept { + return {storage_.data() + path_begin_, path_size_}; +} + +std::string_view Url::query() const noexcept { + return {storage_.data() + query_begin_, query_size_}; +} + +std::string_view Url::target() const noexcept { + return {storage_.data() + target_begin_, target_size_}; +} + +} // namespace cpp_request diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index e69de29..f7b0790 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -0,0 +1,15 @@ +include(${CMAKE_SOURCE_DIR}/cmake/packages/google-test.cmake) + +add_executable(cpp_request_tests + ${CMAKE_CURRENT_SOURCE_DIR}/url_test.cpp +) + +target_link_libraries(cpp_request_tests + PRIVATE + cpp_request::internal + GTest::gtest_main +) + +target_compile_features(cpp_request_tests PRIVATE cxx_std_17) + +gtest_discover_tests(cpp_request_tests) diff --git a/tests/url_test.cpp b/tests/url_test.cpp new file mode 100644 index 0000000..3f0086a --- /dev/null +++ b/tests/url_test.cpp @@ -0,0 +1,121 @@ +#include + +#include + +using cpp_request::ErrorCode; +using cpp_request::Url; + +TEST(UrlTest, ParsesBasicHttpUrl) { + auto parsed = Url::parse("http://example.com/path/to?q=1"); + + ASSERT_TRUE(parsed); + const auto& url = parsed.value(); + EXPECT_EQ(url.scheme(), "http"); + EXPECT_EQ(url.host(), "example.com"); + EXPECT_EQ(url.port(), 80); + EXPECT_FALSE(url.has_explicit_port()); + EXPECT_EQ(url.path(), "/path/to"); + EXPECT_EQ(url.query(), "q=1"); + EXPECT_EQ(url.target(), "/path/to?q=1"); +} + +TEST(UrlTest, UsesSlashAsDefaultPath) { + auto parsed = Url::parse("http://example.com"); + + ASSERT_TRUE(parsed); + EXPECT_EQ(parsed.value().path(), "/"); + EXPECT_EQ(parsed.value().target(), "/"); +} + +TEST(UrlTest, AddsSlashBeforeQueryWhenPathIsMissing) { + auto parsed = Url::parse("http://example.com?x=1"); + + ASSERT_TRUE(parsed); + EXPECT_EQ(parsed.value().path(), "/"); + EXPECT_EQ(parsed.value().query(), "x=1"); + EXPECT_EQ(parsed.value().target(), "/?x=1"); +} + +TEST(UrlTest, ParsesExplicitPort) { + auto parsed = Url::parse("http://example.com:8080/api"); + + ASSERT_TRUE(parsed); + EXPECT_EQ(parsed.value().host(), "example.com"); + EXPECT_EQ(parsed.value().port(), 8080); + EXPECT_TRUE(parsed.value().has_explicit_port()); +} + +TEST(UrlTest, ParsesBracketedIpv6Literal) { + auto parsed = Url::parse("http://[2001:db8::1]:8080/a"); + + ASSERT_TRUE(parsed); + EXPECT_EQ(parsed.value().host(), "2001:db8::1"); + EXPECT_EQ(parsed.value().port(), 8080); + EXPECT_TRUE(parsed.value().has_explicit_port()); + EXPECT_TRUE(parsed.value().host_is_ipv6_literal()); + EXPECT_EQ(parsed.value().target(), "/a"); +} + +TEST(UrlTest, FragmentIsNotPartOfRequestTarget) { + auto parsed = Url::parse("http://example.com/a?x=1#section"); + + ASSERT_TRUE(parsed); + EXPECT_EQ(parsed.value().target(), "/a?x=1"); + EXPECT_EQ(parsed.value().query(), "x=1"); +} + +TEST(UrlTest, AcceptsSchemeCaseInsensitivelyAndNormalizesIt) { + auto parsed = Url::parse("HTTP://example.com/"); + + ASSERT_TRUE(parsed); + EXPECT_EQ(parsed.value().scheme(), "http"); +} + +TEST(UrlTest, RejectsHttpsAsUnsupportedScheme) { + auto parsed = Url::parse("https://example.com/"); + + ASSERT_FALSE(parsed); + EXPECT_EQ(parsed.error().code, ErrorCode::UnsupportedScheme); +} + +TEST(UrlTest, RejectsMissingSchemeSeparator) { + auto parsed = Url::parse("example.com/path"); + + ASSERT_FALSE(parsed); + EXPECT_EQ(parsed.error().code, ErrorCode::InvalidUrl); +} + +TEST(UrlTest, RejectsMissingHost) { + auto parsed = Url::parse("http:///path"); + + ASSERT_FALSE(parsed); + EXPECT_EQ(parsed.error().code, ErrorCode::InvalidUrl); +} + +TEST(UrlTest, RejectsUserInfoInV1) { + auto parsed = Url::parse("http://user@example.com/"); + + ASSERT_FALSE(parsed); + EXPECT_EQ(parsed.error().code, ErrorCode::InvalidUrl); +} + +TEST(UrlTest, RejectsInvalidPort) { + auto parsed = Url::parse("http://example.com:abc/"); + + ASSERT_FALSE(parsed); + EXPECT_EQ(parsed.error().code, ErrorCode::InvalidPort); +} + +TEST(UrlTest, RejectsOutOfRangePort) { + auto parsed = Url::parse("http://example.com:65536/"); + + ASSERT_FALSE(parsed); + EXPECT_EQ(parsed.error().code, ErrorCode::InvalidPort); +} + +TEST(UrlTest, RejectsUnbracketedIpv6Literal) { + auto parsed = Url::parse("http://2001:db8::1/"); + + ASSERT_FALSE(parsed); + EXPECT_EQ(parsed.error().code, ErrorCode::InvalidUrl); +} diff --git a/vcpkg.json b/vcpkg.json index d5d87c1..ddd812e 100644 --- a/vcpkg.json +++ b/vcpkg.json @@ -2,8 +2,9 @@ "name": "cpp-request", "version": "0.1.0", "description": "A lightweight, dependency-free HTTP client library written from scratch in C/C++ to understand low-level network communication and TCP socket programming.", + "builtin-baseline": "a1cae005c39be7b18ba319fced856b68d7276271", "dependencies": [ - "benchmark", - "gtest" - ] -} \ No newline at end of file + "benchmark", + "gtest" + ] +}