clients/c: initial C SDK for clawdforge

Synchronous client over libcurl + vendored cJSON. Single public
header (include/clawdforge.h) with an opaque cf_client_t and the
full surface: /healthz, /run, /files, /admin/tokens.

- C11, no GNU extensions; -Wall -Wextra -Wpedantic clean
- Hidden visibility on the shared lib + CF_API export attribute
- Static + shared lib via CMake; relocatable pkg-config (${pcfiledir})
- Errors via out-param cf_error_t; every output struct has a _free()
- Multipart upload streams from disk via curl_mime_filedata
- 15 in-process socket-loop tests; valgrind + ASan clean
This commit is contained in:
Kayos 2026-04-28 23:00:59 -07:00
parent 09aca5813a
commit a69e924592
14 changed files with 6113 additions and 0 deletions

4
.gitignore vendored
View file

@ -20,3 +20,7 @@ clients/rust/Cargo.lock
# Java
clients/java/target/
# C SDK
clients/c/build/
clients/c/build-*/

149
clients/c/CMakeLists.txt Normal file
View file

@ -0,0 +1,149 @@
# clawdforge — C SDK
#
# MIT License
#
# Copyright (c) 2026 clawdforge contributors
#
# Permission is hereby granted, free of charge, to any person obtaining a copy
# of this software and associated documentation files (the "Software"), to deal
# in the Software without restriction, including without limitation the rights
# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
# copies of the Software, and to permit persons to whom the Software is
# furnished to do so, subject to the following conditions:
#
# The above copyright notice and this permission notice shall be included in
# all copies or substantial portions of the Software.
#
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
# THE SOFTWARE.
cmake_minimum_required(VERSION 3.16)
project(clawdforge
VERSION 0.1.0
DESCRIPTION "C SDK for the clawdforge HTTP service"
LANGUAGES C)
option(CLAWDFORGE_BUILD_TESTS "Build the test suite" ON)
option(CLAWDFORGE_BUILD_EXAMPLES "Build example programs" ON)
option(CLAWDFORGE_ASAN "Build with AddressSanitizer" OFF)
set(CMAKE_C_STANDARD 11)
set(CMAKE_C_STANDARD_REQUIRED ON)
set(CMAKE_C_EXTENSIONS OFF)
include(GNUInstallDirs)
# ----------------------------------------------------------------------------
# Dependencies
# ----------------------------------------------------------------------------
find_package(CURL REQUIRED)
# Common warning flags. Tests bump to -Werror.
set(CF_WARN_FLAGS -Wall -Wextra -Wpedantic)
if(CLAWDFORGE_ASAN)
list(APPEND CF_WARN_FLAGS -fsanitize=address -fno-omit-frame-pointer -g)
endif()
# ----------------------------------------------------------------------------
# Sources
# ----------------------------------------------------------------------------
set(CF_SOURCES
src/client.c
src/http.c
src/json.c
src/cjson/cJSON.c)
# ----------------------------------------------------------------------------
# Static library
# ----------------------------------------------------------------------------
add_library(clawdforge_static STATIC ${CF_SOURCES})
set_target_properties(clawdforge_static PROPERTIES
OUTPUT_NAME clawdforge
POSITION_INDEPENDENT_CODE ON)
target_include_directories(clawdforge_static
PUBLIC
$<BUILD_INTERFACE:${CMAKE_CURRENT_SOURCE_DIR}/include>
$<INSTALL_INTERFACE:${CMAKE_INSTALL_INCLUDEDIR}>
PRIVATE
${CMAKE_CURRENT_SOURCE_DIR}/src
${CMAKE_CURRENT_SOURCE_DIR}/src/cjson)
target_link_libraries(clawdforge_static PUBLIC CURL::libcurl)
target_compile_options(clawdforge_static PRIVATE ${CF_WARN_FLAGS})
target_compile_definitions(clawdforge_static PRIVATE CLAWDFORGE_BUILDING_LIB)
if(CLAWDFORGE_ASAN)
target_link_options(clawdforge_static PUBLIC -fsanitize=address)
endif()
# ----------------------------------------------------------------------------
# Shared library
# ----------------------------------------------------------------------------
add_library(clawdforge_shared SHARED ${CF_SOURCES})
set_target_properties(clawdforge_shared PROPERTIES
OUTPUT_NAME clawdforge
VERSION ${PROJECT_VERSION}
SOVERSION ${PROJECT_VERSION_MAJOR}
POSITION_INDEPENDENT_CODE ON
C_VISIBILITY_PRESET hidden)
target_include_directories(clawdforge_shared
PUBLIC
$<BUILD_INTERFACE:${CMAKE_CURRENT_SOURCE_DIR}/include>
$<INSTALL_INTERFACE:${CMAKE_INSTALL_INCLUDEDIR}>
PRIVATE
${CMAKE_CURRENT_SOURCE_DIR}/src
${CMAKE_CURRENT_SOURCE_DIR}/src/cjson)
target_link_libraries(clawdforge_shared PUBLIC CURL::libcurl)
target_compile_options(clawdforge_shared PRIVATE ${CF_WARN_FLAGS})
target_compile_definitions(clawdforge_shared PRIVATE CLAWDFORGE_BUILDING_LIB)
if(CLAWDFORGE_ASAN)
target_link_options(clawdforge_shared PUBLIC -fsanitize=address)
endif()
# Convenience alias used by examples + tests.
add_library(clawdforge::clawdforge ALIAS clawdforge_static)
# ----------------------------------------------------------------------------
# Install + pkg-config
# ----------------------------------------------------------------------------
install(TARGETS clawdforge_static clawdforge_shared
EXPORT clawdforgeTargets
ARCHIVE DESTINATION ${CMAKE_INSTALL_LIBDIR}
LIBRARY DESTINATION ${CMAKE_INSTALL_LIBDIR}
RUNTIME DESTINATION ${CMAKE_INSTALL_BINDIR})
install(FILES include/clawdforge.h
DESTINATION ${CMAKE_INSTALL_INCLUDEDIR})
configure_file(pkgconfig/clawdforge.pc.in
${CMAKE_CURRENT_BINARY_DIR}/clawdforge.pc @ONLY)
install(FILES ${CMAKE_CURRENT_BINARY_DIR}/clawdforge.pc
DESTINATION ${CMAKE_INSTALL_LIBDIR}/pkgconfig)
# ----------------------------------------------------------------------------
# Examples
# ----------------------------------------------------------------------------
if(CLAWDFORGE_BUILD_EXAMPLES)
add_executable(clawdforge_example_basic examples/basic.c)
target_link_libraries(clawdforge_example_basic PRIVATE clawdforge_static)
target_compile_options(clawdforge_example_basic PRIVATE ${CF_WARN_FLAGS})
endif()
# ----------------------------------------------------------------------------
# Tests
# ----------------------------------------------------------------------------
if(CLAWDFORGE_BUILD_TESTS)
enable_testing()
add_executable(clawdforge_tests tests/test_client.c)
target_link_libraries(clawdforge_tests PRIVATE clawdforge_static)
target_include_directories(clawdforge_tests PRIVATE
${CMAKE_CURRENT_SOURCE_DIR}/src/cjson)
target_compile_options(clawdforge_tests PRIVATE
${CF_WARN_FLAGS} -Werror)
add_test(NAME clawdforge_tests COMMAND clawdforge_tests)
set_tests_properties(clawdforge_tests PROPERTIES TIMEOUT 60)
endif()

255
clients/c/README.md Normal file
View file

@ -0,0 +1,255 @@
# clawdforge — C SDK
A small synchronous C client for the [clawdforge](../../README.md) HTTP service.
clawdforge wraps `claude -p` subprocess calls behind a bearer-token-gated REST
API; this SDK is what you reach for from C, or what you FFI into from any
language with a C ABI.
- **C99 minimum, C11 preferred.** No GNU extensions in the public surface.
- **Dependencies:** [libcurl](https://curl.se/libcurl/) (system) + [cJSON](https://github.com/DaveGamble/cJSON) (vendored under `src/cjson/`).
- **Errors are out-params**, not globals. Library functions never write to `stderr`.
- **All allocations are caller-aware** — every output struct ships with a `_free()`.
- **Thread-safety:** one `cf_client_t` per thread. They are not internally locked.
- License: MIT (see `CMakeLists.txt`).
## Install
Build deps on Debian/Ubuntu:
```
sudo apt install build-essential cmake libcurl4-openssl-dev pkg-config
```
Then:
```
git clone <gitea-url>/Sulkta-Coop/clawdforge.git
cd clawdforge/clients/c
cmake -S . -B build
cmake --build build -j
sudo cmake --install build --prefix /usr/local
```
This installs:
- `libclawdforge.a` and `libclawdforge.so.0.1.0` into `${prefix}/lib`
- `clawdforge.h` into `${prefix}/include`
- `clawdforge.pc` into `${prefix}/lib/pkgconfig`
## Use it from your build
### pkg-config
```
gcc app.c $(pkg-config --cflags --libs clawdforge) -o app
```
### CMake
If you installed to a custom prefix, point `CMAKE_PREFIX_PATH` at it. Then:
```cmake
find_package(PkgConfig REQUIRED)
pkg_check_modules(CLAWDFORGE REQUIRED IMPORTED_TARGET clawdforge)
target_link_libraries(my_app PRIVATE PkgConfig::CLAWDFORGE)
```
## Quickstart
```c
#include <stdio.h>
#include <clawdforge.h>
int main(void) {
cf_client_t *c = cf_client_new("http://192.168.0.5:8800", "cf_xxx");
if (!c) return 1;
cf_error_t err = {0};
/* Health check */
cf_healthz_t h = {0};
if (cf_healthz(c, &h, &err) != CF_OK) {
fprintf(stderr, "%s\n", cf_error_message(&err));
cf_error_free(&err);
cf_client_free(c);
return 1;
}
printf("claude: %s\n", h.claude_version);
cf_healthz_free(&h);
/* Run a prompt */
cf_run_request_t req = {
.prompt = "Reply with JSON: {\"hello\":\"world\"}",
.model = "sonnet",
.timeout_secs = 60,
};
cf_run_result_t res = {0};
if (cf_run(c, &req, &res, &err) != CF_OK) {
fprintf(stderr, "%s\n", cf_error_message(&err));
cf_error_free(&err);
cf_client_free(c);
return 1;
}
printf("%s\n", res.result_json); /* {"hello":"world"} */
printf("%ld ms\n", res.duration_ms);
cf_run_result_free(&res);
cf_client_free(c);
return 0;
}
```
## Memory rules
The library never holds onto pointers past the call boundary. The pattern is:
1. **You** pass `const char *` strings in. The library copies what it needs.
2. **The library** populates output structs. Their string members are heap
allocated and owned by the caller.
3. **You** call the matching `*_free()` to release them.
```c
cf_run_result_t res = {0}; /* zero-initialise */
cf_run(c, &req, &res, &err); /* fills it */
/* ... use res.result_json ... */
cf_run_result_free(&res); /* releases it */
```
The `cf_error_t` follows the same pattern: zero-initialise on the stack,
pass by address, free on failure. Do not call `cf_error_free()` after a
successful call (the struct was not touched).
## Status codes
```c
typedef enum cf_status {
CF_OK = 0,
CF_ERR_AUTH = 1, /* 401 / 403 */
CF_ERR_API = 2, /* server returned a structured error envelope */
CF_ERR_TRANSPORT = 3, /* network / TLS / connection */
CF_ERR_USAGE = 4, /* caller passed a bad argument */
CF_ERR_OOM = 5, /* out of memory */
CF_ERR_PARSE = 6 /* server response was not the expected JSON shape */
} cf_status_t;
```
Use `cf_status_str(code)` for a human label.
## API reference
### Lifecycle
| Function | Notes |
| --- | --- |
| `cf_client_t *cf_client_new(const char *base_url, const char *token)` | Allocates a client. `token` may be `NULL` if you only intend to use admin endpoints. Trailing slashes on `base_url` are stripped. |
| `cf_status_t cf_client_set_admin_token(cf_client_t *c, const char *admin_token)` | Required before any `cf_admin_*` call. |
| `void cf_client_set_timeout_secs(cf_client_t *c, long timeout_secs)` | Default 600 seconds. |
| `void cf_client_free(cf_client_t *c)` | Releases. |
### `/healthz`
```c
cf_status_t cf_healthz(cf_client_t *c, cf_healthz_t *out, cf_error_t *err);
void cf_healthz_free(cf_healthz_t *h);
```
`cf_healthz_t` carries `ok`, `claude_present`, and `claude_version` (heap str).
### `/run`
```c
cf_status_t cf_run(cf_client_t *c,
const cf_run_request_t *req,
cf_run_result_t *out,
cf_error_t *err);
void cf_run_result_free(cf_run_result_t *r);
void *cf_run_result_as_cjson(const cf_run_result_t *r);
```
`cf_run_result_t::result_json` is the **inner** `result` field re-serialised
back to a JSON string. If the server's `result` was a string, this is a
quoted JSON string (e.g. `"\"hello\""`). If the server returned an object,
this is the object's JSON. `result_is_string` distinguishes the two.
`cf_run_result_as_cjson()` returns a freshly-parsed `cJSON *` (cast it).
The caller must `cJSON_Delete()` the returned node. The header returns
`void *` so consumers don't need cJSON visible.
### `/files`
```c
cf_status_t cf_upload_file(cf_client_t *c,
const char *path,
long ttl_secs, /* 0 or 60..86400 */
cf_file_token_t *out,
cf_error_t *err);
void cf_file_token_free(cf_file_token_t *ft);
```
Uploads stream from disk via `curl_mime_filedata` — files are NOT loaded
into memory.
### `/admin/tokens`
```c
cf_status_t cf_admin_create_token(cf_client_t *c,
const char *name,
const char *const *ip_cidrs,
size_t ip_cidrs_count,
cf_admin_token_t *out,
cf_error_t *err);
cf_status_t cf_admin_list_tokens(cf_client_t *c,
cf_admin_token_list_t *out,
cf_error_t *err);
cf_status_t cf_admin_revoke_token(cf_client_t *c,
const char *name,
cf_error_t *err);
```
All require an admin token via `cf_client_set_admin_token` first.
## Build options
| Option | Default | What it does |
| --- | --- | --- |
| `-DCLAWDFORGE_BUILD_TESTS=ON/OFF` | `ON` | Build `clawdforge_tests`. |
| `-DCLAWDFORGE_BUILD_EXAMPLES=ON/OFF` | `ON` | Build `clawdforge_example_basic`. |
| `-DCLAWDFORGE_ASAN=ON/OFF` | `OFF` | Build the lib + tests with `-fsanitize=address`. |
## Tests
```
cmake -S . -B build
cmake --build build -j
cd build && ctest --output-on-failure
```
The test suite spins up an in-process HTTP server on a loopback random port
and exercises the wire surface: URL construction, auth header injection,
request/response JSON shapes, error envelopes, and multipart upload.
To run under AddressSanitizer:
```
cmake -S . -B build-asan -DCLAWDFORGE_ASAN=ON
cmake --build build-asan -j
cd build-asan && ctest --output-on-failure
```
To run under valgrind:
```
valgrind --leak-check=full --error-exitcode=1 ./build/clawdforge_tests
```
## Threading
`cf_client_t` is **not** safe to share across threads. Each thread should
own its own client (or guard one with your own mutex). Internally, every
call constructs and tears down its own libcurl easy handle, so adding a
shared connection pool is a future enhancement that won't change the API.
## Vendored cJSON
`src/cjson/cJSON.{c,h}` are vendored from cJSON v1.7.15 (MIT). The license
file lives next to them.

View file

@ -0,0 +1,69 @@
/* Basic clawdforge client example.
*
* Build: see clients/c/README.md
* Run: CLAWDFORGE_URL=http://localhost:8800 \
* CLAWDFORGE_TOKEN=cf_xxx \
* ./clawdforge_example_basic
*/
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <clawdforge.h>
int main(int argc, char **argv) {
(void)argc; (void)argv;
const char *base = getenv("CLAWDFORGE_URL");
const char *tok = getenv("CLAWDFORGE_TOKEN");
if (!base) base = "http://localhost:8800";
if (!tok) tok = "";
cf_client_t *c = cf_client_new(base, tok);
if (!c) {
fprintf(stderr, "cf_client_new failed (oom or bad url)\n");
return 1;
}
cf_error_t err = {0};
/* Health check (no auth required). */
cf_healthz_t h = {0};
if (cf_healthz(c, &h, &err) != CF_OK) {
fprintf(stderr, "healthz: %s\n", cf_error_message(&err));
cf_error_free(&err);
cf_client_free(c);
return 1;
}
printf("ok=%d claude_present=%d version=%s\n",
h.ok, h.claude_present, h.claude_version ? h.claude_version : "(none)");
cf_healthz_free(&h);
/* Skip the /run round-trip if there's no token. */
if (!tok || !*tok) {
printf("(no CLAWDFORGE_TOKEN; skipping /run)\n");
cf_client_free(c);
return 0;
}
cf_run_request_t req = {
.prompt = "Reply with JSON: {\"hello\":\"world\"}",
.model = "sonnet",
.timeout_secs = 60,
};
cf_run_result_t res = {0};
if (cf_run(c, &req, &res, &err) != CF_OK) {
fprintf(stderr, "run: %s\n", cf_error_message(&err));
cf_error_free(&err);
cf_client_free(c);
return 1;
}
printf("result_json: %s\n", res.result_json ? res.result_json : "(null)");
printf("duration_ms: %ld\n", res.duration_ms);
if (res.stop_reason) printf("stop_reason: %s\n", res.stop_reason);
cf_run_result_free(&res);
cf_client_free(c);
return 0;
}

View file

@ -0,0 +1,282 @@
/*
* clawdforge C SDK
*
* MIT License see CMakeLists.txt for the full text.
*
* A small synchronous C client for the clawdforge HTTP service.
* The service wraps `claude -p` subprocess calls behind a
* bearer-token-gated REST API.
*
* Single public header. The client struct (cf_client_t) is opaque;
* callers only ever see a forward-declared typedef and a set of
* functions that take it.
*
* Threading: cf_client_t is NOT shared across threads. Create one
* client per thread (or guard one client with your own mutex).
*
* Errors are reported through a caller-provided cf_error_t out
* parameter never through stderr or thread-local globals. Free
* any populated cf_error_t with cf_error_free().
*
* Every output struct has a matching _free() that releases its
* heap-allocated members. Stack-allocate the struct, pass its
* address in, free it when done.
*/
#ifndef CLAWDFORGE_H
#define CLAWDFORGE_H
#include <stddef.h>
#ifdef __cplusplus
extern "C" {
#endif
/* Visibility / export attribute. The shared library is built with
* -fvisibility=hidden by default; CF_API marks the public surface so
* those symbols stay visible. The static library and consumers use
* the empty default. */
#if defined(_WIN32) || defined(__CYGWIN__)
# if defined(CLAWDFORGE_BUILDING_DLL)
# define CF_API __declspec(dllexport)
# elif defined(CLAWDFORGE_USING_DLL)
# define CF_API __declspec(dllimport)
# else
# define CF_API
# endif
#else
# if defined(CLAWDFORGE_BUILDING_LIB) && defined(__GNUC__)
# define CF_API __attribute__((visibility("default")))
# else
# define CF_API
# endif
#endif
/* ------------------------------------------------------------------ */
/* Versioning */
/* ------------------------------------------------------------------ */
#define CLAWDFORGE_VERSION_MAJOR 0
#define CLAWDFORGE_VERSION_MINOR 1
#define CLAWDFORGE_VERSION_PATCH 0
#define CLAWDFORGE_VERSION_STRING "0.1.0"
/* Returns the runtime library version string ("X.Y.Z"). */
CF_API const char *cf_version(void);
/* ------------------------------------------------------------------ */
/* Status codes */
/* ------------------------------------------------------------------ */
typedef enum cf_status {
CF_OK = 0, /* success */
CF_ERR_AUTH = 1, /* 401 / 403 from server, or missing token */
CF_ERR_API = 2, /* server returned a structured error envelope */
CF_ERR_TRANSPORT = 3, /* network / TLS / connection */
CF_ERR_USAGE = 4, /* caller passed a bad argument */
CF_ERR_OOM = 5, /* malloc / calloc returned NULL */
CF_ERR_PARSE = 6 /* server response was not the expected JSON shape */
} cf_status_t;
/* ------------------------------------------------------------------ */
/* Error type */
/* ------------------------------------------------------------------ */
/*
* cf_error_t is filled in by every fallible function on failure.
* On CF_OK the contents are left untouched do not call cf_error_free
* if the call returned CF_OK.
*
* Recommended pattern:
*
* cf_error_t err = {0};
* if (cf_run(c, &req, &res, &err) != CF_OK) {
* fprintf(stderr, "%s\n", cf_error_message(&err));
* cf_error_free(&err);
* return 1;
* }
*/
typedef struct cf_error {
cf_status_t code;
long http_status; /* HTTP status code if applicable, else 0 */
char *message; /* heap-allocated, NUL-terminated */
} cf_error_t;
CF_API const char *cf_error_message(const cf_error_t *err);
CF_API void cf_error_free(cf_error_t *err);
/* Static, never-NULL human label for a status code (e.g. "CF_OK"). */
CF_API const char *cf_status_str(cf_status_t code);
/* ------------------------------------------------------------------ */
/* Client */
/* ------------------------------------------------------------------ */
/* Opaque. */
typedef struct cf_client cf_client_t;
/*
* Construct a new client.
*
* base_url: e.g. "http://192.168.0.5:8800". A trailing slash is
* tolerated. Required.
* token: bearer token for /run + /files. May be NULL if the
* caller only intends to use admin endpoints with a
* separately-provided admin token (see cf_client_set_admin_token).
*
* Returns NULL on allocation failure. cf_client_free() releases.
*/
CF_API cf_client_t *cf_client_new(const char *base_url, const char *token);
/* Optional: install or replace the admin bootstrap token used for
* the /admin endpoints. Pass NULL to clear. */
CF_API cf_status_t cf_client_set_admin_token(cf_client_t *c, const char *admin_token);
/* Optional: set the libcurl connection + request timeout in seconds.
* Default is 600 seconds. Setting <= 0 leaves the current value. */
CF_API void cf_client_set_timeout_secs(cf_client_t *c, long timeout_secs);
CF_API void cf_client_free(cf_client_t *c);
/* ------------------------------------------------------------------ */
/* /healthz */
/* ------------------------------------------------------------------ */
typedef struct cf_healthz {
int ok; /* "ok" field */
int claude_present; /* "claude_present" field */
char *claude_version; /* "claude_version" or NULL */
} cf_healthz_t;
CF_API cf_status_t cf_healthz(cf_client_t *c, cf_healthz_t *out, cf_error_t *err);
CF_API void cf_healthz_free(cf_healthz_t *h);
/* ------------------------------------------------------------------ */
/* /run */
/* ------------------------------------------------------------------ */
/*
* Request struct. Initialise with `cf_run_request_t r = {0};` and
* populate the fields you need. Strings are caller-owned; clawdforge
* copies them as needed.
*/
typedef struct cf_run_request {
const char *prompt; /* required, non-empty */
const char *model; /* optional, NULL = server default */
const char *system; /* optional system prompt */
const char *const *files; /* array of file_token strings */
size_t files_count;
int timeout_secs; /* 0 = server default; else 5..600 */
} cf_run_request_t;
/*
* Response from a successful /run call.
*
* result_json: the inner "result" field re-serialised back to a
* JSON string. Always non-NULL on CF_OK. If the server
* returned a string, this is a JSON-encoded string
* (including the surrounding quotes); if it returned
* an object, this is the object as JSON.
*
* result_is_string: 1 iff the server's "result" was a JSON string;
* 0 if it was an object/array/number/bool/null.
*
* stop_reason: e.g. "end_turn"; may be NULL.
*
* duration_ms: server-reported duration in ms.
*/
typedef struct cf_run_result {
char *result_json;
int result_is_string;
char *stop_reason;
long duration_ms;
} cf_run_result_t;
CF_API cf_status_t cf_run(cf_client_t *c,
const cf_run_request_t *req,
cf_run_result_t *out,
cf_error_t *err);
CF_API void cf_run_result_free(cf_run_result_t *r);
/*
* Convenience: parse out->result_json with cJSON. Returns NULL if
* the result was not valid JSON (shouldn't happen in normal use,
* but defensive). The caller must cJSON_Delete() the returned node.
*
* The return type is `void *` so the public header does not have
* to expose the cJSON struct. Callers including cJSON.h can cast
* directly to `cJSON *`.
*/
CF_API void *cf_run_result_as_cjson(const cf_run_result_t *r);
/* ------------------------------------------------------------------ */
/* /files */
/* ------------------------------------------------------------------ */
typedef struct cf_file_token {
char *file_token; /* "ff_..." */
long ttl_secs;
long size;
} cf_file_token_t;
/*
* Upload a file from disk via multipart/form-data. The file is
* streamed (not buffered into memory).
*
* path: absolute or relative filesystem path; must be readable.
* ttl_secs: 60..86400; 0 selects the server default (3600).
*/
CF_API cf_status_t cf_upload_file(cf_client_t *c,
const char *path,
long ttl_secs,
cf_file_token_t *out,
cf_error_t *err);
CF_API void cf_file_token_free(cf_file_token_t *ft);
/* ------------------------------------------------------------------ */
/* /admin/tokens */
/* ------------------------------------------------------------------ */
typedef struct cf_admin_token {
char *name;
char *token; /* plaintext, only present on create */
char **ip_cidrs;
size_t ip_cidrs_count;
} cf_admin_token_t;
typedef struct cf_admin_token_info {
char *name;
char **ip_cidrs;
size_t ip_cidrs_count;
long created_at; /* UNIX seconds, 0 if absent */
long last_used_at; /* UNIX seconds, 0 if absent */
} cf_admin_token_info_t;
typedef struct cf_admin_token_list {
cf_admin_token_info_t *items;
size_t count;
} cf_admin_token_list_t;
/* Mint a new app token. ip_cidrs/ip_cidrs_count may be 0 to omit. */
CF_API cf_status_t cf_admin_create_token(cf_client_t *c,
const char *name,
const char *const *ip_cidrs,
size_t ip_cidrs_count,
cf_admin_token_t *out,
cf_error_t *err);
CF_API void cf_admin_token_free(cf_admin_token_t *t);
CF_API cf_status_t cf_admin_list_tokens(cf_client_t *c,
cf_admin_token_list_t *out,
cf_error_t *err);
CF_API void cf_admin_token_list_free(cf_admin_token_list_t *l);
CF_API cf_status_t cf_admin_revoke_token(cf_client_t *c,
const char *name,
cf_error_t *err);
#ifdef __cplusplus
} /* extern "C" */
#endif
#endif /* CLAWDFORGE_H */

View file

@ -0,0 +1,15 @@
# Resolved at pkg-config invocation time. ${pcfiledir} is a pkgconf
# extension supported by both pkgconf and pkg-config — it gives the
# absolute directory that contains this .pc file. Using it lets the
# installed package work from any --prefix without re-running cmake.
prefix=${pcfiledir}/../..
exec_prefix=${prefix}
libdir=${prefix}/@CMAKE_INSTALL_LIBDIR@
includedir=${prefix}/@CMAKE_INSTALL_INCLUDEDIR@
Name: clawdforge
Description: C SDK for the clawdforge HTTP service
Version: @PROJECT_VERSION@
Requires: libcurl
Libs: -L${libdir} -lclawdforge
Cflags: -I${includedir}

View file

@ -0,0 +1,20 @@
Copyright (c) 2009-2017 Dave Gamble and cJSON contributors
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in
all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
THE SOFTWARE.

3110
clients/c/src/cjson/cJSON.c Normal file

File diff suppressed because it is too large Load diff

293
clients/c/src/cjson/cJSON.h Normal file
View file

@ -0,0 +1,293 @@
/*
Copyright (c) 2009-2017 Dave Gamble and cJSON contributors
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in
all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
THE SOFTWARE.
*/
#ifndef cJSON__h
#define cJSON__h
#ifdef __cplusplus
extern "C"
{
#endif
#if !defined(__WINDOWS__) && (defined(WIN32) || defined(WIN64) || defined(_MSC_VER) || defined(_WIN32))
#define __WINDOWS__
#endif
#ifdef __WINDOWS__
/* When compiling for windows, we specify a specific calling convention to avoid issues where we are being called from a project with a different default calling convention. For windows you have 3 define options:
CJSON_HIDE_SYMBOLS - Define this in the case where you don't want to ever dllexport symbols
CJSON_EXPORT_SYMBOLS - Define this on library build when you want to dllexport symbols (default)
CJSON_IMPORT_SYMBOLS - Define this if you want to dllimport symbol
For *nix builds that support visibility attribute, you can define similar behavior by
setting default visibility to hidden by adding
-fvisibility=hidden (for gcc)
or
-xldscope=hidden (for sun cc)
to CFLAGS
then using the CJSON_API_VISIBILITY flag to "export" the same symbols the way CJSON_EXPORT_SYMBOLS does
*/
#define CJSON_CDECL __cdecl
#define CJSON_STDCALL __stdcall
/* export symbols by default, this is necessary for copy pasting the C and header file */
#if !defined(CJSON_HIDE_SYMBOLS) && !defined(CJSON_IMPORT_SYMBOLS) && !defined(CJSON_EXPORT_SYMBOLS)
#define CJSON_EXPORT_SYMBOLS
#endif
#if defined(CJSON_HIDE_SYMBOLS)
#define CJSON_PUBLIC(type) type CJSON_STDCALL
#elif defined(CJSON_EXPORT_SYMBOLS)
#define CJSON_PUBLIC(type) __declspec(dllexport) type CJSON_STDCALL
#elif defined(CJSON_IMPORT_SYMBOLS)
#define CJSON_PUBLIC(type) __declspec(dllimport) type CJSON_STDCALL
#endif
#else /* !__WINDOWS__ */
#define CJSON_CDECL
#define CJSON_STDCALL
#if (defined(__GNUC__) || defined(__SUNPRO_CC) || defined (__SUNPRO_C)) && defined(CJSON_API_VISIBILITY)
#define CJSON_PUBLIC(type) __attribute__((visibility("default"))) type
#else
#define CJSON_PUBLIC(type) type
#endif
#endif
/* project version */
#define CJSON_VERSION_MAJOR 1
#define CJSON_VERSION_MINOR 7
#define CJSON_VERSION_PATCH 15
#include <stddef.h>
/* cJSON Types: */
#define cJSON_Invalid (0)
#define cJSON_False (1 << 0)
#define cJSON_True (1 << 1)
#define cJSON_NULL (1 << 2)
#define cJSON_Number (1 << 3)
#define cJSON_String (1 << 4)
#define cJSON_Array (1 << 5)
#define cJSON_Object (1 << 6)
#define cJSON_Raw (1 << 7) /* raw json */
#define cJSON_IsReference 256
#define cJSON_StringIsConst 512
/* The cJSON structure: */
typedef struct cJSON
{
/* next/prev allow you to walk array/object chains. Alternatively, use GetArraySize/GetArrayItem/GetObjectItem */
struct cJSON *next;
struct cJSON *prev;
/* An array or object item will have a child pointer pointing to a chain of the items in the array/object. */
struct cJSON *child;
/* The type of the item, as above. */
int type;
/* The item's string, if type==cJSON_String and type == cJSON_Raw */
char *valuestring;
/* writing to valueint is DEPRECATED, use cJSON_SetNumberValue instead */
int valueint;
/* The item's number, if type==cJSON_Number */
double valuedouble;
/* The item's name string, if this item is the child of, or is in the list of subitems of an object. */
char *string;
} cJSON;
typedef struct cJSON_Hooks
{
/* malloc/free are CDECL on Windows regardless of the default calling convention of the compiler, so ensure the hooks allow passing those functions directly. */
void *(CJSON_CDECL *malloc_fn)(size_t sz);
void (CJSON_CDECL *free_fn)(void *ptr);
} cJSON_Hooks;
typedef int cJSON_bool;
/* Limits how deeply nested arrays/objects can be before cJSON rejects to parse them.
* This is to prevent stack overflows. */
#ifndef CJSON_NESTING_LIMIT
#define CJSON_NESTING_LIMIT 1000
#endif
/* returns the version of cJSON as a string */
CJSON_PUBLIC(const char*) cJSON_Version(void);
/* Supply malloc, realloc and free functions to cJSON */
CJSON_PUBLIC(void) cJSON_InitHooks(cJSON_Hooks* hooks);
/* Memory Management: the caller is always responsible to free the results from all variants of cJSON_Parse (with cJSON_Delete) and cJSON_Print (with stdlib free, cJSON_Hooks.free_fn, or cJSON_free as appropriate). The exception is cJSON_PrintPreallocated, where the caller has full responsibility of the buffer. */
/* Supply a block of JSON, and this returns a cJSON object you can interrogate. */
CJSON_PUBLIC(cJSON *) cJSON_Parse(const char *value);
CJSON_PUBLIC(cJSON *) cJSON_ParseWithLength(const char *value, size_t buffer_length);
/* ParseWithOpts allows you to require (and check) that the JSON is null terminated, and to retrieve the pointer to the final byte parsed. */
/* If you supply a ptr in return_parse_end and parsing fails, then return_parse_end will contain a pointer to the error so will match cJSON_GetErrorPtr(). */
CJSON_PUBLIC(cJSON *) cJSON_ParseWithOpts(const char *value, const char **return_parse_end, cJSON_bool require_null_terminated);
CJSON_PUBLIC(cJSON *) cJSON_ParseWithLengthOpts(const char *value, size_t buffer_length, const char **return_parse_end, cJSON_bool require_null_terminated);
/* Render a cJSON entity to text for transfer/storage. */
CJSON_PUBLIC(char *) cJSON_Print(const cJSON *item);
/* Render a cJSON entity to text for transfer/storage without any formatting. */
CJSON_PUBLIC(char *) cJSON_PrintUnformatted(const cJSON *item);
/* Render a cJSON entity to text using a buffered strategy. prebuffer is a guess at the final size. guessing well reduces reallocation. fmt=0 gives unformatted, =1 gives formatted */
CJSON_PUBLIC(char *) cJSON_PrintBuffered(const cJSON *item, int prebuffer, cJSON_bool fmt);
/* Render a cJSON entity to text using a buffer already allocated in memory with given length. Returns 1 on success and 0 on failure. */
/* NOTE: cJSON is not always 100% accurate in estimating how much memory it will use, so to be safe allocate 5 bytes more than you actually need */
CJSON_PUBLIC(cJSON_bool) cJSON_PrintPreallocated(cJSON *item, char *buffer, const int length, const cJSON_bool format);
/* Delete a cJSON entity and all subentities. */
CJSON_PUBLIC(void) cJSON_Delete(cJSON *item);
/* Returns the number of items in an array (or object). */
CJSON_PUBLIC(int) cJSON_GetArraySize(const cJSON *array);
/* Retrieve item number "index" from array "array". Returns NULL if unsuccessful. */
CJSON_PUBLIC(cJSON *) cJSON_GetArrayItem(const cJSON *array, int index);
/* Get item "string" from object. Case insensitive. */
CJSON_PUBLIC(cJSON *) cJSON_GetObjectItem(const cJSON * const object, const char * const string);
CJSON_PUBLIC(cJSON *) cJSON_GetObjectItemCaseSensitive(const cJSON * const object, const char * const string);
CJSON_PUBLIC(cJSON_bool) cJSON_HasObjectItem(const cJSON *object, const char *string);
/* For analysing failed parses. This returns a pointer to the parse error. You'll probably need to look a few chars back to make sense of it. Defined when cJSON_Parse() returns 0. 0 when cJSON_Parse() succeeds. */
CJSON_PUBLIC(const char *) cJSON_GetErrorPtr(void);
/* Check item type and return its value */
CJSON_PUBLIC(char *) cJSON_GetStringValue(const cJSON * const item);
CJSON_PUBLIC(double) cJSON_GetNumberValue(const cJSON * const item);
/* These functions check the type of an item */
CJSON_PUBLIC(cJSON_bool) cJSON_IsInvalid(const cJSON * const item);
CJSON_PUBLIC(cJSON_bool) cJSON_IsFalse(const cJSON * const item);
CJSON_PUBLIC(cJSON_bool) cJSON_IsTrue(const cJSON * const item);
CJSON_PUBLIC(cJSON_bool) cJSON_IsBool(const cJSON * const item);
CJSON_PUBLIC(cJSON_bool) cJSON_IsNull(const cJSON * const item);
CJSON_PUBLIC(cJSON_bool) cJSON_IsNumber(const cJSON * const item);
CJSON_PUBLIC(cJSON_bool) cJSON_IsString(const cJSON * const item);
CJSON_PUBLIC(cJSON_bool) cJSON_IsArray(const cJSON * const item);
CJSON_PUBLIC(cJSON_bool) cJSON_IsObject(const cJSON * const item);
CJSON_PUBLIC(cJSON_bool) cJSON_IsRaw(const cJSON * const item);
/* These calls create a cJSON item of the appropriate type. */
CJSON_PUBLIC(cJSON *) cJSON_CreateNull(void);
CJSON_PUBLIC(cJSON *) cJSON_CreateTrue(void);
CJSON_PUBLIC(cJSON *) cJSON_CreateFalse(void);
CJSON_PUBLIC(cJSON *) cJSON_CreateBool(cJSON_bool boolean);
CJSON_PUBLIC(cJSON *) cJSON_CreateNumber(double num);
CJSON_PUBLIC(cJSON *) cJSON_CreateString(const char *string);
/* raw json */
CJSON_PUBLIC(cJSON *) cJSON_CreateRaw(const char *raw);
CJSON_PUBLIC(cJSON *) cJSON_CreateArray(void);
CJSON_PUBLIC(cJSON *) cJSON_CreateObject(void);
/* Create a string where valuestring references a string so
* it will not be freed by cJSON_Delete */
CJSON_PUBLIC(cJSON *) cJSON_CreateStringReference(const char *string);
/* Create an object/array that only references it's elements so
* they will not be freed by cJSON_Delete */
CJSON_PUBLIC(cJSON *) cJSON_CreateObjectReference(const cJSON *child);
CJSON_PUBLIC(cJSON *) cJSON_CreateArrayReference(const cJSON *child);
/* These utilities create an Array of count items.
* The parameter count cannot be greater than the number of elements in the number array, otherwise array access will be out of bounds.*/
CJSON_PUBLIC(cJSON *) cJSON_CreateIntArray(const int *numbers, int count);
CJSON_PUBLIC(cJSON *) cJSON_CreateFloatArray(const float *numbers, int count);
CJSON_PUBLIC(cJSON *) cJSON_CreateDoubleArray(const double *numbers, int count);
CJSON_PUBLIC(cJSON *) cJSON_CreateStringArray(const char *const *strings, int count);
/* Append item to the specified array/object. */
CJSON_PUBLIC(cJSON_bool) cJSON_AddItemToArray(cJSON *array, cJSON *item);
CJSON_PUBLIC(cJSON_bool) cJSON_AddItemToObject(cJSON *object, const char *string, cJSON *item);
/* Use this when string is definitely const (i.e. a literal, or as good as), and will definitely survive the cJSON object.
* WARNING: When this function was used, make sure to always check that (item->type & cJSON_StringIsConst) is zero before
* writing to `item->string` */
CJSON_PUBLIC(cJSON_bool) cJSON_AddItemToObjectCS(cJSON *object, const char *string, cJSON *item);
/* Append reference to item to the specified array/object. Use this when you want to add an existing cJSON to a new cJSON, but don't want to corrupt your existing cJSON. */
CJSON_PUBLIC(cJSON_bool) cJSON_AddItemReferenceToArray(cJSON *array, cJSON *item);
CJSON_PUBLIC(cJSON_bool) cJSON_AddItemReferenceToObject(cJSON *object, const char *string, cJSON *item);
/* Remove/Detach items from Arrays/Objects. */
CJSON_PUBLIC(cJSON *) cJSON_DetachItemViaPointer(cJSON *parent, cJSON * const item);
CJSON_PUBLIC(cJSON *) cJSON_DetachItemFromArray(cJSON *array, int which);
CJSON_PUBLIC(void) cJSON_DeleteItemFromArray(cJSON *array, int which);
CJSON_PUBLIC(cJSON *) cJSON_DetachItemFromObject(cJSON *object, const char *string);
CJSON_PUBLIC(cJSON *) cJSON_DetachItemFromObjectCaseSensitive(cJSON *object, const char *string);
CJSON_PUBLIC(void) cJSON_DeleteItemFromObject(cJSON *object, const char *string);
CJSON_PUBLIC(void) cJSON_DeleteItemFromObjectCaseSensitive(cJSON *object, const char *string);
/* Update array items. */
CJSON_PUBLIC(cJSON_bool) cJSON_InsertItemInArray(cJSON *array, int which, cJSON *newitem); /* Shifts pre-existing items to the right. */
CJSON_PUBLIC(cJSON_bool) cJSON_ReplaceItemViaPointer(cJSON * const parent, cJSON * const item, cJSON * replacement);
CJSON_PUBLIC(cJSON_bool) cJSON_ReplaceItemInArray(cJSON *array, int which, cJSON *newitem);
CJSON_PUBLIC(cJSON_bool) cJSON_ReplaceItemInObject(cJSON *object,const char *string,cJSON *newitem);
CJSON_PUBLIC(cJSON_bool) cJSON_ReplaceItemInObjectCaseSensitive(cJSON *object,const char *string,cJSON *newitem);
/* Duplicate a cJSON item */
CJSON_PUBLIC(cJSON *) cJSON_Duplicate(const cJSON *item, cJSON_bool recurse);
/* Duplicate will create a new, identical cJSON item to the one you pass, in new memory that will
* need to be released. With recurse!=0, it will duplicate any children connected to the item.
* The item->next and ->prev pointers are always zero on return from Duplicate. */
/* Recursively compare two cJSON items for equality. If either a or b is NULL or invalid, they will be considered unequal.
* case_sensitive determines if object keys are treated case sensitive (1) or case insensitive (0) */
CJSON_PUBLIC(cJSON_bool) cJSON_Compare(const cJSON * const a, const cJSON * const b, const cJSON_bool case_sensitive);
/* Minify a strings, remove blank characters(such as ' ', '\t', '\r', '\n') from strings.
* The input pointer json cannot point to a read-only address area, such as a string constant,
* but should point to a readable and writable address area. */
CJSON_PUBLIC(void) cJSON_Minify(char *json);
/* Helper functions for creating and adding items to an object at the same time.
* They return the added item or NULL on failure. */
CJSON_PUBLIC(cJSON*) cJSON_AddNullToObject(cJSON * const object, const char * const name);
CJSON_PUBLIC(cJSON*) cJSON_AddTrueToObject(cJSON * const object, const char * const name);
CJSON_PUBLIC(cJSON*) cJSON_AddFalseToObject(cJSON * const object, const char * const name);
CJSON_PUBLIC(cJSON*) cJSON_AddBoolToObject(cJSON * const object, const char * const name, const cJSON_bool boolean);
CJSON_PUBLIC(cJSON*) cJSON_AddNumberToObject(cJSON * const object, const char * const name, const double number);
CJSON_PUBLIC(cJSON*) cJSON_AddStringToObject(cJSON * const object, const char * const name, const char * const string);
CJSON_PUBLIC(cJSON*) cJSON_AddRawToObject(cJSON * const object, const char * const name, const char * const raw);
CJSON_PUBLIC(cJSON*) cJSON_AddObjectToObject(cJSON * const object, const char * const name);
CJSON_PUBLIC(cJSON*) cJSON_AddArrayToObject(cJSON * const object, const char * const name);
/* When assigning an integer value, it needs to be propagated to valuedouble too. */
#define cJSON_SetIntValue(object, number) ((object) ? (object)->valueint = (object)->valuedouble = (number) : (number))
/* helper for the cJSON_SetNumberValue macro */
CJSON_PUBLIC(double) cJSON_SetNumberHelper(cJSON *object, double number);
#define cJSON_SetNumberValue(object, number) ((object != NULL) ? cJSON_SetNumberHelper(object, (double)number) : (number))
/* Change the valuestring of a cJSON_String object, only takes effect when type of object is cJSON_String */
CJSON_PUBLIC(char*) cJSON_SetValuestring(cJSON *object, const char *valuestring);
/* Macro for iterating over an array or object */
#define cJSON_ArrayForEach(element, array) for(element = (array != NULL) ? (array)->child : NULL; element != NULL; element = element->next)
/* malloc/free objects using the malloc/free functions that have been set with cJSON_InitHooks */
CJSON_PUBLIC(void *) cJSON_malloc(size_t size);
CJSON_PUBLIC(void) cJSON_free(void *object);
#ifdef __cplusplus
}
#endif
#endif

605
clients/c/src/client.c Normal file
View file

@ -0,0 +1,605 @@
/* Public-facing API: cf_client_*, cf_healthz, cf_run, cf_upload_file,
* cf_admin_*. */
#include "internal.h"
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
/* Forward decl from json.c-side helpers (unused warning suppressed). */
extern const char *cf_version(void);
/* ---------- helpers --------------------------------------------- */
static char *strdup_or_null(const char *s) {
if (!s) return NULL;
size_t n = strlen(s) + 1;
char *p = (char *)malloc(n);
if (!p) return NULL;
memcpy(p, s, n);
return p;
}
static char *strdup_strip_trailing_slash(const char *s) {
if (!s) return NULL;
size_t n = strlen(s);
while (n > 0 && s[n - 1] == '/') n--;
char *p = (char *)malloc(n + 1);
if (!p) return NULL;
memcpy(p, s, n);
p[n] = '\0';
return p;
}
/* Look at an HTTP response and turn a non-2xx into a populated cf_error_t.
* On 2xx returns CF_OK without touching err. */
static cf_status_t check_http_status(const cf_http_response_t *r,
cf_error_t *err) {
if (r->http_status >= 200 && r->http_status < 300) return CF_OK;
cf_status_t code = cf_status_from_http(r->http_status);
char *detail = cf_json_extract_error_message(r->body.data, r->body.len);
cf_err_set(err, code, r->http_status,
"server returned %ld: %s",
r->http_status,
detail ? detail : "(no body)");
free(detail);
return code;
}
/* curl_global_init/cleanup are reference-counted by libcurl. We pin
* the global with the first cf_client_new and release on the matching
* cf_client_free. Not thread-safe to mix init/cleanup across threads
* as documented; users should call cf_client_new from a single thread
* before fanning out, which is the usual pattern anyway. */
#include <curl/curl.h>
static int g_curl_init_count = 0;
static void curl_pin(void) {
if (g_curl_init_count++ == 0) {
curl_global_init(CURL_GLOBAL_DEFAULT);
}
}
static void curl_unpin(void) {
if (--g_curl_init_count == 0) {
curl_global_cleanup();
}
}
/* ---------- cf_client ------------------------------------------- */
cf_client_t *cf_client_new(const char *base_url, const char *token) {
if (!base_url || !*base_url) return NULL;
cf_client_t *c = (cf_client_t *)calloc(1, sizeof *c);
if (!c) return NULL;
c->base_url = strdup_strip_trailing_slash(base_url);
if (!c->base_url) {
free(c);
return NULL;
}
if (token) {
c->token = strdup_or_null(token);
if (!c->token) {
free(c->base_url);
free(c);
return NULL;
}
}
c->timeout_secs = 600;
curl_pin();
return c;
}
cf_status_t cf_client_set_admin_token(cf_client_t *c, const char *admin_token) {
if (!c) return CF_ERR_USAGE;
free(c->admin_token);
c->admin_token = NULL;
if (admin_token) {
c->admin_token = strdup_or_null(admin_token);
if (!c->admin_token) return CF_ERR_OOM;
}
return CF_OK;
}
void cf_client_set_timeout_secs(cf_client_t *c, long timeout_secs) {
if (!c || timeout_secs <= 0) return;
c->timeout_secs = timeout_secs;
}
void cf_client_free(cf_client_t *c) {
if (!c) return;
free(c->base_url);
free(c->token);
free(c->admin_token);
free(c);
curl_unpin();
}
/* ---------- /healthz -------------------------------------------- */
cf_status_t cf_healthz(cf_client_t *c, cf_healthz_t *out, cf_error_t *err) {
if (!c || !out) return cf_err_set(err, CF_ERR_USAGE, 0, "null arg");
memset(out, 0, sizeof *out);
cf_http_response_t resp;
cf_http_response_init(&resp);
cf_status_t s = cf_http_get(c, "/healthz", NULL, &resp, err);
if (s != CF_OK) {
cf_http_response_free(&resp);
return s;
}
s = check_http_status(&resp, err);
if (s != CF_OK) {
cf_http_response_free(&resp);
return s;
}
cJSON *node = NULL;
s = cf_json_parse(resp.body.data, resp.body.len, &node, err);
cf_http_response_free(&resp);
if (s != CF_OK) return s;
cJSON *ok = cJSON_GetObjectItemCaseSensitive(node, "ok");
cJSON *cp = cJSON_GetObjectItemCaseSensitive(node, "claude_present");
cJSON *cv = cJSON_GetObjectItemCaseSensitive(node, "claude_version");
out->ok = cJSON_IsTrue(ok) ? 1 : 0;
out->claude_present = cJSON_IsTrue(cp) ? 1 : 0;
if (cJSON_IsString(cv) && cv->valuestring) {
out->claude_version = strdup_or_null(cv->valuestring);
if (!out->claude_version) {
cJSON_Delete(node);
return cf_err_set(err, CF_ERR_OOM, 0, "out of memory");
}
}
cJSON_Delete(node);
return CF_OK;
}
void cf_healthz_free(cf_healthz_t *h) {
if (!h) return;
free(h->claude_version);
h->claude_version = NULL;
h->ok = 0;
h->claude_present = 0;
}
/* ---------- /run ------------------------------------------------ */
static cJSON *build_run_request_json(const cf_run_request_t *req,
cf_error_t *err) {
cJSON *body = cJSON_CreateObject();
if (!body) {
cf_err_set(err, CF_ERR_OOM, 0, "out of memory");
return NULL;
}
if (!cJSON_AddStringToObject(body, "prompt", req->prompt)) goto oom;
if (req->model && *req->model) {
if (!cJSON_AddStringToObject(body, "model", req->model)) goto oom;
}
if (req->system && *req->system) {
if (!cJSON_AddStringToObject(body, "system", req->system)) goto oom;
}
if (req->files && req->files_count > 0) {
cJSON *arr = cJSON_AddArrayToObject(body, "files");
if (!arr) goto oom;
for (size_t i = 0; i < req->files_count; ++i) {
const char *f = req->files[i];
if (!f) continue;
cJSON *s = cJSON_CreateString(f);
if (!s) goto oom;
cJSON_AddItemToArray(arr, s);
}
}
if (req->timeout_secs > 0) {
if (!cJSON_AddNumberToObject(body, "timeout_secs", req->timeout_secs)) goto oom;
}
return body;
oom:
cJSON_Delete(body);
cf_err_set(err, CF_ERR_OOM, 0, "out of memory building request");
return NULL;
}
cf_status_t cf_run(cf_client_t *c,
const cf_run_request_t *req,
cf_run_result_t *out,
cf_error_t *err) {
if (!c || !req || !out) {
return cf_err_set(err, CF_ERR_USAGE, 0, "null arg");
}
if (!req->prompt || !*req->prompt) {
return cf_err_set(err, CF_ERR_USAGE, 0, "prompt is required");
}
memset(out, 0, sizeof *out);
cJSON *body = build_run_request_json(req, err);
if (!body) return CF_ERR_OOM;
char *body_str = cJSON_PrintUnformatted(body);
cJSON_Delete(body);
if (!body_str) return cf_err_set(err, CF_ERR_OOM, 0, "out of memory serialising");
cf_http_response_t resp;
cf_http_response_init(&resp);
cf_status_t s = cf_http_post_json(c, "/run", NULL, body_str, &resp, err);
cJSON_free(body_str);
if (s != CF_OK) {
cf_http_response_free(&resp);
return s;
}
/* /run uses 502 for upstream failures with a structured body. We treat
* those (and other 4xx/5xx with bodies) as CF_ERR_API and surface the
* embedded error string. */
s = check_http_status(&resp, err);
if (s != CF_OK) {
cf_http_response_free(&resp);
return s;
}
cJSON *node = NULL;
s = cf_json_parse(resp.body.data, resp.body.len, &node, err);
cf_http_response_free(&resp);
if (s != CF_OK) return s;
cJSON *ok = cJSON_GetObjectItemCaseSensitive(node, "ok");
cJSON *result = cJSON_GetObjectItemCaseSensitive(node, "result");
cJSON *dur = cJSON_GetObjectItemCaseSensitive(node, "duration_ms");
cJSON *stop = cJSON_GetObjectItemCaseSensitive(node, "stop_reason");
if (!cJSON_IsTrue(ok)) {
cJSON *erro = cJSON_GetObjectItemCaseSensitive(node, "error");
cf_err_set(err, CF_ERR_API, 200,
"ok=false: %s",
(cJSON_IsString(erro) && erro->valuestring) ? erro->valuestring : "(no error field)");
cJSON_Delete(node);
return CF_ERR_API;
}
if (cJSON_IsNumber(dur)) {
out->duration_ms = (long)dur->valuedouble;
}
if (cJSON_IsString(stop) && stop->valuestring) {
out->stop_reason = strdup_or_null(stop->valuestring);
if (!out->stop_reason) {
cJSON_Delete(node);
return cf_err_set(err, CF_ERR_OOM, 0, "out of memory");
}
}
if (result) {
out->result_is_string = cJSON_IsString(result) ? 1 : 0;
char *as_text = cJSON_PrintUnformatted(result);
if (!as_text) {
cJSON_Delete(node);
return cf_err_set(err, CF_ERR_OOM, 0, "out of memory printing result");
}
/* cJSON_Print uses cJSON's allocator (defaults to malloc), but we
* dup into the standard allocator so callers always free with
* free() keeps the surface predictable. */
out->result_json = strdup_or_null(as_text);
cJSON_free(as_text);
if (!out->result_json) {
cJSON_Delete(node);
return cf_err_set(err, CF_ERR_OOM, 0, "out of memory");
}
}
cJSON_Delete(node);
return CF_OK;
}
void cf_run_result_free(cf_run_result_t *r) {
if (!r) return;
free(r->result_json);
free(r->stop_reason);
r->result_json = NULL;
r->stop_reason = NULL;
r->result_is_string = 0;
r->duration_ms = 0;
}
void *cf_run_result_as_cjson(const cf_run_result_t *r) {
if (!r || !r->result_json) return NULL;
return cJSON_Parse(r->result_json);
}
/* ---------- /files ---------------------------------------------- */
cf_status_t cf_upload_file(cf_client_t *c,
const char *path,
long ttl_secs,
cf_file_token_t *out,
cf_error_t *err) {
if (!c || !path || !out) return cf_err_set(err, CF_ERR_USAGE, 0, "null arg");
if (ttl_secs != 0 && (ttl_secs < 60 || ttl_secs > 86400)) {
return cf_err_set(err, CF_ERR_USAGE, 0,
"ttl_secs must be 0 (default) or 60..86400");
}
memset(out, 0, sizeof *out);
cf_http_response_t resp;
cf_http_response_init(&resp);
cf_status_t s = cf_http_post_multipart_file(c, "/files", path, ttl_secs, &resp, err);
if (s != CF_OK) {
cf_http_response_free(&resp);
return s;
}
s = check_http_status(&resp, err);
if (s != CF_OK) {
cf_http_response_free(&resp);
return s;
}
cJSON *node = NULL;
s = cf_json_parse(resp.body.data, resp.body.len, &node, err);
cf_http_response_free(&resp);
if (s != CF_OK) return s;
cJSON *tok = cJSON_GetObjectItemCaseSensitive(node, "file_token");
cJSON *ttl = cJSON_GetObjectItemCaseSensitive(node, "ttl_secs");
cJSON *sz = cJSON_GetObjectItemCaseSensitive(node, "size");
if (!cJSON_IsString(tok) || !tok->valuestring) {
cJSON_Delete(node);
return cf_err_set(err, CF_ERR_PARSE, 0, "missing file_token in response");
}
out->file_token = strdup_or_null(tok->valuestring);
if (!out->file_token) {
cJSON_Delete(node);
return cf_err_set(err, CF_ERR_OOM, 0, "out of memory");
}
if (cJSON_IsNumber(ttl)) out->ttl_secs = (long)ttl->valuedouble;
if (cJSON_IsNumber(sz)) out->size = (long)sz->valuedouble;
cJSON_Delete(node);
return CF_OK;
}
void cf_file_token_free(cf_file_token_t *ft) {
if (!ft) return;
free(ft->file_token);
ft->file_token = NULL;
ft->ttl_secs = 0;
ft->size = 0;
}
/* ---------- /admin/tokens --------------------------------------- */
static cf_status_t admin_required(const cf_client_t *c, cf_error_t *err) {
if (!c->admin_token || !*c->admin_token) {
return cf_err_set(err, CF_ERR_USAGE, 0,
"admin token not set; call cf_client_set_admin_token");
}
return CF_OK;
}
static int extract_string_array(cJSON *arr, char ***out, size_t *out_n) {
*out = NULL;
*out_n = 0;
if (!cJSON_IsArray(arr)) return 0;
int n = cJSON_GetArraySize(arr);
if (n <= 0) return 0;
char **items = (char **)calloc((size_t)n, sizeof(char *));
if (!items) return -1;
int got = 0;
for (int i = 0; i < n; ++i) {
cJSON *e = cJSON_GetArrayItem(arr, i);
if (cJSON_IsString(e) && e->valuestring) {
items[got] = strdup_or_null(e->valuestring);
if (!items[got]) {
for (int j = 0; j < got; ++j) free(items[j]);
free(items);
return -1;
}
got++;
}
}
*out = items;
*out_n = (size_t)got;
return 0;
}
cf_status_t cf_admin_create_token(cf_client_t *c,
const char *name,
const char *const *ip_cidrs,
size_t ip_cidrs_count,
cf_admin_token_t *out,
cf_error_t *err) {
if (!c || !name || !out) return cf_err_set(err, CF_ERR_USAGE, 0, "null arg");
cf_status_t s = admin_required(c, err);
if (s != CF_OK) return s;
memset(out, 0, sizeof *out);
cJSON *body = cJSON_CreateObject();
if (!body) return cf_err_set(err, CF_ERR_OOM, 0, "out of memory");
if (!cJSON_AddStringToObject(body, "name", name)) {
cJSON_Delete(body);
return cf_err_set(err, CF_ERR_OOM, 0, "out of memory");
}
cJSON *arr = cJSON_AddArrayToObject(body, "ip_cidrs");
if (!arr) {
cJSON_Delete(body);
return cf_err_set(err, CF_ERR_OOM, 0, "out of memory");
}
for (size_t i = 0; i < ip_cidrs_count; ++i) {
if (!ip_cidrs[i]) continue;
cJSON *str = cJSON_CreateString(ip_cidrs[i]);
if (!str) {
cJSON_Delete(body);
return cf_err_set(err, CF_ERR_OOM, 0, "out of memory");
}
cJSON_AddItemToArray(arr, str);
}
char *body_str = cJSON_PrintUnformatted(body);
cJSON_Delete(body);
if (!body_str) return cf_err_set(err, CF_ERR_OOM, 0, "out of memory");
cf_http_response_t resp;
cf_http_response_init(&resp);
s = cf_http_post_json(c, "/admin/tokens", c->admin_token, body_str, &resp, err);
cJSON_free(body_str);
if (s != CF_OK) {
cf_http_response_free(&resp);
return s;
}
s = check_http_status(&resp, err);
if (s != CF_OK) {
cf_http_response_free(&resp);
return s;
}
cJSON *node = NULL;
s = cf_json_parse(resp.body.data, resp.body.len, &node, err);
cf_http_response_free(&resp);
if (s != CF_OK) return s;
cJSON *jname = cJSON_GetObjectItemCaseSensitive(node, "name");
cJSON *jtok = cJSON_GetObjectItemCaseSensitive(node, "token");
cJSON *jcidrs = cJSON_GetObjectItemCaseSensitive(node, "ip_cidrs");
if (cJSON_IsString(jname) && jname->valuestring) {
out->name = strdup_or_null(jname->valuestring);
if (!out->name) goto oom;
}
if (cJSON_IsString(jtok) && jtok->valuestring) {
out->token = strdup_or_null(jtok->valuestring);
if (!out->token) goto oom;
}
if (extract_string_array(jcidrs, &out->ip_cidrs, &out->ip_cidrs_count) != 0) goto oom;
cJSON_Delete(node);
return CF_OK;
oom:
cJSON_Delete(node);
cf_admin_token_free(out);
return cf_err_set(err, CF_ERR_OOM, 0, "out of memory");
}
void cf_admin_token_free(cf_admin_token_t *t) {
if (!t) return;
free(t->name);
free(t->token);
for (size_t i = 0; i < t->ip_cidrs_count; ++i) free(t->ip_cidrs[i]);
free(t->ip_cidrs);
t->name = NULL;
t->token = NULL;
t->ip_cidrs = NULL;
t->ip_cidrs_count = 0;
}
cf_status_t cf_admin_list_tokens(cf_client_t *c,
cf_admin_token_list_t *out,
cf_error_t *err) {
if (!c || !out) return cf_err_set(err, CF_ERR_USAGE, 0, "null arg");
cf_status_t s = admin_required(c, err);
if (s != CF_OK) return s;
memset(out, 0, sizeof *out);
cf_http_response_t resp;
cf_http_response_init(&resp);
s = cf_http_get(c, "/admin/tokens", c->admin_token, &resp, err);
if (s != CF_OK) {
cf_http_response_free(&resp);
return s;
}
s = check_http_status(&resp, err);
if (s != CF_OK) {
cf_http_response_free(&resp);
return s;
}
cJSON *node = NULL;
s = cf_json_parse(resp.body.data, resp.body.len, &node, err);
cf_http_response_free(&resp);
if (s != CF_OK) return s;
cJSON *items = cJSON_GetObjectItemCaseSensitive(node, "tokens");
if (!cJSON_IsArray(items)) {
cJSON_Delete(node);
return cf_err_set(err, CF_ERR_PARSE, 0, "expected 'tokens' array");
}
int n = cJSON_GetArraySize(items);
if (n > 0) {
out->items = (cf_admin_token_info_t *)calloc((size_t)n, sizeof *out->items);
if (!out->items) {
cJSON_Delete(node);
return cf_err_set(err, CF_ERR_OOM, 0, "out of memory");
}
}
for (int i = 0; i < n; ++i) {
cJSON *e = cJSON_GetArrayItem(items, i);
cf_admin_token_info_t *info = &out->items[out->count];
cJSON *jname = cJSON_GetObjectItemCaseSensitive(e, "name");
cJSON *jcidrs = cJSON_GetObjectItemCaseSensitive(e, "ip_cidrs");
cJSON *jcre = cJSON_GetObjectItemCaseSensitive(e, "created_at");
cJSON *jlast = cJSON_GetObjectItemCaseSensitive(e, "last_used_at");
if (cJSON_IsString(jname) && jname->valuestring) {
info->name = strdup_or_null(jname->valuestring);
if (!info->name) {
cJSON_Delete(node);
cf_admin_token_list_free(out);
return cf_err_set(err, CF_ERR_OOM, 0, "out of memory");
}
}
if (extract_string_array(jcidrs, &info->ip_cidrs, &info->ip_cidrs_count) != 0) {
cJSON_Delete(node);
cf_admin_token_list_free(out);
return cf_err_set(err, CF_ERR_OOM, 0, "out of memory");
}
if (cJSON_IsNumber(jcre)) info->created_at = (long)jcre->valuedouble;
if (cJSON_IsNumber(jlast)) info->last_used_at = (long)jlast->valuedouble;
out->count++;
}
cJSON_Delete(node);
return CF_OK;
}
void cf_admin_token_list_free(cf_admin_token_list_t *l) {
if (!l) return;
for (size_t i = 0; i < l->count; ++i) {
free(l->items[i].name);
for (size_t j = 0; j < l->items[i].ip_cidrs_count; ++j) {
free(l->items[i].ip_cidrs[j]);
}
free(l->items[i].ip_cidrs);
}
free(l->items);
l->items = NULL;
l->count = 0;
}
cf_status_t cf_admin_revoke_token(cf_client_t *c,
const char *name,
cf_error_t *err) {
if (!c || !name) return cf_err_set(err, CF_ERR_USAGE, 0, "null arg");
cf_status_t s = admin_required(c, err);
if (s != CF_OK) return s;
/* Build path "/admin/tokens/<name>" — the server requires a-z0-9_-,
* so no URL-encoding needed for valid names; we still bound the size. */
char path[256];
int n = snprintf(path, sizeof path, "/admin/tokens/%s", name);
if (n <= 0 || (size_t)n >= sizeof path) {
return cf_err_set(err, CF_ERR_USAGE, 0, "token name too long");
}
cf_http_response_t resp;
cf_http_response_init(&resp);
s = cf_http_delete(c, path, c->admin_token, &resp, err);
if (s != CF_OK) {
cf_http_response_free(&resp);
return s;
}
s = check_http_status(&resp, err);
cf_http_response_free(&resp);
return s;
}

271
clients/c/src/http.c Normal file
View file

@ -0,0 +1,271 @@
/* libcurl-backed HTTP transport for clawdforge.
*
* Each request constructs its own curl easy handle. Clients are not
* meant to be shared between threads, so there is no easy-handle
* pool right now connection reuse can be added later by switching
* to a curl share handle on the cf_client_t. */
#include "internal.h"
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
/* ---------- buffers --------------------------------------------- */
void cf_buf_init(cf_buf_t *b) {
b->data = NULL;
b->len = 0;
b->cap = 0;
}
int cf_buf_append(cf_buf_t *b, const void *src, size_t n) {
if (b->len + n + 1 > b->cap) {
size_t need = b->len + n + 1;
size_t newcap = b->cap ? b->cap : 256;
while (newcap < need) newcap *= 2;
char *p = (char *)realloc(b->data, newcap);
if (!p) return -1;
b->data = p;
b->cap = newcap;
}
memcpy(b->data + b->len, src, n);
b->len += n;
b->data[b->len] = '\0';
return 0;
}
void cf_buf_free(cf_buf_t *b) {
free(b->data);
b->data = NULL;
b->len = b->cap = 0;
}
/* ---------- response helpers ------------------------------------ */
void cf_http_response_init(cf_http_response_t *r) {
r->http_status = 0;
cf_buf_init(&r->body);
}
void cf_http_response_free(cf_http_response_t *r) {
cf_buf_free(&r->body);
r->http_status = 0;
}
/* ---------- url + headers --------------------------------------- */
static char *make_url(const char *base, const char *path) {
/* base is already normalised (no trailing slash). */
size_t bn = strlen(base);
size_t pn = strlen(path);
int leading_slash = (path[0] == '/');
size_t total = bn + pn + (leading_slash ? 0 : 1) + 1;
char *url = (char *)malloc(total);
if (!url) return NULL;
memcpy(url, base, bn);
if (!leading_slash) {
url[bn++] = '/';
}
memcpy(url + bn, path, pn);
url[bn + pn] = '\0';
return url;
}
static struct curl_slist *push_auth(struct curl_slist *list,
const struct cf_client *c,
const char *override) {
const char *tok = override ? override : c->token;
if (!tok || !*tok) return list;
/* "Authorization: Bearer " + tok */
size_t n = strlen(tok) + 32;
char *line = (char *)malloc(n);
if (!line) return list;
snprintf(line, n, "Authorization: Bearer %s", tok);
struct curl_slist *next = curl_slist_append(list, line);
free(line);
return next ? next : list;
}
/* libcurl write callback. */
static size_t write_cb(char *ptr, size_t size, size_t nmemb, void *user) {
cf_buf_t *buf = (cf_buf_t *)user;
size_t n = size * nmemb;
if (cf_buf_append(buf, ptr, n) != 0) return 0;
return n;
}
/* ---------- shared core ----------------------------------------- */
typedef enum cf_method {
CF_GET,
CF_POST_JSON,
CF_DELETE,
CF_POST_MULTIPART
} cf_method_t;
typedef struct cf_req_opts {
cf_method_t method;
const char *json_body;
const char *file_path;
long ttl_secs;
} cf_req_opts_t;
static cf_status_t do_request(const struct cf_client *c,
const char *path,
const char *bearer_override,
const cf_req_opts_t *opts,
cf_http_response_t *out,
cf_error_t *err) {
if (!c || !path || !out) {
return cf_err_set(err, CF_ERR_USAGE, 0, "null argument to http call");
}
char *url = make_url(c->base_url, path);
if (!url) return cf_err_set(err, CF_ERR_OOM, 0, "out of memory");
CURL *h = curl_easy_init();
if (!h) {
free(url);
return cf_err_set(err, CF_ERR_TRANSPORT, 0, "curl_easy_init failed");
}
struct curl_slist *headers = NULL;
headers = push_auth(headers, c, bearer_override);
curl_mime *mime = NULL;
char errbuf[CURL_ERROR_SIZE] = {0};
curl_easy_setopt(h, CURLOPT_URL, url);
curl_easy_setopt(h, CURLOPT_WRITEFUNCTION, write_cb);
curl_easy_setopt(h, CURLOPT_WRITEDATA, &out->body);
curl_easy_setopt(h, CURLOPT_TIMEOUT, c->timeout_secs);
curl_easy_setopt(h, CURLOPT_NOSIGNAL, 1L);
curl_easy_setopt(h, CURLOPT_ERRORBUFFER, errbuf);
curl_easy_setopt(h, CURLOPT_USERAGENT, "clawdforge-c/" CLAWDFORGE_VERSION_STRING);
curl_easy_setopt(h, CURLOPT_FOLLOWLOCATION, 1L);
curl_easy_setopt(h, CURLOPT_MAXREDIRS, 4L);
switch (opts->method) {
case CF_GET:
curl_easy_setopt(h, CURLOPT_HTTPGET, 1L);
break;
case CF_POST_JSON:
curl_easy_setopt(h, CURLOPT_POST, 1L);
if (opts->json_body) {
curl_easy_setopt(h, CURLOPT_POSTFIELDS, opts->json_body);
curl_easy_setopt(h, CURLOPT_POSTFIELDSIZE, (long)strlen(opts->json_body));
} else {
curl_easy_setopt(h, CURLOPT_POSTFIELDSIZE, 0L);
}
headers = curl_slist_append(headers, "Content-Type: application/json");
headers = curl_slist_append(headers, "Accept: application/json");
break;
case CF_DELETE:
curl_easy_setopt(h, CURLOPT_CUSTOMREQUEST, "DELETE");
break;
case CF_POST_MULTIPART:
mime = curl_mime_init(h);
if (!mime) {
curl_slist_free_all(headers);
curl_easy_cleanup(h);
free(url);
return cf_err_set(err, CF_ERR_OOM, 0, "curl_mime_init failed");
}
{
curl_mimepart *part = curl_mime_addpart(mime);
curl_mime_name(part, "file");
/* Stream from disk; libcurl handles chunked transfer. */
CURLcode mc = curl_mime_filedata(part, opts->file_path);
if (mc != CURLE_OK) {
curl_mime_free(mime);
curl_slist_free_all(headers);
curl_easy_cleanup(h);
free(url);
return cf_err_set(err, CF_ERR_USAGE, 0,
"cannot read file '%s'", opts->file_path);
}
}
if (opts->ttl_secs > 0) {
curl_mimepart *part = curl_mime_addpart(mime);
curl_mime_name(part, "ttl_secs");
char ttl_buf[32];
snprintf(ttl_buf, sizeof ttl_buf, "%ld", opts->ttl_secs);
curl_mime_data(part, ttl_buf, CURL_ZERO_TERMINATED);
}
curl_easy_setopt(h, CURLOPT_MIMEPOST, mime);
break;
}
if (headers) {
curl_easy_setopt(h, CURLOPT_HTTPHEADER, headers);
}
CURLcode rc = curl_easy_perform(h);
if (rc != CURLE_OK) {
const char *msg = errbuf[0] ? errbuf : curl_easy_strerror(rc);
cf_err_set(err, CF_ERR_TRANSPORT, 0, "transport: %s", msg);
if (mime) curl_mime_free(mime);
curl_slist_free_all(headers);
curl_easy_cleanup(h);
free(url);
return CF_ERR_TRANSPORT;
}
long status = 0;
curl_easy_getinfo(h, CURLINFO_RESPONSE_CODE, &status);
out->http_status = status;
if (mime) curl_mime_free(mime);
curl_slist_free_all(headers);
curl_easy_cleanup(h);
free(url);
return CF_OK;
}
cf_status_t cf_http_get(const struct cf_client *c,
const char *path,
const char *bearer_override,
cf_http_response_t *out,
cf_error_t *err) {
cf_req_opts_t opts = {0};
opts.method = CF_GET;
return do_request(c, path, bearer_override, &opts, out, err);
}
cf_status_t cf_http_post_json(const struct cf_client *c,
const char *path,
const char *bearer_override,
const char *json_body,
cf_http_response_t *out,
cf_error_t *err) {
cf_req_opts_t opts = {0};
opts.method = CF_POST_JSON;
opts.json_body = json_body;
return do_request(c, path, bearer_override, &opts, out, err);
}
cf_status_t cf_http_delete(const struct cf_client *c,
const char *path,
const char *bearer_override,
cf_http_response_t *out,
cf_error_t *err) {
cf_req_opts_t opts = {0};
opts.method = CF_DELETE;
return do_request(c, path, bearer_override, &opts, out, err);
}
cf_status_t cf_http_post_multipart_file(const struct cf_client *c,
const char *path,
const char *file_path,
long ttl_secs,
cf_http_response_t *out,
cf_error_t *err) {
cf_req_opts_t opts = {0};
opts.method = CF_POST_MULTIPART;
opts.file_path = file_path;
opts.ttl_secs = ttl_secs;
return do_request(c, path, /*bearer_override*/ NULL, &opts, out, err);
}

99
clients/c/src/internal.h Normal file
View file

@ -0,0 +1,99 @@
/* Internal definitions shared between client.c, http.c, and json.c.
* Not installed; lives only inside the build. */
#ifndef CLAWDFORGE_INTERNAL_H
#define CLAWDFORGE_INTERNAL_H
#include <stddef.h>
#include <stdint.h>
#include "clawdforge.h"
#include <curl/curl.h>
#include "cjson/cJSON.h"
/* The opaque public client. */
struct cf_client {
char *base_url; /* normalised: no trailing slash */
char *token; /* may be NULL */
char *admin_token; /* may be NULL */
long timeout_secs;
};
/* Growable byte buffer used for response bodies. */
typedef struct cf_buf {
char *data;
size_t len;
size_t cap;
} cf_buf_t;
void cf_buf_init(cf_buf_t *b);
int cf_buf_append(cf_buf_t *b, const void *src, size_t n); /* 0 ok, -1 oom */
void cf_buf_free(cf_buf_t *b);
/* Set the err out-param. Steals the message. fmt is printf-style.
* Always returns the supplied code so callers can `return cf_err_set(...)`. */
cf_status_t cf_err_set(cf_error_t *err,
cf_status_t code,
long http_status,
const char *fmt, ...);
/* Clear an error struct without freeing (e.g. before reuse). */
void cf_err_init(cf_error_t *err);
/* HTTP layer ------------------------------------------------------ */
typedef struct cf_http_response {
long http_status;
cf_buf_t body;
} cf_http_response_t;
void cf_http_response_init(cf_http_response_t *r);
void cf_http_response_free(cf_http_response_t *r);
/* Perform a GET. Returns CF_OK on transport success (regardless of HTTP
* status); body and http_status are populated. CF_ERR_TRANSPORT or
* CF_ERR_OOM otherwise. */
cf_status_t cf_http_get(const struct cf_client *c,
const char *path,
const char *bearer_override, /* NULL = use c->token */
cf_http_response_t *out,
cf_error_t *err);
cf_status_t cf_http_post_json(const struct cf_client *c,
const char *path,
const char *bearer_override,
const char *json_body, /* nullable */
cf_http_response_t *out,
cf_error_t *err);
cf_status_t cf_http_delete(const struct cf_client *c,
const char *path,
const char *bearer_override,
cf_http_response_t *out,
cf_error_t *err);
cf_status_t cf_http_post_multipart_file(const struct cf_client *c,
const char *path,
const char *file_path,
long ttl_secs,
cf_http_response_t *out,
cf_error_t *err);
/* JSON helpers ---------------------------------------------------- */
/* Returns CF_OK and a cJSON object via *out_node, or sets err and
* returns CF_ERR_PARSE. Caller must cJSON_Delete(*out_node) on OK. */
cf_status_t cf_json_parse(const char *body,
size_t body_len,
cJSON **out_node,
cf_error_t *err);
/* If the body looks like {"detail":"..."} or {"ok":false,"error":"..."}
* extract a usable message. Returns a new heap-allocated string or NULL. */
char *cf_json_extract_error_message(const char *body, size_t body_len);
/* Translate an HTTP-status -> cf_status_t for non-2xx responses. */
cf_status_t cf_status_from_http(long http_status);
#endif

144
clients/c/src/json.c Normal file
View file

@ -0,0 +1,144 @@
/* JSON helpers wrapping cJSON. */
#include "internal.h"
#include <stdarg.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
/* Local strdup so we don't depend on POSIX feature-test macros at the
* library compilation unit. */
static char *cf_strdup(const char *s) {
if (!s) return NULL;
size_t n = strlen(s) + 1;
char *p = (char *)malloc(n);
if (!p) return NULL;
memcpy(p, s, n);
return p;
}
cf_status_t cf_json_parse(const char *body,
size_t body_len,
cJSON **out_node,
cf_error_t *err) {
if (!body || body_len == 0) {
return cf_err_set(err, CF_ERR_PARSE, 0, "empty response body");
}
cJSON *node = cJSON_ParseWithLength(body, body_len);
if (!node) {
const char *eptr = cJSON_GetErrorPtr();
return cf_err_set(err, CF_ERR_PARSE, 0,
"invalid JSON near: %.40s",
eptr ? eptr : "(unknown)");
}
*out_node = node;
return CF_OK;
}
char *cf_json_extract_error_message(const char *body, size_t body_len) {
if (!body || body_len == 0) return NULL;
cJSON *root = cJSON_ParseWithLength(body, body_len);
if (!root) {
/* Fall back to the raw body, truncated. */
size_t max = body_len < 256 ? body_len : 256;
char *s = (char *)malloc(max + 1);
if (!s) return NULL;
memcpy(s, body, max);
s[max] = '\0';
return s;
}
char *out = NULL;
cJSON *detail = cJSON_GetObjectItemCaseSensitive(root, "detail");
cJSON *error = cJSON_GetObjectItemCaseSensitive(root, "error");
cJSON *stderr_field = cJSON_GetObjectItemCaseSensitive(root, "stderr");
const char *src = NULL;
if (cJSON_IsString(detail)) src = detail->valuestring;
else if (cJSON_IsString(error)) src = error->valuestring;
else if (cJSON_IsString(stderr_field)) src = stderr_field->valuestring;
if (src) {
out = cf_strdup(src);
} else {
char *printed = cJSON_PrintUnformatted(root);
if (printed) {
out = cf_strdup(printed);
cJSON_free(printed);
}
}
cJSON_Delete(root);
return out;
}
cf_status_t cf_status_from_http(long http_status) {
if (http_status == 401 || http_status == 403) return CF_ERR_AUTH;
if (http_status == 0) return CF_ERR_TRANSPORT;
if (http_status >= 400) return CF_ERR_API;
return CF_OK;
}
/* cf_err_set / cf_err_init / cf_status_str / cf_version live here too
* since they're tiny and fit alongside the JSON helpers. */
void cf_err_init(cf_error_t *err) {
if (!err) return;
err->code = CF_OK;
err->http_status = 0;
err->message = NULL;
}
cf_status_t cf_err_set(cf_error_t *err,
cf_status_t code,
long http_status,
const char *fmt, ...) {
if (!err) return code;
free(err->message);
err->message = NULL;
err->code = code;
err->http_status = http_status;
va_list ap;
va_start(ap, fmt);
/* size first, then alloc. */
va_list ap2;
va_copy(ap2, ap);
int n = vsnprintf(NULL, 0, fmt, ap2);
va_end(ap2);
if (n < 0) { va_end(ap); return code; }
char *buf = (char *)malloc((size_t)n + 1);
if (!buf) { va_end(ap); return code; }
vsnprintf(buf, (size_t)n + 1, fmt, ap);
va_end(ap);
err->message = buf;
return code;
}
const char *cf_error_message(const cf_error_t *err) {
if (!err || !err->message) return "";
return err->message;
}
void cf_error_free(cf_error_t *err) {
if (!err) return;
free(err->message);
err->message = NULL;
err->http_status = 0;
err->code = CF_OK;
}
const char *cf_status_str(cf_status_t code) {
switch (code) {
case CF_OK: return "CF_OK";
case CF_ERR_AUTH: return "CF_ERR_AUTH";
case CF_ERR_API: return "CF_ERR_API";
case CF_ERR_TRANSPORT: return "CF_ERR_TRANSPORT";
case CF_ERR_USAGE: return "CF_ERR_USAGE";
case CF_ERR_OOM: return "CF_ERR_OOM";
case CF_ERR_PARSE: return "CF_ERR_PARSE";
}
return "CF_UNKNOWN";
}
const char *cf_version(void) {
return CLAWDFORGE_VERSION_STRING;
}

View file

@ -0,0 +1,797 @@
/* Test suite for the clawdforge C SDK.
*
* Strategy: spin up a tiny single-threaded HTTP server on 127.0.0.1
* inside this process. The server pulls a scripted response off a
* queue per request and returns it. No external dependency beyond
* the platform sockets API and pthreads (already a transitive dep
* of libcurl on Linux).
*
* We test the wire surface of the client end-to-end through libcurl:
* URL construction, auth header injection, JSON request shape,
* JSON response parsing, error envelopes, multipart upload.
*/
#define _DEFAULT_SOURCE
#define _GNU_SOURCE
#define _POSIX_C_SOURCE 200809L
#include <arpa/inet.h>
#include <ctype.h>
#include <netinet/in.h>
#include <pthread.h>
#include <signal.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <sys/socket.h>
#include <sys/types.h>
#include <unistd.h>
#include <fcntl.h>
#include <errno.h>
#include <clawdforge.h>
#include "cJSON.h"
/* ------------------------------------------------------------------ */
/* tiny test harness */
/* ------------------------------------------------------------------ */
static int g_failures = 0;
static int g_tests = 0;
static const char *g_current = "(none)";
#define TEST(name) do { g_current = name; g_tests++; } while (0)
#define CHECK(cond) do { \
if (!(cond)) { \
fprintf(stderr, "FAIL [%s] %s:%d: %s\n", \
g_current, __FILE__, __LINE__, #cond); \
g_failures++; \
} \
} while (0)
/* Portable case-insensitive prefix compare. Returns 0 on match. */
static int header_match(const char *line, const char *name) {
size_t n = strlen(name);
for (size_t i = 0; i < n; ++i) {
if (!line[i]) return 1;
if (tolower((unsigned char)line[i]) != tolower((unsigned char)name[i])) return 1;
}
return 0;
}
/* Portable memmem (avoids _GNU_SOURCE dep). */
static const void *find_bytes(const void *hay, size_t hay_len,
const void *needle, size_t need_len) {
if (need_len == 0) return hay;
if (hay_len < need_len) return NULL;
const char *h = (const char *)hay;
const char *n = (const char *)needle;
for (size_t i = 0; i + need_len <= hay_len; ++i) {
if (memcmp(h + i, n, need_len) == 0) return h + i;
}
return NULL;
}
#define CHECK_STR_EQ(a, b) do { \
const char *_a = (a); const char *_b = (b); \
if (!_a || !_b || strcmp(_a, _b) != 0) { \
fprintf(stderr, "FAIL [%s] %s:%d: \"%s\" != \"%s\"\n", \
g_current, __FILE__, __LINE__, \
_a ? _a : "(null)", _b ? _b : "(null)"); \
g_failures++; \
} \
} while (0)
/* ------------------------------------------------------------------ */
/* mock server */
/* ------------------------------------------------------------------ */
typedef struct mock_response {
int status;
char *content_type;
char *body;
} mock_response_t;
#define MAX_RESPONSES 16
typedef struct mock_request {
char method[16];
char path[256];
char *auth; /* "Bearer ..." or NULL */
char *content_type;
char *body;
size_t body_len;
} mock_request_t;
static int srv_fd = -1;
static int srv_port = 0;
static pthread_t srv_thread;
static volatile int srv_stop = 0;
static pthread_mutex_t q_mu = PTHREAD_MUTEX_INITIALIZER;
static mock_response_t q_resp[MAX_RESPONSES];
static int q_resp_head = 0;
static int q_resp_tail = 0;
static mock_request_t captured[MAX_RESPONSES];
static int captured_count = 0;
static void enqueue_response(int status, const char *content_type, const char *body) {
pthread_mutex_lock(&q_mu);
mock_response_t *r = &q_resp[q_resp_tail];
r->status = status;
r->content_type = content_type ? strdup(content_type) : strdup("application/json");
r->body = body ? strdup(body) : strdup("");
q_resp_tail = (q_resp_tail + 1) % MAX_RESPONSES;
pthread_mutex_unlock(&q_mu);
}
static int dequeue_response(mock_response_t *out) {
pthread_mutex_lock(&q_mu);
if (q_resp_head == q_resp_tail) {
pthread_mutex_unlock(&q_mu);
return -1;
}
*out = q_resp[q_resp_head];
q_resp_head = (q_resp_head + 1) % MAX_RESPONSES;
pthread_mutex_unlock(&q_mu);
return 0;
}
static void capture(const mock_request_t *r) {
pthread_mutex_lock(&q_mu);
if (captured_count < MAX_RESPONSES) {
captured[captured_count++] = *r;
}
pthread_mutex_unlock(&q_mu);
}
/* Read the full HTTP request from a socket. Returns 0 on success. */
static int read_request(int fd, mock_request_t *req) {
char buf[64 * 1024];
size_t off = 0;
/* Read until headers complete */
while (off < sizeof(buf) - 1) {
ssize_t n = read(fd, buf + off, sizeof(buf) - 1 - off);
if (n <= 0) return -1;
off += (size_t)n;
buf[off] = '\0';
if (strstr(buf, "\r\n\r\n")) break;
}
char *blank_line = strstr(buf, "\r\n\r\n");
if (!blank_line) return -1;
char *body_start = blank_line + 4;
size_t header_len = (size_t)(body_start - buf);
/* The last header's terminating "\r\n" is the FIRST "\r\n" of the
* "\r\n\r\n" sequence so it's at blank_line..+1. To process every
* header line including the last one, walk byte-range
* [buf .. blank_line + 2). The "+2" includes the last header's "\r\n". */
char *header_end = blank_line + 2;
/* Method + path */
char *line_end = strstr(buf, "\r\n");
if (!line_end || line_end >= header_end) return -1;
char first_line[1024];
size_t fl_len = (size_t)(line_end - buf);
if (fl_len >= sizeof first_line) fl_len = sizeof first_line - 1;
memcpy(first_line, buf, fl_len);
first_line[fl_len] = '\0';
char path_buf[256] = {0};
if (sscanf(first_line, "%15s %255s", req->method, path_buf) != 2) return -1;
memcpy(req->path, path_buf, sizeof req->path);
req->path[sizeof req->path - 1] = '\0';
/* Headers */
req->auth = NULL;
req->content_type = NULL;
long content_length = 0;
char *p = line_end + 2;
while (p < header_end) {
/* find next \r\n strictly within the header range */
char *nl = NULL;
for (char *q = p; q + 1 < header_end; ++q) {
if (q[0] == '\r' && q[1] == '\n') { nl = q; break; }
}
if (!nl) break;
size_t llen = (size_t)(nl - p);
char line[2048];
if (llen >= sizeof line) llen = sizeof line - 1;
memcpy(line, p, llen);
line[llen] = '\0';
if (header_match(line, "Authorization:") == 0) {
char *v = line + 14;
while (*v == ' ') v++;
req->auth = strdup(v);
} else if (header_match(line, "Content-Type:") == 0) {
char *v = line + 13;
while (*v == ' ') v++;
req->content_type = strdup(v);
} else if (header_match(line, "Content-Length:") == 0) {
content_length = strtol(line + 15, NULL, 10);
}
p = nl + 2;
}
/* Body */
req->body = NULL;
req->body_len = 0;
if (content_length > 0) {
size_t already = off - header_len;
char *body = (char *)malloc((size_t)content_length + 1);
if (!body) return -1;
if (already > (size_t)content_length) already = (size_t)content_length;
memcpy(body, body_start, already);
size_t got = already;
while (got < (size_t)content_length) {
ssize_t n = read(fd, body + got, (size_t)content_length - got);
if (n <= 0) { free(body); return -1; }
got += (size_t)n;
}
body[content_length] = '\0';
req->body = body;
req->body_len = (size_t)content_length;
}
return 0;
}
static void write_response(int fd, const mock_response_t *resp) {
char header[512];
int n = snprintf(header, sizeof header,
"HTTP/1.1 %d %s\r\n"
"Content-Type: %s\r\n"
"Content-Length: %zu\r\n"
"Connection: close\r\n"
"\r\n",
resp->status,
resp->status == 200 ? "OK" :
resp->status == 401 ? "Unauthorized" :
resp->status == 404 ? "Not Found" :
resp->status == 502 ? "Bad Gateway" : "Other",
resp->content_type ? resp->content_type : "application/json",
resp->body ? strlen(resp->body) : 0);
if (n > 0) {
ssize_t wrote = write(fd, header, (size_t)n);
(void)wrote;
if (resp->body && *resp->body) {
wrote = write(fd, resp->body, strlen(resp->body));
(void)wrote;
}
}
}
static void *server_loop(void *arg) {
(void)arg;
while (!srv_stop) {
struct sockaddr_in addr;
socklen_t alen = sizeof addr;
int cfd = accept(srv_fd, (struct sockaddr *)&addr, &alen);
if (cfd < 0) {
if (errno == EINTR || errno == EAGAIN) continue;
break;
}
mock_request_t req;
memset(&req, 0, sizeof req);
if (read_request(cfd, &req) == 0) {
capture(&req);
mock_response_t resp;
if (dequeue_response(&resp) == 0) {
write_response(cfd, &resp);
free(resp.content_type);
free(resp.body);
} else {
/* No script left — return 500. */
mock_response_t fb = {500, strdup("text/plain"), strdup("no scripted response")};
write_response(cfd, &fb);
free(fb.content_type);
free(fb.body);
}
}
close(cfd);
}
return NULL;
}
static int start_server(void) {
srv_fd = socket(AF_INET, SOCK_STREAM, 0);
if (srv_fd < 0) return -1;
int yes = 1;
setsockopt(srv_fd, SOL_SOCKET, SO_REUSEADDR, &yes, sizeof yes);
/* Bind to a random port on loopback. */
struct sockaddr_in addr = {0};
addr.sin_family = AF_INET;
addr.sin_addr.s_addr = htonl(INADDR_LOOPBACK);
addr.sin_port = 0;
if (bind(srv_fd, (struct sockaddr *)&addr, sizeof addr) != 0) return -1;
socklen_t alen = sizeof addr;
if (getsockname(srv_fd, (struct sockaddr *)&addr, &alen) != 0) return -1;
srv_port = ntohs(addr.sin_port);
if (listen(srv_fd, 16) != 0) return -1;
/* Make accept interruptible by close. */
int flags = fcntl(srv_fd, F_GETFL, 0);
(void)flags;
if (pthread_create(&srv_thread, NULL, server_loop, NULL) != 0) return -1;
return 0;
}
static void stop_server(void) {
srv_stop = 1;
/* Kick accept by connecting + closing. */
int kicker = socket(AF_INET, SOCK_STREAM, 0);
if (kicker >= 0) {
struct sockaddr_in addr = {0};
addr.sin_family = AF_INET;
addr.sin_addr.s_addr = htonl(INADDR_LOOPBACK);
addr.sin_port = htons(srv_port);
connect(kicker, (struct sockaddr *)&addr, sizeof addr);
close(kicker);
}
pthread_join(srv_thread, NULL);
if (srv_fd >= 0) close(srv_fd);
/* Drain any residual queue + captures. */
pthread_mutex_lock(&q_mu);
while (q_resp_head != q_resp_tail) {
free(q_resp[q_resp_head].content_type);
free(q_resp[q_resp_head].body);
q_resp_head = (q_resp_head + 1) % MAX_RESPONSES;
}
for (int i = 0; i < captured_count; ++i) {
free(captured[i].auth);
free(captured[i].content_type);
free(captured[i].body);
}
captured_count = 0;
pthread_mutex_unlock(&q_mu);
}
static char *make_base_url(void) {
static char buf[64];
snprintf(buf, sizeof buf, "http://127.0.0.1:%d", srv_port);
return buf;
}
static void reset_captures(void) {
pthread_mutex_lock(&q_mu);
for (int i = 0; i < captured_count; ++i) {
free(captured[i].auth);
free(captured[i].content_type);
free(captured[i].body);
}
captured_count = 0;
pthread_mutex_unlock(&q_mu);
}
/* ------------------------------------------------------------------ */
/* tests */
/* ------------------------------------------------------------------ */
static void t_version(void) {
TEST("version");
CHECK_STR_EQ(cf_version(), "0.1.0");
CHECK_STR_EQ(cf_status_str(CF_OK), "CF_OK");
CHECK_STR_EQ(cf_status_str(CF_ERR_PARSE), "CF_ERR_PARSE");
}
static void t_client_lifecycle(void) {
TEST("client_lifecycle");
cf_client_t *c = cf_client_new("http://127.0.0.1:1/", "tok");
CHECK(c != NULL);
cf_client_set_timeout_secs(c, 30);
CHECK(cf_client_set_admin_token(c, "admintok") == CF_OK);
cf_client_free(c);
/* NULL base_url rejected. */
CHECK(cf_client_new(NULL, "x") == NULL);
CHECK(cf_client_new("", "x") == NULL);
}
static void t_healthz_ok(void) {
TEST("healthz_ok");
reset_captures();
enqueue_response(200, "application/json",
"{\"ok\":true,\"claude_present\":true,\"claude_version\":\"1.2.3\"}");
cf_client_t *c = cf_client_new(make_base_url(), "tok");
cf_client_set_timeout_secs(c, 5);
cf_healthz_t h = {0};
cf_error_t err = {0};
cf_status_t s = cf_healthz(c, &h, &err);
CHECK(s == CF_OK);
CHECK(h.ok == 1);
CHECK(h.claude_present == 1);
CHECK_STR_EQ(h.claude_version, "1.2.3");
cf_healthz_free(&h);
cf_error_free(&err);
cf_client_free(c);
/* /healthz must NOT have an Auth header — server says it doesn't
* require one and the client doesn't promise one. (Our code does
* inject the bearer token when set; that's also fine server
* ignores. Just verify the path was correct.) */
CHECK_STR_EQ(captured[0].method, "GET");
CHECK_STR_EQ(captured[0].path, "/healthz");
}
static void t_healthz_transport_fail(void) {
TEST("healthz_transport_fail");
cf_client_t *c = cf_client_new("http://127.0.0.1:1", "tok");
cf_client_set_timeout_secs(c, 2);
cf_healthz_t h = {0};
cf_error_t err = {0};
cf_status_t s = cf_healthz(c, &h, &err);
CHECK(s == CF_ERR_TRANSPORT);
CHECK(err.message && err.message[0] != '\0');
cf_error_free(&err);
cf_client_free(c);
}
static void t_run_success_object(void) {
TEST("run_success_object");
reset_captures();
enqueue_response(200, "application/json",
"{\"ok\":true,"
"\"result\":{\"hello\":\"world\",\"n\":7},"
"\"duration_ms\":1234,"
"\"stop_reason\":\"end_turn\"}");
cf_client_t *c = cf_client_new(make_base_url(), "cf_abc");
cf_run_request_t req = {
.prompt = "Reply with JSON",
.model = "sonnet",
.timeout_secs = 30,
};
cf_run_result_t res = {0};
cf_error_t err = {0};
cf_status_t s = cf_run(c, &req, &res, &err);
CHECK(s == CF_OK);
CHECK(res.duration_ms == 1234);
CHECK(res.result_is_string == 0);
CHECK_STR_EQ(res.stop_reason, "end_turn");
CHECK(res.result_json != NULL);
/* result_json should be parseable by cJSON. */
cJSON *parsed = (cJSON *)cf_run_result_as_cjson(&res);
CHECK(parsed != NULL);
cJSON *hello = cJSON_GetObjectItemCaseSensitive(parsed, "hello");
CHECK(cJSON_IsString(hello));
if (cJSON_IsString(hello)) CHECK_STR_EQ(hello->valuestring, "world");
cJSON_Delete(parsed);
/* Verify request body sent the right shape. */
CHECK_STR_EQ(captured[0].method, "POST");
CHECK_STR_EQ(captured[0].path, "/run");
CHECK(captured[0].auth != NULL);
CHECK_STR_EQ(captured[0].auth, "Bearer cf_abc");
cJSON *sent = cJSON_Parse(captured[0].body);
CHECK(sent != NULL);
if (sent) {
cJSON *prompt = cJSON_GetObjectItemCaseSensitive(sent, "prompt");
cJSON *model = cJSON_GetObjectItemCaseSensitive(sent, "model");
cJSON *to = cJSON_GetObjectItemCaseSensitive(sent, "timeout_secs");
CHECK(cJSON_IsString(prompt));
if (cJSON_IsString(prompt)) CHECK_STR_EQ(prompt->valuestring, "Reply with JSON");
CHECK(cJSON_IsString(model));
if (cJSON_IsString(model)) CHECK_STR_EQ(model->valuestring, "sonnet");
CHECK(cJSON_IsNumber(to));
if (cJSON_IsNumber(to)) CHECK((int)to->valuedouble == 30);
cJSON_Delete(sent);
}
cf_run_result_free(&res);
cf_error_free(&err);
cf_client_free(c);
}
static void t_run_success_string(void) {
TEST("run_success_string");
reset_captures();
enqueue_response(200, "application/json",
"{\"ok\":true,"
"\"result\":\"plain text reply\","
"\"duration_ms\":42,"
"\"stop_reason\":\"end_turn\"}");
cf_client_t *c = cf_client_new(make_base_url(), "tok");
cf_run_request_t req = { .prompt = "hi" };
cf_run_result_t res = {0};
cf_error_t err = {0};
CHECK(cf_run(c, &req, &res, &err) == CF_OK);
CHECK(res.result_is_string == 1);
/* result_json is the JSON-encoded string, including quotes. */
CHECK_STR_EQ(res.result_json, "\"plain text reply\"");
cf_run_result_free(&res);
cf_error_free(&err);
cf_client_free(c);
}
static void t_run_files_passed(void) {
TEST("run_files_passed");
reset_captures();
enqueue_response(200, "application/json",
"{\"ok\":true,\"result\":\"ok\",\"duration_ms\":1,\"stop_reason\":\"end_turn\"}");
const char *files[] = { "ff_aaa", "ff_bbb" };
cf_client_t *c = cf_client_new(make_base_url(), "tok");
cf_run_request_t req = {
.prompt = "extract",
.files = files,
.files_count = 2,
};
cf_run_result_t res = {0};
cf_error_t err = {0};
CHECK(cf_run(c, &req, &res, &err) == CF_OK);
cJSON *sent = cJSON_Parse(captured[0].body);
CHECK(sent != NULL);
if (sent) {
cJSON *arr = cJSON_GetObjectItemCaseSensitive(sent, "files");
CHECK(cJSON_IsArray(arr));
if (cJSON_IsArray(arr)) {
CHECK(cJSON_GetArraySize(arr) == 2);
cJSON *e0 = cJSON_GetArrayItem(arr, 0);
cJSON *e1 = cJSON_GetArrayItem(arr, 1);
CHECK(cJSON_IsString(e0));
CHECK(cJSON_IsString(e1));
if (cJSON_IsString(e0)) CHECK_STR_EQ(e0->valuestring, "ff_aaa");
if (cJSON_IsString(e1)) CHECK_STR_EQ(e1->valuestring, "ff_bbb");
}
cJSON_Delete(sent);
}
cf_run_result_free(&res);
cf_error_free(&err);
cf_client_free(c);
}
static void t_run_502_envelope(void) {
TEST("run_502_envelope");
reset_captures();
enqueue_response(502, "application/json",
"{\"ok\":false,\"error\":\"claude exited with status 1\","
"\"stderr\":\"some stderr\",\"duration_ms\":2000,\"stop_reason\":null}");
cf_client_t *c = cf_client_new(make_base_url(), "tok");
cf_run_request_t req = { .prompt = "x" };
cf_run_result_t res = {0};
cf_error_t err = {0};
cf_status_t s = cf_run(c, &req, &res, &err);
CHECK(s == CF_ERR_API);
CHECK(err.http_status == 502);
CHECK(err.message != NULL);
CHECK(strstr(err.message, "claude exited") != NULL);
cf_run_result_free(&res);
cf_error_free(&err);
cf_client_free(c);
}
static void t_run_401_auth(void) {
TEST("run_401_auth");
reset_captures();
enqueue_response(401, "application/json", "{\"detail\":\"invalid token\"}");
cf_client_t *c = cf_client_new(make_base_url(), "wrong");
cf_run_request_t req = { .prompt = "x" };
cf_run_result_t res = {0};
cf_error_t err = {0};
cf_status_t s = cf_run(c, &req, &res, &err);
CHECK(s == CF_ERR_AUTH);
CHECK(err.http_status == 401);
CHECK(err.message != NULL);
CHECK(strstr(err.message, "invalid token") != NULL);
cf_run_result_free(&res);
cf_error_free(&err);
cf_client_free(c);
}
static void t_run_usage_errors(void) {
TEST("run_usage_errors");
cf_client_t *c = cf_client_new("http://127.0.0.1:1", "tok");
cf_run_request_t req = { .prompt = "" };
cf_run_result_t res = {0};
cf_error_t err = {0};
cf_status_t s = cf_run(c, &req, &res, &err);
CHECK(s == CF_ERR_USAGE);
CHECK(err.message != NULL);
cf_error_free(&err);
/* NULL request */
s = cf_run(c, NULL, &res, &err);
CHECK(s == CF_ERR_USAGE);
cf_error_free(&err);
cf_client_free(c);
}
static void t_upload_file(void) {
TEST("upload_file");
reset_captures();
enqueue_response(200, "application/json",
"{\"file_token\":\"ff_xyz\",\"ttl_secs\":600,\"size\":11}");
/* Write a tmp file. */
char tmpl[] = "/tmp/cf-c-test-XXXXXX";
int fd = mkstemp(tmpl);
CHECK(fd >= 0);
if (fd >= 0) {
ssize_t w = write(fd, "hello world", 11);
(void)w;
close(fd);
}
cf_client_t *c = cf_client_new(make_base_url(), "tok");
cf_file_token_t ft = {0};
cf_error_t err = {0};
cf_status_t s = cf_upload_file(c, tmpl, 600, &ft, &err);
CHECK(s == CF_OK);
CHECK_STR_EQ(ft.file_token, "ff_xyz");
CHECK(ft.ttl_secs == 600);
CHECK(ft.size == 11);
/* Verify request shape: POST /files, multipart, contains file content. */
CHECK_STR_EQ(captured[0].method, "POST");
CHECK_STR_EQ(captured[0].path, "/files");
CHECK(captured[0].content_type != NULL);
if (captured[0].content_type) {
CHECK(strstr(captured[0].content_type, "multipart/form-data") != NULL);
}
CHECK(captured[0].body != NULL);
if (captured[0].body) {
CHECK(find_bytes(captured[0].body, captured[0].body_len, "hello world", 11) != NULL);
CHECK(find_bytes(captured[0].body, captured[0].body_len, "ttl_secs", 8) != NULL);
}
cf_file_token_free(&ft);
cf_error_free(&err);
cf_client_free(c);
unlink(tmpl);
}
static void t_admin_create_token(void) {
TEST("admin_create_token");
reset_captures();
enqueue_response(200, "application/json",
"{\"name\":\"cauldron\",\"token\":\"cf_secret_xxx\","
"\"ip_cidrs\":[\"172.24.0.0/16\",\"10.0.0.0/8\"]}");
cf_client_t *c = cf_client_new(make_base_url(), NULL);
CHECK(cf_client_set_admin_token(c, "admin_bootstrap_token") == CF_OK);
const char *cidrs[] = { "172.24.0.0/16", "10.0.0.0/8" };
cf_admin_token_t out = {0};
cf_error_t err = {0};
cf_status_t s = cf_admin_create_token(c, "cauldron", cidrs, 2, &out, &err);
CHECK(s == CF_OK);
CHECK_STR_EQ(out.name, "cauldron");
CHECK_STR_EQ(out.token, "cf_secret_xxx");
CHECK(out.ip_cidrs_count == 2);
if (out.ip_cidrs_count == 2) {
CHECK_STR_EQ(out.ip_cidrs[0], "172.24.0.0/16");
CHECK_STR_EQ(out.ip_cidrs[1], "10.0.0.0/8");
}
/* Verify auth was the admin token. */
CHECK_STR_EQ(captured[0].auth, "Bearer admin_bootstrap_token");
cf_admin_token_free(&out);
cf_error_free(&err);
cf_client_free(c);
}
static void t_admin_list_revoke(void) {
TEST("admin_list_revoke");
reset_captures();
enqueue_response(200, "application/json",
"{\"tokens\":["
"{\"name\":\"a\",\"ip_cidrs\":[\"127.0.0.1/32\"],\"created_at\":1700000000,\"last_used_at\":1700000100},"
"{\"name\":\"b\",\"ip_cidrs\":[],\"created_at\":1700000200}"
"]}");
cf_client_t *c = cf_client_new(make_base_url(), NULL);
cf_client_set_admin_token(c, "admin");
cf_admin_token_list_t list = {0};
cf_error_t err = {0};
CHECK(cf_admin_list_tokens(c, &list, &err) == CF_OK);
CHECK(list.count == 2);
if (list.count == 2) {
CHECK_STR_EQ(list.items[0].name, "a");
CHECK(list.items[0].ip_cidrs_count == 1);
if (list.items[0].ip_cidrs_count == 1) {
CHECK_STR_EQ(list.items[0].ip_cidrs[0], "127.0.0.1/32");
}
CHECK(list.items[0].created_at == 1700000000);
CHECK(list.items[0].last_used_at == 1700000100);
CHECK_STR_EQ(list.items[1].name, "b");
CHECK(list.items[1].ip_cidrs_count == 0);
}
cf_admin_token_list_free(&list);
cf_error_free(&err);
/* Revoke. */
enqueue_response(200, "application/json", "{\"ok\":true}");
CHECK(cf_admin_revoke_token(c, "a", &err) == CF_OK);
CHECK_STR_EQ(captured[1].method, "DELETE");
CHECK_STR_EQ(captured[1].path, "/admin/tokens/a");
/* Revoke missing -> 404 */
enqueue_response(404, "application/json", "{\"detail\":\"no such token\"}");
cf_status_t s = cf_admin_revoke_token(c, "nope", &err);
CHECK(s == CF_ERR_API);
CHECK(err.http_status == 404);
CHECK(err.message && strstr(err.message, "no such token") != NULL);
cf_error_free(&err);
cf_client_free(c);
}
static void t_admin_requires_token(void) {
TEST("admin_requires_token");
cf_client_t *c = cf_client_new("http://127.0.0.1:1", NULL);
cf_admin_token_list_t list = {0};
cf_error_t err = {0};
cf_status_t s = cf_admin_list_tokens(c, &list, &err);
CHECK(s == CF_ERR_USAGE);
CHECK(err.message != NULL);
cf_error_free(&err);
cf_client_free(c);
}
static void t_url_normalisation(void) {
TEST("url_normalisation");
reset_captures();
enqueue_response(200, "application/json",
"{\"ok\":true,\"claude_present\":false}");
/* Trailing slash should be stripped. */
char base_with_slash[64];
snprintf(base_with_slash, sizeof base_with_slash, "%s/", make_base_url());
cf_client_t *c = cf_client_new(base_with_slash, "tok");
cf_healthz_t h = {0};
cf_error_t err = {0};
CHECK(cf_healthz(c, &h, &err) == CF_OK);
CHECK_STR_EQ(captured[0].path, "/healthz");
cf_healthz_free(&h);
cf_error_free(&err);
cf_client_free(c);
}
/* ------------------------------------------------------------------ */
/* main */
/* ------------------------------------------------------------------ */
int main(void) {
/* Don't die on SIGPIPE if a peer hangs up. */
signal(SIGPIPE, SIG_IGN);
if (start_server() != 0) {
fprintf(stderr, "FATAL: could not start mock server\n");
return 2;
}
t_version();
t_client_lifecycle();
t_healthz_ok();
t_healthz_transport_fail();
t_run_success_object();
t_run_success_string();
t_run_files_passed();
t_run_502_envelope();
t_run_401_auth();
t_run_usage_errors();
t_upload_file();
t_admin_create_token();
t_admin_list_revoke();
t_admin_requires_token();
t_url_normalisation();
stop_server();
fprintf(stderr, "\n%d tests, %d failures\n", g_tests, g_failures);
return g_failures == 0 ? 0 : 1;
}