clawdforge/clients/cpp/src/http.hpp
Kayos bae34a7701 clients/cpp: initial C++ SDK for clawdforge
Modern C++20 SDK targeting CMake 3.20+. Library is RAII / move-only,
backed by a libcurl easy handle per Client. Public surface is throwing;
exception hierarchy under clawdforge::Error covers AuthError, APIError
(carries status_code + body), TransportError, and ProtocolError.

Dependencies: libcurl + nlohmann/json (FetchContent or find_package).
Tests use cpp-httplib's in-process server + doctest. 12 test cases /
70 assertions cover healthz, run with JSON / text / 502 / files,
multipart upload, full token CRUD, transport failure, URL normalization,
and bad-input rejection. Clean under -Wall -Wextra -Wpedantic -Werror,
ASan + UBSan clean (no leaks, no UB).

upload_file streams via curl_mime_filedata — no in-memory buffering.

Install path produces clawdforge::clawdforge target consumable via
target_link_libraries; FetchContent path mirrors the existing Rust /
Go SDK ergonomics. MIT licensed.
2026-04-28 23:02:51 -07:00

112 lines
4 KiB
C++

// SPDX-License-Identifier: MIT
//
// Tiny libcurl wrapper used internally by the SDK. Not part of the public
// surface — header lives under src/ on purpose.
#pragma once
#include <chrono>
#include <cstdint>
#include <map>
#include <optional>
#include <string>
#include <string_view>
#include <utility>
#include <vector>
#include <curl/curl.h>
namespace clawdforge::detail {
/// One header line on the wire, as `Name: value`. Stored as a map so writers
/// can replace by name without dupes.
using HeaderMap = std::map<std::string, std::string>;
/// Parameters for a single HTTP exchange.
struct Request {
std::string method; ///< "GET" / "POST" / "DELETE" / ...
std::string url; ///< Fully-qualified.
HeaderMap headers; ///< Caller adds Authorization / Content-Type / Accept.
std::optional<std::string> body; ///< Raw bytes for application/json POSTs.
/// Optional multipart filedata streamed off disk. When set, the SDK builds
/// a libcurl `curl_mime` and adds the regular form fields below as text
/// parts. `body` is ignored in that case.
struct MultipartFile {
std::string field_name; ///< form field name, e.g. "file"
std::string filename; ///< Content-Disposition filename
std::string content_type; ///< e.g. "application/octet-stream"
std::string filesystem_path; ///< absolute or cwd-relative path on disk
};
std::optional<MultipartFile> file;
std::vector<std::pair<std::string, std::string>> form_fields; ///< extra text parts
};
/// Result of an HTTP exchange.
struct Response {
long status{0};
std::string body;
HeaderMap headers;
};
/// Thin RAII wrapper around a `CURL*` easy handle plus the per-call mutables
/// (header list, mime, etc.).
class CurlSession {
public:
/// Throws `TransportError` if libcurl initialisation fails.
CurlSession(std::chrono::seconds timeout,
std::chrono::seconds connect_timeout,
std::string user_agent,
bool insecure_tls);
~CurlSession();
CurlSession(const CurlSession&) = delete;
CurlSession& operator=(const CurlSession&) = delete;
CurlSession(CurlSession&&) noexcept;
CurlSession& operator=(CurlSession&&) noexcept;
/// Perform a request, throwing `TransportError` on libcurl failure. The
/// returned `Response::status` may still be non-2xx — that's the caller's
/// problem to translate.
Response perform(const Request& req);
private:
CURL* easy_{nullptr};
std::chrono::seconds timeout_{};
std::chrono::seconds connect_timeout_{};
std::string user_agent_;
bool insecure_tls_{false};
};
/// Concatenate base + path with exactly one separating slash. Idempotent for
/// trailing slashes on the base.
[[nodiscard]] std::string join_url(std::string_view base, std::string_view path);
/// Percent-encode a path segment (e.g. token name in /admin/tokens/<name>).
[[nodiscard]] std::string url_encode_path(std::string_view in);
/// Truncate UTF-8-ish strings for error logs without slicing through the
/// middle of a multibyte sequence. Best-effort, not perfect.
[[nodiscard]] std::string truncate_for_log(std::string_view s, std::size_t max);
/// Library-wide one-time `curl_global_init`, ref-counted via a static counter.
/// Construct one of these in any TU that uses libcurl; the first one calls
/// `curl_global_init`, subsequent ones bump the refcount, and destruction
/// runs `curl_global_cleanup` once the last guard goes away.
class CurlGlobalGuard {
public:
CurlGlobalGuard();
~CurlGlobalGuard();
/// Copy ctor bumps the refcount — cheap and useful for `Client` move
/// semantics where Impl wants to own one as a value member.
CurlGlobalGuard(const CurlGlobalGuard&);
CurlGlobalGuard& operator=(const CurlGlobalGuard&);
CurlGlobalGuard(CurlGlobalGuard&&) noexcept;
CurlGlobalGuard& operator=(CurlGlobalGuard&&) noexcept;
private:
bool active_{true};
};
} // namespace clawdforge::detail