From bae34a7701e1e3a0a45e64385002c735aac6f718 Mon Sep 17 00:00:00 2001 From: Kayos Date: Tue, 28 Apr 2026 23:02:37 -0700 Subject: [PATCH] clients/cpp: initial C++ SDK for clawdforge MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- clients/cpp/.gitignore | 13 + clients/cpp/CMakeLists.txt | 204 +++++++++++ clients/cpp/README.md | 160 +++++++++ clients/cpp/cmake/clawdforge-config.cmake.in | 9 + clients/cpp/examples/basic.cpp | 78 +++++ clients/cpp/include/clawdforge/client.hpp | 103 ++++++ clients/cpp/include/clawdforge/error.hpp | 79 +++++ clients/cpp/include/clawdforge/types.hpp | 203 +++++++++++ clients/cpp/src/client.cpp | 244 ++++++++++++++ clients/cpp/src/http.cpp | 326 ++++++++++++++++++ clients/cpp/src/http.hpp | 112 +++++++ clients/cpp/tests/test_client.cpp | 335 +++++++++++++++++++ 12 files changed, 1866 insertions(+) create mode 100644 clients/cpp/.gitignore create mode 100644 clients/cpp/CMakeLists.txt create mode 100644 clients/cpp/README.md create mode 100644 clients/cpp/cmake/clawdforge-config.cmake.in create mode 100644 clients/cpp/examples/basic.cpp create mode 100644 clients/cpp/include/clawdforge/client.hpp create mode 100644 clients/cpp/include/clawdforge/error.hpp create mode 100644 clients/cpp/include/clawdforge/types.hpp create mode 100644 clients/cpp/src/client.cpp create mode 100644 clients/cpp/src/http.cpp create mode 100644 clients/cpp/src/http.hpp create mode 100644 clients/cpp/tests/test_client.cpp diff --git a/clients/cpp/.gitignore b/clients/cpp/.gitignore new file mode 100644 index 0000000..01a5e33 --- /dev/null +++ b/clients/cpp/.gitignore @@ -0,0 +1,13 @@ +build/ +build-*/ +out/ +.cache/ +compile_commands.json +*.o +*.a +*.so +*.dylib +CMakeFiles/ +CMakeCache.txt +cmake_install.cmake +Makefile diff --git a/clients/cpp/CMakeLists.txt b/clients/cpp/CMakeLists.txt new file mode 100644 index 0000000..9529761 --- /dev/null +++ b/clients/cpp/CMakeLists.txt @@ -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 + $ + $ + 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() diff --git a/clients/cpp/README.md b/clients/cpp/README.md new file mode 100644 index 0000000..0caa914 --- /dev/null +++ b/clients/cpp/README.md @@ -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 +#include + +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(&r.result)) { + std::cout << j->at("hello").get() << "\n"; + } else if (auto* s = std::get_if(&r.result)) { + std::cout << *s << "\n"; + } +} +``` + +## API + +`clawdforge::Client` (in ``): + +| 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` | +| `revoke_token(name)` | `DELETE /admin/tokens/` | `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. diff --git a/clients/cpp/cmake/clawdforge-config.cmake.in b/clients/cpp/cmake/clawdforge-config.cmake.in new file mode 100644 index 0000000..4eda5a0 --- /dev/null +++ b/clients/cpp/cmake/clawdforge-config.cmake.in @@ -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) diff --git a/clients/cpp/examples/basic.cpp b/clients/cpp/examples/basic.cpp new file mode 100644 index 0000000..2d6b1b1 --- /dev/null +++ b/clients/cpp/examples/basic.cpp @@ -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 +#include +#include + +#include + +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(&res.result)) { + std::cout << "json result: " << j->dump() << "\n"; + if (j->contains("hello")) { + std::cout << "hello = " << j->at("hello").get() << "\n"; + } + } else if (const auto* s = std::get_if(&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; +} diff --git a/clients/cpp/include/clawdforge/client.hpp b/clients/cpp/include/clawdforge/client.hpp new file mode 100644 index 0000000..52beb44 --- /dev/null +++ b/clients/cpp/include/clawdforge/client.hpp @@ -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 +#include +#include +#include +#include +#include + +#include +#include + +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 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_; +}; + +} // namespace clawdforge diff --git a/clients/cpp/include/clawdforge/error.hpp b/clients/cpp/include/clawdforge/error.hpp new file mode 100644 index 0000000..28fdc1b --- /dev/null +++ b/clients/cpp/include/clawdforge/error.hpp @@ -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 +#include +#include + +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 diff --git a/clients/cpp/include/clawdforge/types.hpp b/clients/cpp/include/clawdforge/types.hpp new file mode 100644 index 0000000..f843e5a --- /dev/null +++ b/clients/cpp/include/clawdforge/types.hpp @@ -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 +#include +#include +#include +#include + +#include + +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 model; + std::optional system; + std::vector files; + std::optional 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(&res.result)` to branch. +struct RunResult { + bool ok{false}; + std::variant result; + std::int64_t duration_ms{0}; + std::optional 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 error; + std::optional stderr_tail; + std::int64_t duration_ms{0}; + std::optional 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 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 ip_cidrs; +}; + +/// One row of `GET /admin/tokens`. +struct AppTokenInfo { + std::string name; + std::vector ip_cidrs; + /// Server-controlled — may be absent on older deployments. + std::optional 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(); + } else { + h.claude_version.clear(); + } +} + +inline void from_json(const nlohmann::json& j, FileToken& f) { + f.file_token = j.at("file_token").get(); + 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(); + } + if (j.contains("stderr") && !j.at("stderr").is_null()) { + f.stderr_tail = j.at("stderr").get(); + } + 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(); + } +} + +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(); + } + const auto& v = j.at("result"); + if (v.is_string()) { + r.result = v.get(); + } 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(); + a.token = j.at("token").get(); + if (j.contains("ip_cidrs") && j.at("ip_cidrs").is_array()) { + a.ip_cidrs = j.at("ip_cidrs").get>(); + } +} + +inline void from_json(const nlohmann::json& j, AppTokenInfo& a) { + a.name = j.at("name").get(); + if (j.contains("ip_cidrs") && j.at("ip_cidrs").is_array()) { + a.ip_cidrs = j.at("ip_cidrs").get>(); + } + if (j.contains("created_at") && !j.at("created_at").is_null()) { + a.created_at = j.at("created_at").get(); + } + // 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 diff --git a/clients/cpp/src/client.cpp b/clients/cpp/src/client.cpp new file mode 100644 index 0000000..ea97733 --- /dev/null +++ b/clients/cpp/src/client.cpp @@ -0,0 +1,244 @@ +// SPDX-License-Identifier: MIT + +#include + +#include +#include +#include +#include + +#include + +#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(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(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(); +} + +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(); +} + +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(); +} + +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(); +} + +std::vector 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 out; + out.reserve(parsed["tokens"].size()); + for (const auto& el : parsed["tokens"]) { + out.push_back(el.get()); + } + 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 diff --git a/clients/cpp/src/http.cpp b/clients/cpp/src/http.cpp new file mode 100644 index 0000000..d7ed04f --- /dev/null +++ b/clients/cpp/src/http.cpp @@ -0,0 +1,326 @@ +// SPDX-License-Identifier: MIT + +#include "http.hpp" + +#include +#include +#include +#include +#include +#include + +#include + +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 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(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(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(std::tolower(static_cast(c))); + + (*hdrs)[std::move(name)] = std::move(value); + return n; +} + +} // namespace + +CurlGlobalGuard::CurlGlobalGuard() { + std::lock_guard 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 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 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 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(timeout_.count())); + curl_easy_setopt(easy_, CURLOPT_CONNECTTIMEOUT, static_cast(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(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(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(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(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(s[cut]) & 0xC0) == 0x80) { + --cut; + } + std::string out{s.substr(0, cut)}; + out.append("...[truncated]"); + return out; +} + +} // namespace clawdforge::detail diff --git a/clients/cpp/src/http.hpp b/clients/cpp/src/http.hpp new file mode 100644 index 0000000..95a09b6 --- /dev/null +++ b/clients/cpp/src/http.hpp @@ -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 +#include +#include +#include +#include +#include +#include +#include + +#include + +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; + +/// 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 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 file; + std::vector> 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/). +[[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 diff --git a/clients/cpp/tests/test_client.cpp b/clients/cpp/tests/test_client.cpp new file mode 100644 index 0000000..91d120d --- /dev/null +++ b/clients/cpp/tests/test_client.cpp @@ -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 + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include +#include + +#include + +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 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(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") { + 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() == "hi"); + CHECK(body.at("model").get() == "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(&r.result); + REQUIRE(j != nullptr); + CHECK(j->at("hello").get() == "world"); +} + +TEST_CASE("run with string result decodes into variant") { + 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(&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() == "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() == "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 saw_field{false}; + std::atomic received_size{0}; + std::atomic 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 seen_methods; + std::vector 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() == "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); +}