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:
parent
a69e924592
commit
bae34a7701
12 changed files with 1866 additions and 0 deletions
13
clients/cpp/.gitignore
vendored
Normal file
13
clients/cpp/.gitignore
vendored
Normal 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
204
clients/cpp/CMakeLists.txt
Normal 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
160
clients/cpp/README.md
Normal 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.
|
||||
9
clients/cpp/cmake/clawdforge-config.cmake.in
Normal file
9
clients/cpp/cmake/clawdforge-config.cmake.in
Normal 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)
|
||||
78
clients/cpp/examples/basic.cpp
Normal file
78
clients/cpp/examples/basic.cpp
Normal 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;
|
||||
}
|
||||
103
clients/cpp/include/clawdforge/client.hpp
Normal file
103
clients/cpp/include/clawdforge/client.hpp
Normal 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
|
||||
79
clients/cpp/include/clawdforge/error.hpp
Normal file
79
clients/cpp/include/clawdforge/error.hpp
Normal 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
|
||||
203
clients/cpp/include/clawdforge/types.hpp
Normal file
203
clients/cpp/include/clawdforge/types.hpp
Normal 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
244
clients/cpp/src/client.cpp
Normal 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
326
clients/cpp/src/http.cpp
Normal 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
112
clients/cpp/src/http.hpp
Normal 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
|
||||
335
clients/cpp/tests/test_client.cpp
Normal file
335
clients/cpp/tests/test_client.cpp
Normal 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);
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue