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:
parent
09aca5813a
commit
a69e924592
14 changed files with 6113 additions and 0 deletions
4
.gitignore
vendored
4
.gitignore
vendored
|
|
@ -20,3 +20,7 @@ clients/rust/Cargo.lock
|
||||||
|
|
||||||
# Java
|
# Java
|
||||||
clients/java/target/
|
clients/java/target/
|
||||||
|
|
||||||
|
# C SDK
|
||||||
|
clients/c/build/
|
||||||
|
clients/c/build-*/
|
||||||
|
|
|
||||||
149
clients/c/CMakeLists.txt
Normal file
149
clients/c/CMakeLists.txt
Normal 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
255
clients/c/README.md
Normal 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.
|
||||||
69
clients/c/examples/basic.c
Normal file
69
clients/c/examples/basic.c
Normal 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;
|
||||||
|
}
|
||||||
282
clients/c/include/clawdforge.h
Normal file
282
clients/c/include/clawdforge.h
Normal 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 */
|
||||||
15
clients/c/pkgconfig/clawdforge.pc.in
Normal file
15
clients/c/pkgconfig/clawdforge.pc.in
Normal 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}
|
||||||
20
clients/c/src/cjson/LICENSE
Normal file
20
clients/c/src/cjson/LICENSE
Normal 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
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
293
clients/c/src/cjson/cJSON.h
Normal 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
605
clients/c/src/client.c
Normal 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
271
clients/c/src/http.c
Normal 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
99
clients/c/src/internal.h
Normal 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
144
clients/c/src/json.c
Normal 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;
|
||||||
|
}
|
||||||
797
clients/c/tests/test_client.c
Normal file
797
clients/c/tests/test_client.c
Normal 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;
|
||||||
|
}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue