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.
This commit is contained in:
Kayos 2026-04-28 23:02:37 -07:00
parent a69e924592
commit bae34a7701
12 changed files with 1866 additions and 0 deletions

13
clients/cpp/.gitignore vendored Normal file
View file

@ -0,0 +1,13 @@
build/
build-*/
out/
.cache/
compile_commands.json
*.o
*.a
*.so
*.dylib
CMakeFiles/
CMakeCache.txt
cmake_install.cmake
Makefile

204
clients/cpp/CMakeLists.txt Normal file
View file

@ -0,0 +1,204 @@
# SPDX-License-Identifier: MIT
cmake_minimum_required(VERSION 3.20)
project(clawdforge
VERSION 0.1.0
DESCRIPTION "C++ SDK for the clawdforge HTTP API"
LANGUAGES CXX
)
# ---- options ----------------------------------------------------------------
option(CLAWDFORGE_BUILD_TESTS "Build unit tests" ON)
option(CLAWDFORGE_BUILD_EXAMPLES "Build examples" ON)
option(CLAWDFORGE_WARNINGS_AS_ERRORS "Treat warnings as errors" ON)
# When consumed via add_subdirectory, default the extras off unless the user
# explicitly opts in. Top-level builds (the SDK's own CI / dev loop) keep them
# on so we exercise them.
if(NOT CMAKE_PROJECT_NAME STREQUAL PROJECT_NAME)
set(CLAWDFORGE_BUILD_TESTS OFF)
set(CLAWDFORGE_BUILD_EXAMPLES OFF)
endif()
# ---- standard / flags -------------------------------------------------------
set(CMAKE_CXX_STANDARD 20)
set(CMAKE_CXX_STANDARD_REQUIRED ON)
set(CMAKE_CXX_EXTENSIONS OFF)
if(NOT CMAKE_BUILD_TYPE AND NOT CMAKE_CONFIGURATION_TYPES)
set(CMAKE_BUILD_TYPE Release CACHE STRING "" FORCE)
endif()
include(CMakePackageConfigHelpers)
include(GNUInstallDirs)
include(FetchContent)
# ---- dependencies -----------------------------------------------------------
# nlohmann/json — try find_package first, fall back to FetchContent.
find_package(nlohmann_json 3.10 QUIET)
if(NOT nlohmann_json_FOUND)
message(STATUS "clawdforge: nlohmann_json not installed, fetching v3.11.3")
FetchContent_Declare(nlohmann_json
GIT_REPOSITORY https://github.com/nlohmann/json.git
GIT_TAG v3.11.3
GIT_SHALLOW TRUE
)
set(JSON_BuildTests OFF CACHE INTERNAL "")
set(JSON_Install ON CACHE INTERNAL "")
FetchContent_MakeAvailable(nlohmann_json)
endif()
# libcurl — system-installed.
find_package(CURL REQUIRED)
# ---- library target ---------------------------------------------------------
add_library(clawdforge)
add_library(clawdforge::clawdforge ALIAS clawdforge)
target_sources(clawdforge
PRIVATE
src/client.cpp
src/http.cpp
)
target_include_directories(clawdforge
PUBLIC
$<BUILD_INTERFACE:${CMAKE_CURRENT_SOURCE_DIR}/include>
$<INSTALL_INTERFACE:${CMAKE_INSTALL_INCLUDEDIR}>
PRIVATE
${CMAKE_CURRENT_SOURCE_DIR}/src
)
target_link_libraries(clawdforge
PUBLIC
nlohmann_json::nlohmann_json
PRIVATE
CURL::libcurl
)
target_compile_features(clawdforge PUBLIC cxx_std_20)
set_target_properties(clawdforge PROPERTIES
CXX_VISIBILITY_PRESET hidden
VISIBILITY_INLINES_HIDDEN ON
POSITION_INDEPENDENT_CODE ON
VERSION ${PROJECT_VERSION}
SOVERSION ${PROJECT_VERSION_MAJOR}
EXPORT_NAME clawdforge
)
# Warnings -- private so consumers don't inherit our flags.
if(CMAKE_CXX_COMPILER_ID MATCHES "GNU|Clang")
target_compile_options(clawdforge PRIVATE
-Wall -Wextra -Wpedantic
-Wshadow -Wconversion -Wsign-conversion
-Wnon-virtual-dtor -Wold-style-cast
)
if(CLAWDFORGE_WARNINGS_AS_ERRORS)
target_compile_options(clawdforge PRIVATE -Werror)
endif()
elseif(MSVC)
target_compile_options(clawdforge PRIVATE /W4 /permissive-)
if(CLAWDFORGE_WARNINGS_AS_ERRORS)
target_compile_options(clawdforge PRIVATE /WX)
endif()
endif()
# ---- install ----------------------------------------------------------------
install(DIRECTORY include/clawdforge
DESTINATION ${CMAKE_INSTALL_INCLUDEDIR}
FILES_MATCHING PATTERN "*.hpp"
)
install(TARGETS clawdforge
EXPORT clawdforge-targets
LIBRARY DESTINATION ${CMAKE_INSTALL_LIBDIR}
ARCHIVE DESTINATION ${CMAKE_INSTALL_LIBDIR}
RUNTIME DESTINATION ${CMAKE_INSTALL_BINDIR}
)
install(EXPORT clawdforge-targets
FILE clawdforge-targets.cmake
NAMESPACE clawdforge::
DESTINATION ${CMAKE_INSTALL_LIBDIR}/cmake/clawdforge
)
configure_package_config_file(
cmake/clawdforge-config.cmake.in
"${CMAKE_CURRENT_BINARY_DIR}/clawdforge-config.cmake"
INSTALL_DESTINATION ${CMAKE_INSTALL_LIBDIR}/cmake/clawdforge
)
write_basic_package_version_file(
"${CMAKE_CURRENT_BINARY_DIR}/clawdforge-config-version.cmake"
VERSION ${PROJECT_VERSION}
COMPATIBILITY SameMajorVersion
)
install(FILES
"${CMAKE_CURRENT_BINARY_DIR}/clawdforge-config.cmake"
"${CMAKE_CURRENT_BINARY_DIR}/clawdforge-config-version.cmake"
DESTINATION ${CMAKE_INSTALL_LIBDIR}/cmake/clawdforge
)
# ---- examples ---------------------------------------------------------------
if(CLAWDFORGE_BUILD_EXAMPLES)
add_executable(clawdforge_basic_example examples/basic.cpp)
target_link_libraries(clawdforge_basic_example PRIVATE clawdforge::clawdforge)
endif()
# ---- tests ------------------------------------------------------------------
if(CLAWDFORGE_BUILD_TESTS)
enable_testing()
# cpp-httplib for an in-process mock server. Prefer system / installed copy.
find_package(httplib QUIET CONFIG)
if(NOT httplib_FOUND)
message(STATUS "clawdforge: cpp-httplib not installed, fetching v0.15.3")
FetchContent_Declare(cpp_httplib
GIT_REPOSITORY https://github.com/yhirose/cpp-httplib.git
GIT_TAG v0.15.3
GIT_SHALLOW TRUE
)
FetchContent_MakeAvailable(cpp_httplib)
endif()
# doctest — single-header, fastest to compile of the popular options.
find_package(doctest QUIET CONFIG)
if(NOT doctest_FOUND)
message(STATUS "clawdforge: doctest not installed, fetching v2.4.11")
FetchContent_Declare(doctest
GIT_REPOSITORY https://github.com/doctest/doctest.git
GIT_TAG v2.4.11
GIT_SHALLOW TRUE
)
FetchContent_MakeAvailable(doctest)
endif()
add_executable(clawdforge_tests tests/test_client.cpp)
target_link_libraries(clawdforge_tests
PRIVATE
clawdforge::clawdforge
httplib::httplib
doctest::doctest
)
if(CMAKE_CXX_COMPILER_ID MATCHES "GNU|Clang")
target_compile_options(clawdforge_tests PRIVATE
-Wall -Wextra
# Designated initializers in C++20 don't need to set every member.
-Wno-missing-field-initializers
# CHECK_THROWS_AS expands to a discarded-value expression; the
# `[[nodiscard]]` on Client methods makes that legitimately noisy.
-Wno-unused-result
)
endif()
add_test(NAME clawdforge_tests COMMAND clawdforge_tests)
endif()

160
clients/cpp/README.md Normal file
View file

@ -0,0 +1,160 @@
# clawdforge — C++ SDK
Modern C++ client for the [clawdforge](https://github.com/Sulkta-Coop/clawdforge)
HTTP API. Wraps `claude -p` subprocess calls behind a bearer-token-gated REST
service.
- C++17 minimum (C++20 lets you use designated initializers in examples).
- libcurl + nlohmann/json. No Boost.
- RAII, move-only `Client` (one libcurl handle per instance).
- Throwing API; full exception hierarchy under `clawdforge::Error`.
## Install
### Option A — FetchContent (drop-in for existing CMake projects)
```cmake
include(FetchContent)
FetchContent_Declare(clawdforge
GIT_REPOSITORY https://github.com/Sulkta-Coop/clawdforge.git
GIT_TAG main
SOURCE_SUBDIR clients/cpp
)
FetchContent_MakeAvailable(clawdforge)
target_link_libraries(my_app PRIVATE clawdforge::clawdforge)
```
`CURL` must be available via `find_package(CURL REQUIRED)`. On Debian/Ubuntu:
`sudo apt install libcurl4-openssl-dev`.
### Option B — install + find_package
```bash
git clone https://github.com/Sulkta-Coop/clawdforge.git
cd clawdforge/clients/cpp
cmake -S . -B build -DCMAKE_BUILD_TYPE=Release
cmake --build build -j
sudo cmake --install build
```
Then in your project:
```cmake
find_package(clawdforge CONFIG REQUIRED)
target_link_libraries(my_app PRIVATE clawdforge::clawdforge)
```
## Quickstart
```cpp
#include <clawdforge/client.hpp>
#include <iostream>
namespace cf = clawdforge;
int main() {
cf::Client client{cf::ClientOptions{
.base_url = "http://localhost:8800",
.token = "cf_...",
}};
auto h = client.healthz();
std::cout << "claude_version=" << h.claude_version << "\n";
auto r = client.run(cf::RunRequest{
.prompt = R"(Reply with JSON: {"hello":"world"})",
.model = "sonnet",
.timeout_secs = 60,
});
if (auto* j = std::get_if<nlohmann::json>(&r.result)) {
std::cout << j->at("hello").get<std::string>() << "\n";
} else if (auto* s = std::get_if<std::string>(&r.result)) {
std::cout << *s << "\n";
}
}
```
## API
`clawdforge::Client` (in `<clawdforge/client.hpp>`):
| Method | HTTP | Returns |
|---|---|---|
| `healthz()` | `GET /healthz` | `HealthzResponse` |
| `run(req)` | `POST /run` | `RunResult` |
| `upload_file(path, ttl_secs=0)` | `POST /files` | `FileToken` |
| `create_token(req)` | `POST /admin/tokens` | `AppToken` |
| `list_tokens()` | `GET /admin/tokens` | `std::vector<AppTokenInfo>` |
| `revoke_token(name)` | `DELETE /admin/tokens/<name>` | `void` |
All methods throw on failure. `ClientOptions` lets you set `base_url`, `token`,
`admin_token`, `timeout`, `connect_timeout`, `user_agent`, and `insecure_tls`.
## Errors
```text
clawdforge::Error // abstract base, derives from std::runtime_error
├── clawdforge::AuthError // 401 / 403, or missing token on the client
├── clawdforge::APIError // any other non-2xx — has status_code() + body()
├── clawdforge::TransportError // libcurl-level failures
└── clawdforge::ProtocolError // bad URL, missing fields, malformed JSON
```
Catch broadly:
```cpp
try {
client.run(req);
} catch (const cf::AuthError& e) { /* refresh / abort */ }
catch (const cf::APIError& e) { std::cerr << e.status_code() << " " << e.body(); }
catch (const cf::TransportError& e) { /* retry */ }
catch (const cf::Error& e) { /* anything else */ }
```
`POST /run` returns HTTP 502 on subprocess failure. Surfaced as `APIError`
with `status_code() == 502`; `body()` parses as the documented `RunFailure`
shape.
## File uploads
```cpp
auto ft = client.upload_file("/path/to/recipe.png", /*ttl_secs=*/3600);
auto res = client.run(cf::RunRequest{
.prompt = "extract recipe data",
.files = {ft.file_token},
});
```
The file is streamed off disk via libcurl's `curl_mime_filedata` — no
in-memory buffering of the payload.
## Threading
`Client` is move-only and **not** thread-safe. Construct one per worker thread
or wrap external accesses in a mutex. Multiple `Client` instances share the
process-wide libcurl global state safely (refcounted internally).
## Build options
| Option | Default | Effect |
|---|---|---|
| `CLAWDFORGE_BUILD_TESTS` | `ON` (top-level), `OFF` (subdirectory) | doctest-based suite |
| `CLAWDFORGE_BUILD_EXAMPLES` | `ON` (top-level), `OFF` (subdirectory) | builds `clawdforge_basic_example` |
| `CLAWDFORGE_WARNINGS_AS_ERRORS` | `ON` | `-Werror` / `/WX` on the library target |
## Development
```bash
cmake -S . -B build -DCMAKE_BUILD_TYPE=Release
cmake --build build -j
ctest --test-dir build --output-on-failure
```
The test suite spins up a `cpp-httplib` mock server in-process — no real
clawdforge needed.
## License
MIT. See `LICENSE` at the repo root.

View file

@ -0,0 +1,9 @@
@PACKAGE_INIT@
include(CMakeFindDependencyMacro)
find_dependency(CURL)
find_dependency(nlohmann_json 3.10)
include("${CMAKE_CURRENT_LIST_DIR}/clawdforge-targets.cmake")
check_required_components(clawdforge)

View file

@ -0,0 +1,78 @@
// SPDX-License-Identifier: MIT
//
// Minimal end-to-end example for the clawdforge C++ SDK. Set CF_BASE_URL and
// CF_TOKEN in your environment, point them at a running clawdforge, build,
// run.
#include <cstdlib>
#include <iostream>
#include <string>
#include <clawdforge/client.hpp>
namespace cf = clawdforge;
namespace {
std::string env_or_default(const char* name, std::string fallback) {
if (const char* v = std::getenv(name); v != nullptr && *v != '\0') {
return std::string{v};
}
return fallback;
}
} // namespace
int main(int argc, char** argv) {
(void)argc;
(void)argv;
const std::string base_url = env_or_default("CF_BASE_URL", "http://localhost:8800");
const std::string token = env_or_default("CF_TOKEN", "");
if (token.empty()) {
std::cerr << "set CF_TOKEN to a clawdforge bearer\n";
return 2;
}
cf::Client client{cf::ClientOptions{
.base_url = base_url,
.token = token,
}};
try {
const auto h = client.healthz();
std::cout << "healthz ok=" << std::boolalpha << h.ok
<< " claude_present=" << h.claude_present
<< " version=" << h.claude_version << "\n";
const auto res = client.run(cf::RunRequest{
.prompt = R"(Reply with JSON: {"hello": "world"})",
.model = "sonnet",
.timeout_secs = 60,
});
std::cout << "duration_ms=" << res.duration_ms << "\n";
if (const auto* j = std::get_if<nlohmann::json>(&res.result)) {
std::cout << "json result: " << j->dump() << "\n";
if (j->contains("hello")) {
std::cout << "hello = " << j->at("hello").get<std::string>() << "\n";
}
} else if (const auto* s = std::get_if<std::string>(&res.result)) {
std::cout << "text result: " << *s << "\n";
}
} catch (const cf::AuthError& e) {
std::cerr << "auth: " << e.what() << "\n";
return 3;
} catch (const cf::APIError& e) {
std::cerr << "api " << e.status_code() << ": " << e.what() << "\n";
std::cerr << "body: " << e.body() << "\n";
return 4;
} catch (const cf::TransportError& e) {
std::cerr << "transport: " << e.what() << "\n";
return 5;
} catch (const cf::Error& e) {
std::cerr << "clawdforge: " << e.what() << "\n";
return 6;
}
return 0;
}

View file

@ -0,0 +1,103 @@
// SPDX-License-Identifier: MIT
//
// Public client surface for the clawdforge HTTP API.
//
// Construct a `Client` once, hold onto it, call methods. Throws on failure —
// see `error.hpp` for the exception hierarchy.
#pragma once
#include <chrono>
#include <cstdint>
#include <memory>
#include <string>
#include <string_view>
#include <vector>
#include <clawdforge/error.hpp>
#include <clawdforge/types.hpp>
namespace clawdforge {
/// Knobs for `Client` construction. Use designated initializers to set only
/// what you care about.
struct ClientOptions {
/// Required. e.g. `"http://localhost:8800"` or `"http://192.168.0.5:8800"`.
/// A trailing slash is fine — it gets normalized away.
std::string base_url;
/// Bearer token used for `/run`, `/files`, `/healthz`. Optional — leave
/// empty if the client is admin-only.
std::string token;
/// Bearer token for `/admin/*` endpoints. Optional — leave empty when not
/// doing token CRUD.
std::string admin_token;
/// Per-request timeout. Default 120 s leaves headroom over the server's
/// default 60 s `claude` subprocess budget.
std::chrono::seconds timeout{120};
/// Connection establishment timeout. Default 10 s.
std::chrono::seconds connect_timeout{10};
/// Override the User-Agent header.
std::string user_agent;
/// Skip TLS verification. Off by default; only useful for self-signed
/// LAN-internal certs.
bool insecure_tls{false};
};
/// HTTP client for clawdforge.
///
/// Move-only: each `Client` owns a libcurl easy handle, which doesn't share
/// well between threads. Construct one per worker thread, or guard external
/// access with a mutex.
class Client {
public:
explicit Client(ClientOptions opts);
~Client();
// Move-only.
Client(const Client&) = delete;
Client& operator=(const Client&) = delete;
Client(Client&&) noexcept;
Client& operator=(Client&&) noexcept;
/// Base URL the client was configured with (trailing slash trimmed).
[[nodiscard]] const std::string& base_url() const noexcept;
// -- public API --------------------------------------------------------
/// `GET /healthz`. Server still enforces the global IP allowlist.
[[nodiscard]] HealthzResponse healthz();
/// `POST /run`. Throws `APIError` on 502 — inspect `body()` for the
/// `RunFailure` JSON.
[[nodiscard]] RunResult run(const RunRequest& req);
/// `POST /files`. Streams the file via `curl_mime_filedata` — the SDK
/// does not slurp it into memory.
///
/// `ttl_secs == 0` lets the server pick its default (3600). Otherwise
/// must be in 60..86400.
[[nodiscard]] FileToken upload_file(std::string_view path,
std::int32_t ttl_secs = 0);
/// `POST /admin/tokens`. Requires `admin_token` on the client.
[[nodiscard]] AppToken create_token(const TokenCreateRequest& req);
/// `GET /admin/tokens`. Requires `admin_token` on the client.
[[nodiscard]] std::vector<AppTokenInfo> list_tokens();
/// `DELETE /admin/tokens/{name}`. Requires `admin_token` on the client.
/// Throws `APIError` with `status_code() == 404` if the token is unknown.
void revoke_token(std::string_view name);
private:
struct Impl;
std::unique_ptr<Impl> impl_;
};
} // namespace clawdforge

View file

@ -0,0 +1,79 @@
// SPDX-License-Identifier: MIT
//
// Exception hierarchy for the clawdforge C++ SDK.
//
// All exceptions thrown by `clawdforge::Client` derive from `clawdforge::Error`,
// which itself derives from `std::runtime_error`. Catch `Error` for the
// catch-all path; catch the more specific subclasses when you need to branch.
#pragma once
#include <stdexcept>
#include <string>
#include <utility>
namespace clawdforge {
/// Abstract base for every exception this SDK throws.
///
/// Inherits from `std::runtime_error`, so a top-level `catch (const std::exception&)`
/// will pick these up too.
class Error : public std::runtime_error {
public:
using std::runtime_error::runtime_error;
protected:
// Marker to keep this class abstract — instantiate one of the subclasses.
virtual void anchor_() const = 0;
};
/// Thrown for HTTP 401 / 403 responses or when a required token is missing on
/// the client side.
class AuthError final : public Error {
public:
using Error::Error;
private:
void anchor_() const override {}
};
/// Thrown for any non-2xx HTTP response that isn't an auth failure.
///
/// `status_code` is the HTTP status. `body` is the raw response body (already
/// truncated to a reasonable size by the SDK to keep error logs sane).
class APIError final : public Error {
public:
APIError(int status_code, std::string body, const std::string& msg)
: Error(msg), status_code_(status_code), body_(std::move(body)) {}
[[nodiscard]] int status_code() const noexcept { return status_code_; }
[[nodiscard]] const std::string& body() const noexcept { return body_; }
private:
int status_code_;
std::string body_;
void anchor_() const override {}
};
/// Thrown for libcurl-level transport problems (connect refused, DNS failure,
/// timeout, TLS handshake error, etc.).
class TransportError final : public Error {
public:
using Error::Error;
private:
void anchor_() const override {}
};
/// Thrown for malformed JSON in a response, or when the SDK can't construct a
/// valid HTTP request from caller input (bad URL, missing required field, …).
class ProtocolError final : public Error {
public:
using Error::Error;
private:
void anchor_() const override {}
};
} // namespace clawdforge

View file

@ -0,0 +1,203 @@
// SPDX-License-Identifier: MIT
//
// Wire types for the clawdforge HTTP API.
//
// These are aggregate structs with sensible defaults — designated initializers
// (C++20) make construction read like the REST body. Conversion to/from JSON
// is via `nlohmann::json` adl_serializer specialisations defined in this file.
#pragma once
#include <cstdint>
#include <optional>
#include <string>
#include <variant>
#include <vector>
#include <nlohmann/json.hpp>
namespace clawdforge {
// ---------------------------------------------------------------------------
// /healthz
// ---------------------------------------------------------------------------
/// Response body of `GET /healthz`.
struct HealthzResponse {
bool ok{false};
bool claude_present{false};
/// First line of `claude --version`. Empty when not detected.
std::string claude_version;
};
// ---------------------------------------------------------------------------
// /run
// ---------------------------------------------------------------------------
/// Request body for `POST /run`.
///
/// `prompt` is required. Everything else is optional — leave at default to
/// fall back to the server-side defaults. `timeout_secs == 0` means "do not
/// send the field" (the server clamps real values to 5..=600).
struct RunRequest {
std::string prompt;
std::optional<std::string> model;
std::optional<std::string> system;
std::vector<std::string> files;
std::optional<std::int32_t> timeout_secs;
};
/// Successful response body from `POST /run`.
///
/// `result` is a `variant` because clawdforge auto-parses the inner `claude`
/// reply as JSON when possible (object/array/etc.) and falls back to a raw
/// string otherwise. Use `std::get_if<nlohmann::json>(&res.result)` to branch.
struct RunResult {
bool ok{false};
std::variant<nlohmann::json, std::string> result;
std::int64_t duration_ms{0};
std::optional<std::string> stop_reason;
};
/// Failure body from `POST /run` (HTTP 502). Surfaced via `APIError::body()` —
/// callers can `nlohmann::json::parse()` it themselves and pull these fields.
struct RunFailure {
bool ok{false};
std::optional<std::string> error;
std::optional<std::string> stderr_tail;
std::int64_t duration_ms{0};
std::optional<std::string> stop_reason;
};
// ---------------------------------------------------------------------------
// /files
// ---------------------------------------------------------------------------
/// Response body of `POST /files`.
struct FileToken {
/// Opaque token, prefix `ff_`. Pass into `RunRequest::files`.
std::string file_token;
/// TTL the server registered (clamped to 60..86400).
std::int32_t ttl_secs{0};
/// Bytes the server staged.
std::int64_t size{0};
};
// ---------------------------------------------------------------------------
// /admin/tokens
// ---------------------------------------------------------------------------
/// Request body for `POST /admin/tokens`.
struct TokenCreateRequest {
/// Matches the server's `[a-z0-9][a-z0-9_-]{0,63}` pattern.
std::string name;
/// Optional per-token CIDR allowlist.
std::vector<std::string> ip_cidrs;
};
/// Response body of `POST /admin/tokens`. `token` is plaintext and is only
/// returned at creation time — store it now.
struct AppToken {
std::string name;
std::string token;
std::vector<std::string> ip_cidrs;
};
/// One row of `GET /admin/tokens`.
struct AppTokenInfo {
std::string name;
std::vector<std::string> ip_cidrs;
/// Server-controlled — may be absent on older deployments.
std::optional<std::int64_t> created_at;
/// Catch-all for forward-compat fields. Populated from anything not
/// explicitly listed above.
nlohmann::json extra;
};
// ---------------------------------------------------------------------------
// JSON glue
// ---------------------------------------------------------------------------
inline void to_json(nlohmann::json& j, const RunRequest& r) {
j = nlohmann::json{{"prompt", r.prompt}};
if (r.model) j["model"] = *r.model;
if (r.system) j["system"] = *r.system;
if (!r.files.empty()) j["files"] = r.files;
if (r.timeout_secs && *r.timeout_secs > 0) j["timeout_secs"] = *r.timeout_secs;
}
inline void to_json(nlohmann::json& j, const TokenCreateRequest& r) {
j = nlohmann::json{{"name", r.name}, {"ip_cidrs", r.ip_cidrs}};
}
inline void from_json(const nlohmann::json& j, HealthzResponse& h) {
h.ok = j.value("ok", false);
h.claude_present = j.value("claude_present", false);
if (j.contains("claude_version") && !j.at("claude_version").is_null()) {
h.claude_version = j.at("claude_version").get<std::string>();
} else {
h.claude_version.clear();
}
}
inline void from_json(const nlohmann::json& j, FileToken& f) {
f.file_token = j.at("file_token").get<std::string>();
f.ttl_secs = j.value("ttl_secs", 0);
f.size = j.value("size", std::int64_t{0});
}
inline void from_json(const nlohmann::json& j, RunFailure& f) {
f.ok = j.value("ok", false);
if (j.contains("error") && !j.at("error").is_null()) {
f.error = j.at("error").get<std::string>();
}
if (j.contains("stderr") && !j.at("stderr").is_null()) {
f.stderr_tail = j.at("stderr").get<std::string>();
}
f.duration_ms = j.value("duration_ms", std::int64_t{0});
if (j.contains("stop_reason") && !j.at("stop_reason").is_null()) {
f.stop_reason = j.at("stop_reason").get<std::string>();
}
}
inline void from_json(const nlohmann::json& j, RunResult& r) {
r.ok = j.value("ok", false);
r.duration_ms = j.value("duration_ms", std::int64_t{0});
if (j.contains("stop_reason") && !j.at("stop_reason").is_null()) {
r.stop_reason = j.at("stop_reason").get<std::string>();
}
const auto& v = j.at("result");
if (v.is_string()) {
r.result = v.get<std::string>();
} else {
r.result = v; // object/array/number/bool/null all preserved as JSON
}
}
inline void from_json(const nlohmann::json& j, AppToken& a) {
a.name = j.at("name").get<std::string>();
a.token = j.at("token").get<std::string>();
if (j.contains("ip_cidrs") && j.at("ip_cidrs").is_array()) {
a.ip_cidrs = j.at("ip_cidrs").get<std::vector<std::string>>();
}
}
inline void from_json(const nlohmann::json& j, AppTokenInfo& a) {
a.name = j.at("name").get<std::string>();
if (j.contains("ip_cidrs") && j.at("ip_cidrs").is_array()) {
a.ip_cidrs = j.at("ip_cidrs").get<std::vector<std::string>>();
}
if (j.contains("created_at") && !j.at("created_at").is_null()) {
a.created_at = j.at("created_at").get<std::int64_t>();
}
// Stash anything else for forward-compat.
a.extra = nlohmann::json::object();
for (auto it = j.begin(); it != j.end(); ++it) {
const auto& k = it.key();
if (k != "name" && k != "ip_cidrs" && k != "created_at") {
a.extra[k] = it.value();
}
}
}
} // namespace clawdforge

244
clients/cpp/src/client.cpp Normal file
View file

@ -0,0 +1,244 @@
// SPDX-License-Identifier: MIT
#include <clawdforge/client.hpp>
#include <filesystem>
#include <stdexcept>
#include <string>
#include <utility>
#include <nlohmann/json.hpp>
#include "http.hpp"
namespace clawdforge {
namespace {
constexpr std::size_t kErrorBodyMax = 2048;
std::string default_user_agent() {
return "clawdforge-cpp/0.1.0";
}
[[nodiscard]] nlohmann::json parse_json_or_throw(const std::string& body, long status) {
try {
return nlohmann::json::parse(body);
} catch (const nlohmann::json::exception& e) {
throw ProtocolError(std::string{"server returned non-JSON ("} +
std::to_string(status) + "): " + e.what());
}
}
[[noreturn]] void throw_for_status(long status, std::string body) {
const std::string trimmed = detail::truncate_for_log(body, kErrorBodyMax);
if (status == 401 || status == 403) {
throw AuthError(std::string{"HTTP "} + std::to_string(status) + ": " + trimmed);
}
throw APIError(static_cast<int>(status), std::move(body),
std::string{"HTTP "} + std::to_string(status) + ": " + trimmed);
}
} // namespace
struct Client::Impl {
Impl(ClientOptions o, detail::CurlGlobalGuard g, detail::CurlSession s)
: opts(std::move(o)), guard(std::move(g)), session(std::move(s)) {}
ClientOptions opts;
detail::CurlGlobalGuard guard;
detail::CurlSession session;
[[nodiscard]] detail::HeaderMap auth_headers(bool admin) const {
detail::HeaderMap h;
h["Accept"] = "application/json";
const std::string& tok = admin ? opts.admin_token : opts.token;
if (admin && tok.empty()) {
throw AuthError("admin_token not configured on this Client");
}
if (!tok.empty()) {
h["Authorization"] = std::string{"Bearer "} + tok;
}
return h;
}
};
namespace {
// Normalize: strip trailing slashes; reject empty / non-http(s).
std::string normalize_base_url(std::string url) {
while (!url.empty() && url.back() == '/') url.pop_back();
if (url.empty()) {
throw ProtocolError("base_url is empty");
}
auto starts_with = [&](std::string_view prefix) {
return url.size() >= prefix.size() && url.compare(0, prefix.size(), prefix) == 0;
};
if (!starts_with("http://") && !starts_with("https://")) {
throw ProtocolError("base_url must start with http:// or https://");
}
return url;
}
} // namespace
Client::Client(ClientOptions opts) {
opts.base_url = normalize_base_url(std::move(opts.base_url));
if (opts.user_agent.empty()) {
opts.user_agent = default_user_agent();
}
if (opts.timeout <= std::chrono::seconds{0}) {
opts.timeout = std::chrono::seconds{120};
}
if (opts.connect_timeout <= std::chrono::seconds{0}) {
opts.connect_timeout = std::chrono::seconds{10};
}
detail::CurlGlobalGuard guard;
detail::CurlSession session{opts.timeout, opts.connect_timeout, opts.user_agent,
opts.insecure_tls};
impl_ = std::make_unique<Impl>(std::move(opts), std::move(guard), std::move(session));
}
Client::~Client() = default;
Client::Client(Client&&) noexcept = default;
Client& Client::operator=(Client&&) noexcept = default;
const std::string& Client::base_url() const noexcept { return impl_->opts.base_url; }
HealthzResponse Client::healthz() {
detail::Request req;
req.method = "GET";
req.url = detail::join_url(impl_->opts.base_url, "/healthz");
req.headers["Accept"] = "application/json";
if (!impl_->opts.token.empty()) {
req.headers["Authorization"] = "Bearer " + impl_->opts.token;
}
auto resp = impl_->session.perform(req);
if (resp.status < 200 || resp.status >= 300) {
throw_for_status(resp.status, std::move(resp.body));
}
auto j = parse_json_or_throw(resp.body, resp.status);
return j.get<HealthzResponse>();
}
RunResult Client::run(const RunRequest& body) {
if (impl_->opts.token.empty()) {
throw AuthError("Client::run requires `token` in ClientOptions");
}
if (body.prompt.empty()) {
throw ProtocolError("RunRequest.prompt must not be empty");
}
detail::Request req;
req.method = "POST";
req.url = detail::join_url(impl_->opts.base_url, "/run");
req.headers = impl_->auth_headers(/*admin=*/false);
req.headers["Content-Type"] = "application/json";
nlohmann::json j = body;
req.body = j.dump();
auto resp = impl_->session.perform(req);
if (resp.status < 200 || resp.status >= 300) {
throw_for_status(resp.status, std::move(resp.body));
}
auto parsed = parse_json_or_throw(resp.body, resp.status);
return parsed.get<RunResult>();
}
FileToken Client::upload_file(std::string_view path, std::int32_t ttl_secs) {
if (impl_->opts.token.empty()) {
throw AuthError("Client::upload_file requires `token` in ClientOptions");
}
namespace fs = std::filesystem;
const fs::path p{std::string{path}};
std::error_code ec;
if (!fs::exists(p, ec) || ec) {
throw ProtocolError(std::string{"upload_file: file does not exist: "} + p.string());
}
if (!fs::is_regular_file(p, ec) || ec) {
throw ProtocolError(std::string{"upload_file: not a regular file: "} + p.string());
}
detail::Request req;
req.method = "POST";
req.url = detail::join_url(impl_->opts.base_url, "/files");
req.headers = impl_->auth_headers(/*admin=*/false);
// multipart Content-Type is set by libcurl with a boundary; don't preset.
detail::Request::MultipartFile mf;
mf.field_name = "file";
mf.filename = p.filename().empty() ? std::string{"upload"} : p.filename().string();
mf.content_type = "application/octet-stream";
mf.filesystem_path = p.string();
req.file = std::move(mf);
if (ttl_secs > 0) {
req.form_fields.emplace_back("ttl_secs", std::to_string(ttl_secs));
}
auto resp = impl_->session.perform(req);
if (resp.status < 200 || resp.status >= 300) {
throw_for_status(resp.status, std::move(resp.body));
}
auto j = parse_json_or_throw(resp.body, resp.status);
return j.get<FileToken>();
}
AppToken Client::create_token(const TokenCreateRequest& body) {
detail::Request req;
req.method = "POST";
req.url = detail::join_url(impl_->opts.base_url, "/admin/tokens");
req.headers = impl_->auth_headers(/*admin=*/true);
req.headers["Content-Type"] = "application/json";
nlohmann::json j = body;
req.body = j.dump();
auto resp = impl_->session.perform(req);
if (resp.status < 200 || resp.status >= 300) {
throw_for_status(resp.status, std::move(resp.body));
}
auto parsed = parse_json_or_throw(resp.body, resp.status);
return parsed.get<AppToken>();
}
std::vector<AppTokenInfo> Client::list_tokens() {
detail::Request req;
req.method = "GET";
req.url = detail::join_url(impl_->opts.base_url, "/admin/tokens");
req.headers = impl_->auth_headers(/*admin=*/true);
auto resp = impl_->session.perform(req);
if (resp.status < 200 || resp.status >= 300) {
throw_for_status(resp.status, std::move(resp.body));
}
auto parsed = parse_json_or_throw(resp.body, resp.status);
if (!parsed.contains("tokens") || !parsed.at("tokens").is_array()) {
throw ProtocolError("list_tokens: missing or non-array `tokens`");
}
std::vector<AppTokenInfo> out;
out.reserve(parsed["tokens"].size());
for (const auto& el : parsed["tokens"]) {
out.push_back(el.get<AppTokenInfo>());
}
return out;
}
void Client::revoke_token(std::string_view name) {
if (name.empty()) {
throw ProtocolError("revoke_token: name is empty");
}
detail::Request req;
req.method = "DELETE";
req.url = detail::join_url(impl_->opts.base_url,
"/admin/tokens/" + detail::url_encode_path(name));
req.headers = impl_->auth_headers(/*admin=*/true);
auto resp = impl_->session.perform(req);
if (resp.status < 200 || resp.status >= 300) {
throw_for_status(resp.status, std::move(resp.body));
}
// Body is `{"ok": true}`; we don't surface anything from it.
}
} // namespace clawdforge

326
clients/cpp/src/http.cpp Normal file
View file

@ -0,0 +1,326 @@
// SPDX-License-Identifier: MIT
#include "http.hpp"
#include <atomic>
#include <cctype>
#include <cstring>
#include <mutex>
#include <stdexcept>
#include <string>
#include <clawdforge/error.hpp>
namespace clawdforge::detail {
namespace {
// Reference-counted global init. libcurl is safe to call from multiple threads
// once `curl_global_init` has run; we just need to make sure it has.
std::atomic<int> g_global_refs{0};
std::mutex g_global_mu;
std::size_t write_body_cb(char* ptr, std::size_t size, std::size_t nmemb, void* userdata) {
auto* out = static_cast<std::string*>(userdata);
const std::size_t n = size * nmemb;
out->append(ptr, n);
return n;
}
std::size_t write_header_cb(char* ptr, std::size_t size, std::size_t nmemb, void* userdata) {
auto* hdrs = static_cast<HeaderMap*>(userdata);
const std::size_t n = size * nmemb;
std::string line(ptr, n);
// Strip trailing CRLF.
while (!line.empty() && (line.back() == '\n' || line.back() == '\r')) {
line.pop_back();
}
auto colon = line.find(':');
if (colon == std::string::npos) {
return n; // status line or blank — skip
}
std::string name = line.substr(0, colon);
std::string value = line.substr(colon + 1);
// Trim leading whitespace on the value.
std::size_t i = 0;
while (i < value.size() && (value[i] == ' ' || value[i] == '\t')) ++i;
value.erase(0, i);
// Lowercase the header name for case-insensitive lookup.
for (auto& c : name) c = static_cast<char>(std::tolower(static_cast<unsigned char>(c)));
(*hdrs)[std::move(name)] = std::move(value);
return n;
}
} // namespace
CurlGlobalGuard::CurlGlobalGuard() {
std::lock_guard<std::mutex> lk(g_global_mu);
if (g_global_refs.fetch_add(1) == 0) {
const CURLcode rc = curl_global_init(CURL_GLOBAL_DEFAULT);
if (rc != CURLE_OK) {
g_global_refs.fetch_sub(1);
active_ = false;
throw TransportError(std::string{"curl_global_init failed: "} + curl_easy_strerror(rc));
}
}
active_ = true;
}
CurlGlobalGuard::~CurlGlobalGuard() {
if (!active_) return;
std::lock_guard<std::mutex> lk(g_global_mu);
if (g_global_refs.fetch_sub(1) == 1) {
curl_global_cleanup();
}
}
CurlGlobalGuard::CurlGlobalGuard(const CurlGlobalGuard& other) : active_(other.active_) {
if (active_) {
g_global_refs.fetch_add(1);
}
}
CurlGlobalGuard& CurlGlobalGuard::operator=(const CurlGlobalGuard& other) {
if (this == &other) return *this;
// Drop our existing ref, then take one from `other`.
if (active_) {
std::lock_guard<std::mutex> lk(g_global_mu);
if (g_global_refs.fetch_sub(1) == 1) {
curl_global_cleanup();
}
}
active_ = other.active_;
if (active_) {
g_global_refs.fetch_add(1);
}
return *this;
}
CurlGlobalGuard::CurlGlobalGuard(CurlGlobalGuard&& other) noexcept : active_(other.active_) {
other.active_ = false;
}
CurlGlobalGuard& CurlGlobalGuard::operator=(CurlGlobalGuard&& other) noexcept {
if (this == &other) return *this;
if (active_) {
std::lock_guard<std::mutex> lk(g_global_mu);
if (g_global_refs.fetch_sub(1) == 1) {
curl_global_cleanup();
}
}
active_ = other.active_;
other.active_ = false;
return *this;
}
CurlSession::CurlSession(std::chrono::seconds timeout,
std::chrono::seconds connect_timeout,
std::string user_agent,
bool insecure_tls)
: timeout_(timeout),
connect_timeout_(connect_timeout),
user_agent_(std::move(user_agent)),
insecure_tls_(insecure_tls) {
easy_ = curl_easy_init();
if (easy_ == nullptr) {
throw TransportError("curl_easy_init returned null");
}
}
CurlSession::~CurlSession() {
if (easy_ != nullptr) {
curl_easy_cleanup(easy_);
}
}
CurlSession::CurlSession(CurlSession&& other) noexcept
: easy_(other.easy_),
timeout_(other.timeout_),
connect_timeout_(other.connect_timeout_),
user_agent_(std::move(other.user_agent_)),
insecure_tls_(other.insecure_tls_) {
other.easy_ = nullptr;
}
CurlSession& CurlSession::operator=(CurlSession&& other) noexcept {
if (this != &other) {
if (easy_ != nullptr) curl_easy_cleanup(easy_);
easy_ = other.easy_;
timeout_ = other.timeout_;
connect_timeout_ = other.connect_timeout_;
user_agent_ = std::move(other.user_agent_);
insecure_tls_ = other.insecure_tls_;
other.easy_ = nullptr;
}
return *this;
}
Response CurlSession::perform(const Request& req) {
// libcurl recommends easy_reset between uses of an easy handle; cheaper
// than init + cleanup per request and preserves the connection cache.
curl_easy_reset(easy_);
Response resp;
curl_easy_setopt(easy_, CURLOPT_URL, req.url.c_str());
curl_easy_setopt(easy_, CURLOPT_FOLLOWLOCATION, 1L);
curl_easy_setopt(easy_, CURLOPT_NOSIGNAL, 1L); // be thread-friendly
curl_easy_setopt(easy_, CURLOPT_TIMEOUT, static_cast<long>(timeout_.count()));
curl_easy_setopt(easy_, CURLOPT_CONNECTTIMEOUT, static_cast<long>(connect_timeout_.count()));
curl_easy_setopt(easy_, CURLOPT_USERAGENT, user_agent_.c_str());
curl_easy_setopt(easy_, CURLOPT_WRITEFUNCTION, write_body_cb);
curl_easy_setopt(easy_, CURLOPT_WRITEDATA, &resp.body);
curl_easy_setopt(easy_, CURLOPT_HEADERFUNCTION, write_header_cb);
curl_easy_setopt(easy_, CURLOPT_HEADERDATA, &resp.headers);
if (insecure_tls_) {
curl_easy_setopt(easy_, CURLOPT_SSL_VERIFYPEER, 0L);
curl_easy_setopt(easy_, CURLOPT_SSL_VERIFYHOST, 0L);
}
// Method + body / mime ----------------------------------------------------
struct curl_slist* hdr_list = nullptr;
curl_mime* mime = nullptr;
// Idempotent RAII cleanup — runs exactly once whether we exit via return
// or exception, no matter the path through the body.
struct CurlReqCleanup {
struct curl_slist** hdrs;
curl_mime** mime;
~CurlReqCleanup() {
if (*hdrs != nullptr) {
curl_slist_free_all(*hdrs);
*hdrs = nullptr;
}
if (*mime != nullptr) {
curl_mime_free(*mime);
*mime = nullptr;
}
}
} cleanup{&hdr_list, &mime};
{
if (req.method == "GET") {
curl_easy_setopt(easy_, CURLOPT_HTTPGET, 1L);
} else if (req.method == "POST") {
curl_easy_setopt(easy_, CURLOPT_POST, 1L);
if (req.file) {
mime = curl_mime_init(easy_);
if (mime == nullptr) {
throw TransportError("curl_mime_init returned null");
}
{
curl_mimepart* part = curl_mime_addpart(mime);
curl_mime_name(part, req.file->field_name.c_str());
curl_mime_filename(part, req.file->filename.c_str());
if (!req.file->content_type.empty()) {
curl_mime_type(part, req.file->content_type.c_str());
}
const CURLcode mrc =
curl_mime_filedata(part, req.file->filesystem_path.c_str());
if (mrc != CURLE_OK) {
throw TransportError(std::string{"curl_mime_filedata: "} +
curl_easy_strerror(mrc));
}
}
for (const auto& [k, v] : req.form_fields) {
curl_mimepart* part = curl_mime_addpart(mime);
curl_mime_name(part, k.c_str());
curl_mime_data(part, v.c_str(), v.size());
}
curl_easy_setopt(easy_, CURLOPT_MIMEPOST, mime);
} else if (req.body) {
curl_easy_setopt(easy_, CURLOPT_POSTFIELDS, req.body->data());
curl_easy_setopt(easy_, CURLOPT_POSTFIELDSIZE_LARGE,
static_cast<curl_off_t>(req.body->size()));
} else {
// POST with empty body — still valid (e.g. action endpoints).
curl_easy_setopt(easy_, CURLOPT_POSTFIELDS, "");
curl_easy_setopt(easy_, CURLOPT_POSTFIELDSIZE, 0L);
}
} else {
curl_easy_setopt(easy_, CURLOPT_CUSTOMREQUEST, req.method.c_str());
if (req.body) {
curl_easy_setopt(easy_, CURLOPT_POSTFIELDS, req.body->data());
curl_easy_setopt(easy_, CURLOPT_POSTFIELDSIZE_LARGE,
static_cast<curl_off_t>(req.body->size()));
}
}
for (const auto& [name, value] : req.headers) {
std::string line = name + ": " + value;
hdr_list = curl_slist_append(hdr_list, line.c_str());
}
// Suppress libcurl's auto-Expect:100-continue on POST — needless RTT.
hdr_list = curl_slist_append(hdr_list, "Expect:");
if (hdr_list != nullptr) {
curl_easy_setopt(easy_, CURLOPT_HTTPHEADER, hdr_list);
}
char errbuf[CURL_ERROR_SIZE]{};
curl_easy_setopt(easy_, CURLOPT_ERRORBUFFER, errbuf);
const CURLcode rc = curl_easy_perform(easy_);
if (rc != CURLE_OK) {
std::string msg = errbuf[0] != '\0' ? errbuf : curl_easy_strerror(rc);
throw TransportError(std::string{"libcurl: "} + msg);
}
long status = 0;
curl_easy_getinfo(easy_, CURLINFO_RESPONSE_CODE, &status);
resp.status = status;
}
return resp;
}
std::string join_url(std::string_view base, std::string_view path) {
while (!base.empty() && base.back() == '/') {
base.remove_suffix(1);
}
while (!path.empty() && path.front() == '/') {
path.remove_prefix(1);
}
std::string out;
out.reserve(base.size() + 1 + path.size());
out.append(base.begin(), base.end());
out.push_back('/');
out.append(path.begin(), path.end());
return out;
}
std::string url_encode_path(std::string_view in) {
static const char* hex = "0123456789ABCDEF";
std::string out;
out.reserve(in.size());
for (char ch : in) {
const auto c = static_cast<unsigned char>(ch);
const bool unreserved =
(c >= 'A' && c <= 'Z') || (c >= 'a' && c <= 'z') ||
(c >= '0' && c <= '9') || c == '-' || c == '_' || c == '.' || c == '~';
if (unreserved) {
out.push_back(static_cast<char>(c));
} else {
out.push_back('%');
out.push_back(hex[c >> 4U]);
out.push_back(hex[c & 0x0FU]);
}
}
return out;
}
std::string truncate_for_log(std::string_view s, std::size_t max) {
if (s.size() <= max) return std::string{s};
// Don't slice mid-codepoint (best effort).
std::size_t cut = max;
while (cut > 0 && (static_cast<unsigned char>(s[cut]) & 0xC0) == 0x80) {
--cut;
}
std::string out{s.substr(0, cut)};
out.append("...[truncated]");
return out;
}
} // namespace clawdforge::detail

112
clients/cpp/src/http.hpp Normal file
View file

@ -0,0 +1,112 @@
// 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

View file

@ -0,0 +1,335 @@
// SPDX-License-Identifier: MIT
//
// Mocks the clawdforge HTTP API with cpp-httplib and exercises the SDK end to
// end. Each TEST_CASE spins up a fresh server bound to 127.0.0.1:0 (random
// free port) so cases can run in parallel without colliding.
#define DOCTEST_CONFIG_IMPLEMENT_WITH_MAIN
#include <doctest/doctest.h>
#include <atomic>
#include <chrono>
#include <cstdio>
#include <filesystem>
#include <fstream>
#include <future>
#include <memory>
#include <string>
#include <thread>
#include <httplib.h>
#include <nlohmann/json.hpp>
#include <clawdforge/client.hpp>
namespace cf = clawdforge;
using nlohmann::json;
namespace {
/// Owns a httplib::Server bound to 127.0.0.1:0 in a background thread. RAII —
/// the destructor stops the server and joins the thread.
class MockServer {
public:
explicit MockServer(std::function<void(httplib::Server&)> wire_routes) {
// Cap the worker thread pool low — cpp-httplib defaults to one worker
// per CPU core, which on big hosts piles up under ASan and trips the
// per-process map limit. Two workers is plenty for these tests.
srv_.new_task_queue = [] { return new httplib::ThreadPool(2); };
wire_routes(srv_);
port_ = srv_.bind_to_any_port("127.0.0.1");
REQUIRE(port_ > 0);
thread_ = std::thread([this] { srv_.listen_after_bind(); });
// Wait for the server to be ready (cheap poll — `listen_after_bind`
// doesn't expose a barrier).
for (int i = 0; i < 200; ++i) {
if (srv_.is_running()) break;
std::this_thread::sleep_for(std::chrono::milliseconds(5));
}
REQUIRE(srv_.is_running());
}
~MockServer() {
srv_.stop();
if (thread_.joinable()) thread_.join();
}
[[nodiscard]] std::string base_url() const {
return "http://127.0.0.1:" + std::to_string(port_);
}
private:
httplib::Server srv_;
int port_{0};
std::thread thread_;
};
[[nodiscard]] std::string write_temp_file(std::string_view content) {
namespace fs = std::filesystem;
const auto path = fs::temp_directory_path() /
("clawdforge-test-" +
std::to_string(std::chrono::steady_clock::now().time_since_epoch().count()) +
".bin");
std::ofstream out(path, std::ios::binary);
out.write(content.data(), static_cast<std::streamsize>(content.size()));
out.close();
return path.string();
}
} // namespace
TEST_CASE("healthz returns parsed body") {
MockServer mock{[](httplib::Server& s) {
s.Get("/healthz", [](const httplib::Request&, httplib::Response& res) {
res.set_content(
R"({"ok":true,"claude_present":true,"claude_version":"1.2.3"})",
"application/json");
});
}};
cf::Client c{cf::ClientOptions{.base_url = mock.base_url(), .token = "cf_test"}};
const auto h = c.healthz();
CHECK(h.ok);
CHECK(h.claude_present);
CHECK(h.claude_version == "1.2.3");
}
TEST_CASE("run with JSON result decodes into variant<json>") {
MockServer mock{[](httplib::Server& s) {
s.Post("/run", [](const httplib::Request& req, httplib::Response& res) {
CHECK(req.has_header("Authorization"));
CHECK(req.get_header_value("Authorization") == "Bearer cf_test");
const auto body = json::parse(req.body);
CHECK(body.at("prompt").get<std::string>() == "hi");
CHECK(body.at("model").get<std::string>() == "sonnet");
res.set_content(
R"({"ok":true,"result":{"hello":"world"},"duration_ms":42,"stop_reason":"end_turn"})",
"application/json");
});
}};
cf::Client c{cf::ClientOptions{.base_url = mock.base_url(), .token = "cf_test"}};
const auto r = c.run(cf::RunRequest{.prompt = "hi", .model = "sonnet"});
CHECK(r.ok);
CHECK(r.duration_ms == 42);
REQUIRE(r.stop_reason.has_value());
CHECK(*r.stop_reason == "end_turn");
const auto* j = std::get_if<nlohmann::json>(&r.result);
REQUIRE(j != nullptr);
CHECK(j->at("hello").get<std::string>() == "world");
}
TEST_CASE("run with string result decodes into variant<string>") {
MockServer mock{[](httplib::Server& s) {
s.Post("/run", [](const httplib::Request&, httplib::Response& res) {
res.set_content(
R"({"ok":true,"result":"plain text reply","duration_ms":12})",
"application/json");
});
}};
cf::Client c{cf::ClientOptions{.base_url = mock.base_url(), .token = "cf_test"}};
const auto r = c.run(cf::RunRequest{.prompt = "hi"});
const auto* s = std::get_if<std::string>(&r.result);
REQUIRE(s != nullptr);
CHECK(*s == "plain text reply");
}
TEST_CASE("run failure -> APIError with status 502 and body") {
MockServer mock{[](httplib::Server& s) {
s.Post("/run", [](const httplib::Request&, httplib::Response& res) {
res.status = 502;
res.set_content(
R"({"ok":false,"error":"timeout","duration_ms":60000,"stderr":"...","stop_reason":null})",
"application/json");
});
}};
cf::Client c{cf::ClientOptions{.base_url = mock.base_url(), .token = "cf_test"}};
try {
(void)c.run(cf::RunRequest{.prompt = "hi"});
FAIL("expected APIError");
} catch (const cf::APIError& e) {
CHECK(e.status_code() == 502);
const auto body = json::parse(e.body());
CHECK(body.at("error").get<std::string>() == "timeout");
}
}
TEST_CASE("401 -> AuthError") {
MockServer mock{[](httplib::Server& s) {
s.Post("/run", [](const httplib::Request&, httplib::Response& res) {
res.status = 401;
res.set_content(R"({"detail":"bad token"})", "application/json");
});
}};
cf::Client c{cf::ClientOptions{.base_url = mock.base_url(), .token = "cf_bad"}};
CHECK_THROWS_AS(c.run(cf::RunRequest{.prompt = "hi"}), cf::AuthError);
}
TEST_CASE("run with files attaches list to body") {
MockServer mock{[](httplib::Server& s) {
s.Post("/run", [](const httplib::Request& req, httplib::Response& res) {
const auto body = json::parse(req.body);
REQUIRE(body.contains("files"));
REQUIRE(body.at("files").is_array());
CHECK(body.at("files").size() == 1);
CHECK(body.at("files")[0].get<std::string>() == "ff_abcd");
res.set_content(
R"({"ok":true,"result":"got file","duration_ms":1})",
"application/json");
});
}};
cf::Client c{cf::ClientOptions{.base_url = mock.base_url(), .token = "cf_test"}};
const auto r = c.run(cf::RunRequest{
.prompt = "extract", .files = {"ff_abcd"}, .timeout_secs = 30});
CHECK(r.ok);
}
TEST_CASE("upload_file streams a multipart and parses FileToken") {
const std::string payload(1024 * 200, 'x'); // 200 KB
const auto path = write_temp_file(payload);
std::atomic<bool> saw_field{false};
std::atomic<std::size_t> received_size{0};
std::atomic<bool> saw_ttl{false};
MockServer mock{[&](httplib::Server& s) {
s.Post("/files", [&](const httplib::Request& req, httplib::Response& res) {
if (req.has_file("file")) {
saw_field = true;
received_size = req.get_file_value("file").content.size();
}
if (req.has_file("ttl_secs")) {
saw_ttl = true;
CHECK(req.get_file_value("ttl_secs").content == "120");
}
res.set_content(
R"({"file_token":"ff_xyz","ttl_secs":120,"size":204800})",
"application/json");
});
}};
cf::Client c{cf::ClientOptions{.base_url = mock.base_url(), .token = "cf_test"}};
const auto ft = c.upload_file(path, 120);
CHECK(ft.file_token == "ff_xyz");
CHECK(ft.ttl_secs == 120);
CHECK(ft.size == 204800);
CHECK(saw_field.load());
CHECK(saw_ttl.load());
CHECK(received_size.load() == payload.size());
std::filesystem::remove(path);
}
TEST_CASE("admin token CRUD round-trip") {
std::vector<std::string> seen_methods;
std::vector<std::string> seen_paths;
MockServer mock{[&](httplib::Server& s) {
s.Post("/admin/tokens", [&](const httplib::Request& req, httplib::Response& res) {
seen_methods.emplace_back("POST");
seen_paths.emplace_back("/admin/tokens");
CHECK(req.get_header_value("Authorization") == "Bearer admin_secret");
const auto body = json::parse(req.body);
CHECK(body.at("name").get<std::string>() == "consumer-1");
res.set_content(
R"({"name":"consumer-1","token":"cf_NEWTOKEN","ip_cidrs":["10.0.0.0/8"]})",
"application/json");
});
s.Get("/admin/tokens", [&](const httplib::Request& req, httplib::Response& res) {
seen_methods.emplace_back("GET");
seen_paths.emplace_back("/admin/tokens");
CHECK(req.get_header_value("Authorization") == "Bearer admin_secret");
res.set_content(
R"({"tokens":[
{"name":"consumer-1","ip_cidrs":["10.0.0.0/8"],"created_at":1700000000,"extra":"yo"}
]})",
"application/json");
});
s.Delete(R"(/admin/tokens/(.+))",
[&](const httplib::Request& req, httplib::Response& res) {
seen_methods.emplace_back("DELETE");
seen_paths.emplace_back(req.path);
res.set_content(R"({"ok":true})", "application/json");
});
}};
cf::Client c{cf::ClientOptions{.base_url = mock.base_url(),
.token = "cf_test",
.admin_token = "admin_secret"}};
const auto created = c.create_token(cf::TokenCreateRequest{
.name = "consumer-1", .ip_cidrs = {"10.0.0.0/8"}});
CHECK(created.token == "cf_NEWTOKEN");
const auto list = c.list_tokens();
REQUIRE(list.size() == 1);
CHECK(list[0].name == "consumer-1");
REQUIRE(list[0].created_at.has_value());
CHECK(*list[0].created_at == 1700000000);
CHECK(list[0].extra.contains("extra"));
c.revoke_token("consumer-1");
REQUIRE(seen_methods.size() == 3);
CHECK(seen_methods[0] == "POST");
CHECK(seen_methods[1] == "GET");
CHECK(seen_methods[2] == "DELETE");
CHECK(seen_paths[2] == "/admin/tokens/consumer-1");
}
TEST_CASE("revoke_token without admin token throws AuthError") {
cf::Client c{cf::ClientOptions{.base_url = "http://127.0.0.1:1", .token = "cf_test"}};
CHECK_THROWS_AS(c.revoke_token("foo"), cf::AuthError);
}
TEST_CASE("transport-level failure surfaces as TransportError") {
// Port 1 is reserved (tcpmux); connect should refuse fast on Linux.
cf::Client c{cf::ClientOptions{
.base_url = "http://127.0.0.1:1",
.token = "cf_test",
.timeout = std::chrono::seconds{2},
.connect_timeout = std::chrono::seconds{1},
}};
auto fire = [&] {
auto h = c.healthz();
(void)h;
};
CHECK_THROWS_AS(fire(), cf::TransportError);
}
TEST_CASE("base_url is normalized (trailing slash trimmed)") {
MockServer mock{[](httplib::Server& s) {
s.Get("/healthz", [](const httplib::Request&, httplib::Response& res) {
res.set_content(R"({"ok":true,"claude_present":false})", "application/json");
});
}};
cf::Client c{cf::ClientOptions{.base_url = mock.base_url() + "/", .token = "cf_test"}};
CHECK(c.base_url() == mock.base_url());
const auto h = c.healthz();
CHECK(h.ok);
CHECK_FALSE(h.claude_present);
CHECK(h.claude_version.empty());
}
TEST_CASE("invalid base_url scheme is rejected at construction") {
auto make_ftp = [] {
cf::ClientOptions opts;
opts.base_url = "ftp://nope";
opts.token = "x";
cf::Client c{std::move(opts)};
(void)c;
};
auto make_empty = [] {
cf::ClientOptions opts;
opts.base_url = "";
opts.token = "x";
cf::Client c{std::move(opts)};
(void)c;
};
CHECK_THROWS_AS(make_ftp(), cf::ProtocolError);
CHECK_THROWS_AS(make_empty(), cf::ProtocolError);
}