diff --git a/docs/api/public-api.md b/docs/api/public-api.md index f69eec7..7d40e00 100644 --- a/docs/api/public-api.md +++ b/docs/api/public-api.md @@ -120,7 +120,7 @@ The v1 method/body policy is: When a body is dropped, body-specific request headers such as `Content-Length`, `Transfer-Encoding`, and `Content-Type` are not forwarded to the redirected request. -Relative `Location` values are resolved against the current request URL, including absolute-path, relative-path, query-only, and scheme-relative forms. URL fragments are never sent in the HTTP request target. +Relative `Location` values are resolved against the current effective request URL, including query parameters appended through `Request::add_query_param()`, and including absolute-path, relative-path, query-only, fragment-only, and scheme-relative forms. URL fragments are never sent in the HTTP request target. Cross-origin redirects do not forward caller-supplied `Host`, `Authorization`, `Proxy-Authorization`, or `Cookie` fields. Once these fields are removed during a redirect chain they are not automatically restored if a later hop returns to the original origin. @@ -148,6 +148,11 @@ enum class Method { class Request { public: + struct QueryParam { + std::string name; + std::string value; + }; + Request(Method method, std::string_view url); Method method() const noexcept; @@ -158,17 +163,39 @@ public: void set_body(std::string_view body) noexcept; std::string_view body() const noexcept; + + void add_query_param(std::string_view name, std::string_view value); + const std::vector& query_params() const noexcept; }; } // namespace cpp_request ``` -### Borrowed request data +### Borrowed and owned request data + +The base URL and body may remain non-owning views. Their lifetime requirements are defined in `lifetime.md`. -`Request` may hold non-owning views for URL and body data. Their lifetime requirements are defined in `lifetime.md`. +Headers and query parameters added through the request object own their copied text independently from the caller's source buffers. The v1 design does not require request-body ownership or request streaming. +### Query parameter behavior + +`add_query_param(name, value)` appends one parameter in insertion order. Repeated names are preserved rather than deduplicated. + +For request serialization: + +- an existing raw query already present in the URL is preserved, +- appended parameters are added after that raw query, +- parameter names and values are percent-encoded byte-for-byte, +- URI unreserved bytes (`ALPHA`, `DIGIT`, `-`, `.`, `_`, `~`) remain unescaped, +- spaces are encoded as `%20`, not `+`, +- reserved characters such as `/`, `?`, `&`, and `=` are encoded inside appended names/values, +- UTF-8 input is percent-encoded by its UTF-8 bytes, +- repeated parameters preserve insertion order. + +The raw URL parser does not silently repair malformed percent escapes. A raw path or query containing `%` must contain a complete `%HH` escape using hexadecimal digits. + --- ## `Response` @@ -280,6 +307,8 @@ public: For v1, only `http://` is supported. `https://` must fail explicitly. +Raw path/query percent escapes must be syntactically complete `%HH` sequences. Bracketed hosts are accepted only when they contain a valid numeric IPv6 literal. IPv6 zone identifiers are not part of the v1 URL contract. + Implementation may own normalized URL storage internally when necessary; callers must not depend on the exact storage representation. --- diff --git a/include/cpp_request/request.hpp b/include/cpp_request/request.hpp index f0c9841..0aa4fe0 100644 --- a/include/cpp_request/request.hpp +++ b/include/cpp_request/request.hpp @@ -1,6 +1,9 @@ #pragma once +#include #include +#include +#include #include @@ -17,6 +20,11 @@ enum class Method { class Request { public: + struct QueryParam final { + std::string name; + std::string value; + }; + Request(Method method, std::string_view url) noexcept : method_(method), url_(url) {} @@ -29,11 +37,21 @@ class Request { void set_body(std::string_view body) noexcept { body_ = body; } [[nodiscard]] std::string_view body() const noexcept { return body_; } + void add_query_param(std::string_view name, std::string_view value) { + QueryParam param{std::string{name}, std::string{value}}; + query_params_.push_back(std::move(param)); + } + + [[nodiscard]] const std::vector& query_params() const noexcept { + return query_params_; + } + private: Method method_; std::string_view url_; Headers headers_; std::string_view body_; + std::vector query_params_; }; } // namespace cpp_request diff --git a/src/core/client.cpp b/src/core/client.cpp index 94e3289..047991b 100644 --- a/src/core/client.cpp +++ b/src/core/client.cpp @@ -217,7 +217,7 @@ Result Client::request(const Request& request_value) { return response; } - std::string current_url{request_value.url()}; + std::string current_url = detail::http::effective_request_url(request_value); Method current_method = request_value.method(); Headers current_headers = request_value.headers(); std::string_view current_body = request_value.body(); diff --git a/src/core/url.cpp b/src/core/url.cpp index 8eae0b9..1d4d6d2 100644 --- a/src/core/url.cpp +++ b/src/core/url.cpp @@ -1,22 +1,37 @@ #include #include +#include #include -#include +#include #include +#ifdef _WIN32 +#ifndef NOMINMAX +#define NOMINMAX +#endif +#include +#include +#else +#include +#endif + namespace cpp_request { namespace { +[[nodiscard]] constexpr char ascii_lower(char ch) noexcept { + return ch >= 'A' && ch <= 'Z' + ? static_cast(ch + ('a' - 'A')) + : ch; +} + [[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)) { + if (ascii_lower(lhs[i]) != ascii_lower(rhs[i])) { return false; } } @@ -32,6 +47,44 @@ namespace { return false; } +[[nodiscard]] constexpr bool is_hex_digit(char ch) noexcept { + return (ch >= '0' && ch <= '9') + || (ch >= 'A' && ch <= 'F') + || (ch >= 'a' && ch <= 'f'); +} + +[[nodiscard]] bool valid_percent_escapes(std::string_view text) noexcept { + for (std::size_t index = 0; index < text.size(); ++index) { + if (text[index] != '%') { + continue; + } + + if (index + 2 >= text.size() + || !is_hex_digit(text[index + 1]) + || !is_hex_digit(text[index + 2])) { + return false; + } + index += 2; + } + return true; +} + +[[nodiscard]] bool valid_ipv6_literal(std::string_view text) noexcept { + if (text.empty() || text.size() >= 64 || text.find('%') != std::string_view::npos) { + return false; + } + + std::array buffer{}; + std::copy(text.begin(), text.end(), buffer.begin()); + std::array address{}; + +#ifdef _WIN32 + return ::InetPtonA(AF_INET6, buffer.data(), address.data()) == 1; +#else + return ::inet_pton(AF_INET6, buffer.data(), address.data()) == 1; +#endif +} + [[nodiscard]] Result parse_port(std::string_view text) noexcept { if (text.empty()) { return Error{ErrorCode::InvalidPort}; @@ -76,7 +129,7 @@ Result Url::parse(std::string_view input) { result.storage_.begin(), result.storage_.begin() + static_cast(scheme_separator), result.storage_.begin(), - [](unsigned char ch) { return static_cast(std::tolower(ch)); }); + [](char ch) { return ascii_lower(ch); }); result.scheme_begin_ = 0; result.scheme_size_ = scheme_separator; @@ -98,7 +151,8 @@ Result Url::parse(std::string_view input) { result.storage_.data() + authority_begin, authority_end - authority_begin}; - if (authority.find('@') != std::string_view::npos) { + if (authority.find('@') != std::string_view::npos + || authority.find('%') != std::string_view::npos) { return Error{ErrorCode::InvalidUrl}; } @@ -108,6 +162,11 @@ Result Url::parse(std::string_view input) { return Error{ErrorCode::InvalidUrl}; } + const std::string_view literal = authority.substr(1, closing - 1); + if (!valid_ipv6_literal(literal)) { + return Error{ErrorCode::InvalidUrl}; + } + result.host_is_ipv6_literal_ = true; result.host_begin_ = authority_begin + 1; result.host_size_ = closing - 1; @@ -175,6 +234,12 @@ Result Url::parse(std::string_view input) { result.query_size_ = result.storage_.size() - result.query_begin_; } + if (!valid_percent_escapes(std::string_view{ + result.storage_.data() + path_begin, + result.storage_.size() - path_begin})) { + return Error{ErrorCode::InvalidUrl}; + } + result.target_begin_ = path_begin; result.target_size_ = result.storage_.size() - path_begin; diff --git a/src/http/request_serializer.cpp b/src/http/request_serializer.cpp index 954390b..28c9734 100644 --- a/src/http/request_serializer.cpp +++ b/src/http/request_serializer.cpp @@ -5,6 +5,7 @@ #include #include #include +#include namespace cpp_request::detail::http { namespace { @@ -71,6 +72,54 @@ namespace { } } +[[nodiscard]] constexpr bool is_unreserved(unsigned char ch) noexcept { + return (ch >= '0' && ch <= '9') + || (ch >= 'A' && ch <= 'Z') + || (ch >= 'a' && ch <= 'z') + || ch == '-' + || ch == '.' + || ch == '_' + || ch == '~'; +} + +void append_percent_encoded(std::string& output, std::string_view input) { + constexpr char kHex[] = "0123456789ABCDEF"; + for (const unsigned char ch : input) { + if (is_unreserved(ch)) { + output.push_back(static_cast(ch)); + continue; + } + + output.push_back('%'); + output.push_back(kHex[(ch >> 4) & 0x0f]); + output.push_back(kHex[ch & 0x0f]); + } +} + +void append_query_params( + std::string& output, + const std::vector& params) { + if (params.empty()) { + return; + } + + const bool has_query = output.find('?') != std::string::npos; + if (!has_query) { + output.push_back('?'); + } else if (!output.empty() && output.back() != '?' && output.back() != '&') { + output.push_back('&'); + } + + for (std::size_t index = 0; index < params.size(); ++index) { + if (index != 0) { + output.push_back('&'); + } + append_percent_encoded(output, params[index].name); + output.push_back('='); + append_percent_encoded(output, params[index].value); + } +} + [[nodiscard]] bool valid_header_name(std::string_view name) noexcept { if (name.empty()) { return false; @@ -146,8 +195,28 @@ void append_generated_host(std::string& output, const Url& url) { output.append("\r\n"); } +[[nodiscard]] std::size_t encoded_query_reserve( + const std::vector& params) noexcept { + std::size_t result = 0; + for (const auto& param : params) { + result += (param.name.size() + param.value.size()) * 3 + 2; + } + return result; +} + } // namespace +std::string effective_request_url(const Request& request) { + const std::string_view raw = request.url(); + const std::size_t fragment = raw.find('#'); + const std::string_view without_fragment = raw.substr(0, fragment); + + std::string result{without_fragment}; + result.reserve(result.size() + encoded_query_reserve(request.query_params())); + append_query_params(result, request.query_params()); + return result; +} + Result serialize_request(const Request& request) { auto parsed_url = Url::parse(request.url()); if (!parsed_url) { @@ -198,9 +267,14 @@ Result serialize_request(const Request& request) { serialized.url = std::move(parsed_url).value(); serialized.body = request.body(); + std::string request_target{serialized.url.target()}; + request_target.reserve( + request_target.size() + encoded_query_reserve(request.query_params())); + append_query_params(request_target, request.query_params()); + std::size_t reserve_size = method.size() + 1 - + serialized.url.target().size() + + request_target.size() + sizeof(" HTTP/1.1\r\n") - 1 + request.headers().size() * 16 + 64; @@ -211,9 +285,7 @@ Result serialize_request(const Request& request) { serialized.head.append(method.data(), method.size()); serialized.head.push_back(' '); - serialized.head.append( - serialized.url.target().data(), - serialized.url.target().size()); + serialized.head.append(request_target); serialized.head.append(" HTTP/1.1\r\n"); if (host_count == 0) { diff --git a/src/http/request_serializer.hpp b/src/http/request_serializer.hpp index 24eee57..b1f288c 100644 --- a/src/http/request_serializer.hpp +++ b/src/http/request_serializer.hpp @@ -16,5 +16,6 @@ struct SerializedRequest final { }; [[nodiscard]] Result serialize_request(const Request& request); +[[nodiscard]] std::string effective_request_url(const Request& request); } // namespace cpp_request::detail::http diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index 306bad8..3b5d584 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -7,6 +7,7 @@ add_executable(cpp_request_tests ${CMAKE_CURRENT_SOURCE_DIR}/headers_test.cpp ${CMAKE_CURRENT_SOURCE_DIR}/request_test.cpp ${CMAKE_CURRENT_SOURCE_DIR}/request_serializer_test.cpp + ${CMAKE_CURRENT_SOURCE_DIR}/query_test.cpp ${CMAKE_CURRENT_SOURCE_DIR}/redirect_test.cpp ${CMAKE_CURRENT_SOURCE_DIR}/chunked_decoder_test.cpp ${CMAKE_CURRENT_SOURCE_DIR}/response_parser_test.cpp diff --git a/tests/query_test.cpp b/tests/query_test.cpp new file mode 100644 index 0000000..7c6fd4f --- /dev/null +++ b/tests/query_test.cpp @@ -0,0 +1,91 @@ +#include + +#include "http/request_serializer.hpp" + +#include + +#include + +namespace { + +using cpp_request::Method; +using cpp_request::Request; +using cpp_request::detail::http::effective_request_url; +using cpp_request::detail::http::serialize_request; + +TEST(QueryTest, PercentEncodesAppendedParameters) { + Request request{Method::Get, "http://example.com/search"}; + request.add_query_param("q", "hello world"); + request.add_query_param("reserved", "a/b?c&d=e"); + request.add_query_param("safe", "AZaz09-._~"); + + auto result = serialize_request(request); + + ASSERT_TRUE(result); + EXPECT_NE( + result.value().head.find( + "GET /search?q=hello%20world&reserved=a%2Fb%3Fc%26d%3De&safe=AZaz09-._~ HTTP/1.1\r\n"), + std::string::npos); +} + +TEST(QueryTest, AppendsAfterExistingRawQuery) { + Request request{Method::Get, "http://example.com/search?lang=en"}; + request.add_query_param("q", "cpp http"); + + auto result = serialize_request(request); + + ASSERT_TRUE(result); + EXPECT_NE( + result.value().head.find( + "GET /search?lang=en&q=cpp%20http HTTP/1.1\r\n"), + std::string::npos); +} + +TEST(QueryTest, HandlesExistingEmptyQueryMarker) { + Request request{Method::Get, "http://example.com/search?"}; + request.add_query_param("q", "value"); + + auto result = serialize_request(request); + + ASSERT_TRUE(result); + EXPECT_NE( + result.value().head.find("GET /search?q=value HTTP/1.1\r\n"), + std::string::npos); +} + +TEST(QueryTest, PreservesRepeatedParametersInInsertionOrder) { + Request request{Method::Get, "http://example.com/items"}; + request.add_query_param("tag", "cpp"); + request.add_query_param("tag", "http"); + + auto result = serialize_request(request); + + ASSERT_TRUE(result); + EXPECT_NE( + result.value().head.find("GET /items?tag=cpp&tag=http HTTP/1.1\r\n"), + std::string::npos); +} + +TEST(QueryTest, PercentEncodesUtf8Bytes) { + Request request{Method::Get, "http://example.com/search"}; + const std::string value{"caf\xC3\xA9"}; + request.add_query_param("q", value); + + auto result = serialize_request(request); + + ASSERT_TRUE(result); + EXPECT_NE( + result.value().head.find("GET /search?q=caf%C3%A9 HTTP/1.1\r\n"), + std::string::npos); +} + +TEST(QueryTest, EffectiveUrlIncludesParametersAndDropsFragment) { + Request request{Method::Get, "http://example.com/search?raw=1#section"}; + request.add_query_param("q", "hello world"); + + EXPECT_EQ( + effective_request_url(request), + "http://example.com/search?raw=1&q=hello%20world"); +} + +} // namespace diff --git a/tests/request_test.cpp b/tests/request_test.cpp index d3cd08f..9aa28c7 100644 --- a/tests/request_test.cpp +++ b/tests/request_test.cpp @@ -41,10 +41,38 @@ TEST(RequestTest, OwnsHeaderStorageIndependentlyFromBorrowedRequestData) { EXPECT_EQ(request.headers().get("content-type"), "application/json"); } +TEST(RequestTest, OwnsQueryParameterStorage) { + std::string name = "search"; + std::string value = "hello world"; + + Request request{Method::Get, "http://example.com/items"}; + request.add_query_param(name, value); + + name.assign("changed"); + value.assign("changed"); + + ASSERT_EQ(request.query_params().size(), 1u); + EXPECT_EQ(request.query_params()[0].name, "search"); + EXPECT_EQ(request.query_params()[0].value, "hello world"); +} + +TEST(RequestTest, PreservesRepeatedQueryParametersInInsertionOrder) { + Request request{Method::Get, "http://example.com/items"}; + request.add_query_param("tag", "cpp"); + request.add_query_param("tag", "http"); + + ASSERT_EQ(request.query_params().size(), 2u); + EXPECT_EQ(request.query_params()[0].name, "tag"); + EXPECT_EQ(request.query_params()[0].value, "cpp"); + EXPECT_EQ(request.query_params()[1].name, "tag"); + EXPECT_EQ(request.query_params()[1].value, "http"); +} + 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"); + mutable_request.add_query_param("mode", "fast"); const Request& request = mutable_request; @@ -52,6 +80,9 @@ TEST(RequestTest, ConstAccessorsExposeRequestState) { EXPECT_EQ(request.url(), "http://example.com/item/1"); EXPECT_EQ(request.body(), "patch-body"); EXPECT_EQ(request.headers().get("x-test"), "value"); + ASSERT_EQ(request.query_params().size(), 1u); + EXPECT_EQ(request.query_params()[0].name, "mode"); + EXPECT_EQ(request.query_params()[0].value, "fast"); } TEST(RequestTest, SupportsAllV1Methods) { diff --git a/tests/url_test.cpp b/tests/url_test.cpp index 3f0086a..700019d 100644 --- a/tests/url_test.cpp +++ b/tests/url_test.cpp @@ -56,6 +56,23 @@ TEST(UrlTest, ParsesBracketedIpv6Literal) { EXPECT_EQ(parsed.value().target(), "/a"); } +TEST(UrlTest, ParsesIpv6LiteralWithEmbeddedIpv4) { + auto parsed = Url::parse("http://[::ffff:192.0.2.128]/a"); + + ASSERT_TRUE(parsed); + EXPECT_EQ(parsed.value().host(), "::ffff:192.0.2.128"); + EXPECT_TRUE(parsed.value().host_is_ipv6_literal()); +} + +TEST(UrlTest, PreservesValidPercentEscapes) { + auto parsed = Url::parse("http://example.com/a%20b?q=x%2Fy"); + + ASSERT_TRUE(parsed); + EXPECT_EQ(parsed.value().path(), "/a%20b"); + EXPECT_EQ(parsed.value().query(), "q=x%2Fy"); + EXPECT_EQ(parsed.value().target(), "/a%20b?q=x%2Fy"); +} + TEST(UrlTest, FragmentIsNotPartOfRequestTarget) { auto parsed = Url::parse("http://example.com/a?x=1#section"); @@ -119,3 +136,45 @@ TEST(UrlTest, RejectsUnbracketedIpv6Literal) { ASSERT_FALSE(parsed); EXPECT_EQ(parsed.error().code, ErrorCode::InvalidUrl); } + +TEST(UrlTest, RejectsBracketedNonIpv6Host) { + auto parsed = Url::parse("http://[not-an-ipv6]/"); + + ASSERT_FALSE(parsed); + EXPECT_EQ(parsed.error().code, ErrorCode::InvalidUrl); +} + +TEST(UrlTest, RejectsMalformedBracketedIpv6Literal) { + auto parsed = Url::parse("http://[2001:::1]/"); + + ASSERT_FALSE(parsed); + EXPECT_EQ(parsed.error().code, ErrorCode::InvalidUrl); +} + +TEST(UrlTest, RejectsIpv6ZoneIdentifierInV1) { + auto parsed = Url::parse("http://[fe80::1%25eth0]/"); + + ASSERT_FALSE(parsed); + EXPECT_EQ(parsed.error().code, ErrorCode::InvalidUrl); +} + +TEST(UrlTest, RejectsTruncatedPercentEscapeInPath) { + auto parsed = Url::parse("http://example.com/a%2"); + + ASSERT_FALSE(parsed); + EXPECT_EQ(parsed.error().code, ErrorCode::InvalidUrl); +} + +TEST(UrlTest, RejectsNonHexPercentEscapeInPath) { + auto parsed = Url::parse("http://example.com/a%XZ"); + + ASSERT_FALSE(parsed); + EXPECT_EQ(parsed.error().code, ErrorCode::InvalidUrl); +} + +TEST(UrlTest, RejectsMalformedPercentEscapeInQuery) { + auto parsed = Url::parse("http://example.com/a?q=%GG"); + + ASSERT_FALSE(parsed); + EXPECT_EQ(parsed.error().code, ErrorCode::InvalidUrl); +}