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
35 changes: 32 additions & 3 deletions docs/api/public-api.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down Expand Up @@ -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;
Expand All @@ -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<QueryParam>& 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`
Expand Down Expand Up @@ -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.

---
Expand Down
18 changes: 18 additions & 0 deletions include/cpp_request/request.hpp
Original file line number Diff line number Diff line change
@@ -1,6 +1,9 @@
#pragma once

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

#include <cpp_request/headers.hpp>

Expand All @@ -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) {}

Expand All @@ -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<QueryParam>& query_params() const noexcept {
return query_params_;
}

private:
Method method_;
std::string_view url_;
Headers headers_;
std::string_view body_;
std::vector<QueryParam> query_params_;
};

} // namespace cpp_request
2 changes: 1 addition & 1 deletion src/core/client.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -217,7 +217,7 @@ Result<Response> 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();
Expand Down
77 changes: 71 additions & 6 deletions src/core/url.cpp
Original file line number Diff line number Diff line change
@@ -1,22 +1,37 @@
#include <cpp_request/url.hpp>

#include <algorithm>
#include <array>
#include <charconv>
#include <cctype>
#include <cstddef>
#include <limits>

#ifdef _WIN32
#ifndef NOMINMAX
#define NOMINMAX
#endif
#include <winsock2.h>
#include <ws2tcpip.h>
#else
#include <arpa/inet.h>
#endif

namespace cpp_request {
namespace {

[[nodiscard]] constexpr char ascii_lower(char ch) noexcept {
return ch >= 'A' && ch <= 'Z'
? static_cast<char>(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<unsigned char>(lhs[i]);
const auto b = static_cast<unsigned char>(rhs[i]);
if (std::tolower(a) != std::tolower(b)) {
if (ascii_lower(lhs[i]) != ascii_lower(rhs[i])) {
return false;
}
}
Expand All @@ -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<char, 64> buffer{};
std::copy(text.begin(), text.end(), buffer.begin());
std::array<unsigned char, 16> 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<std::uint16_t> parse_port(std::string_view text) noexcept {
if (text.empty()) {
return Error{ErrorCode::InvalidPort};
Expand Down Expand Up @@ -76,7 +129,7 @@ Result<Url> Url::parse(std::string_view input) {
result.storage_.begin(),
result.storage_.begin() + static_cast<std::ptrdiff_t>(scheme_separator),
result.storage_.begin(),
[](unsigned char ch) { return static_cast<char>(std::tolower(ch)); });
[](char ch) { return ascii_lower(ch); });

result.scheme_begin_ = 0;
result.scheme_size_ = scheme_separator;
Expand All @@ -98,7 +151,8 @@ Result<Url> 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};
}

Expand All @@ -108,6 +162,11 @@ Result<Url> 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;
Expand Down Expand Up @@ -175,6 +234,12 @@ Result<Url> 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;

Expand Down
80 changes: 76 additions & 4 deletions src/http/request_serializer.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
#include <string>
#include <string_view>
#include <utility>
#include <vector>

namespace cpp_request::detail::http {
namespace {
Expand Down Expand Up @@ -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<char>(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<Request::QueryParam>& 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;
Expand Down Expand Up @@ -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<Request::QueryParam>& 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<SerializedRequest> serialize_request(const Request& request) {
auto parsed_url = Url::parse(request.url());
if (!parsed_url) {
Expand Down Expand Up @@ -198,9 +267,14 @@ Result<SerializedRequest> 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;
Expand All @@ -211,9 +285,7 @@ Result<SerializedRequest> 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) {
Expand Down
1 change: 1 addition & 0 deletions src/http/request_serializer.hpp
Original file line number Diff line number Diff line change
Expand Up @@ -16,5 +16,6 @@ struct SerializedRequest final {
};

[[nodiscard]] Result<SerializedRequest> serialize_request(const Request& request);
[[nodiscard]] std::string effective_request_url(const Request& request);

} // namespace cpp_request::detail::http
1 change: 1 addition & 0 deletions tests/CMakeLists.txt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading
Loading