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.
112 lines
4 KiB
C++
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
|