ffi/curl: blocking POST with header list and response capture
Phase 0 binding per PHASE0.md §6. M.post(url, body, headers, timeout_ms)
uses CURLOPT_{URL, POST, POSTFIELDS, HTTPHEADER, WRITEFUNCTION, NOSIGNAL,
TIMEOUT_MS, USERAGENT} on a fresh easy handle, capturing the response
into a Lua string via a closure-based WRITEFUNCTION callback.
curl_easy_setopt is variadic; LuaJIT's variadic FFI dispatch needs
ffi.new() per argument otherwise. Pre-cast to three concrete signatures
(long / void* / const char*) bypasses that — cleaner and matches the
lua-curl idiom.
Robust loader: tries `curl`, `curl.so.4`, `curl-gnutls.so.4` so a
runtime-only host (no libcurl-dev installed) just works. Same idiom
as ffi/readline.
Smoke against a local nc listener: request was correctly framed
(POST path, Content-Type + X-Test headers, Content-Length matches
JSON body length) and the canned response was captured into the
returned Lua string.
SSE streaming for Phase 1 reuses this same WRITEFUNCTION hook —
chunks arrive incrementally, the closure consumes them as they come.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
+102
-3
@@ -1,16 +1,115 @@
|
||||
-- ffi/curl.lua — libcurl easy interface binding.
|
||||
-- Phase 0: blocking POST. Phase 1: SSE streaming via WRITEFUNCTION callback.
|
||||
-- Phase 0: blocking POST with header list and response capture into Lua string.
|
||||
-- Phase 1: SSE streaming via streaming WRITEFUNCTION (this same callback hook).
|
||||
-- See docs/PHASE0.md §6.
|
||||
|
||||
local ffi = require("ffi")
|
||||
|
||||
ffi.cdef[[
|
||||
typedef void CURL;
|
||||
|
||||
struct curl_slist {
|
||||
char *data;
|
||||
struct curl_slist *next;
|
||||
};
|
||||
|
||||
CURL *curl_easy_init(void);
|
||||
void curl_easy_cleanup(CURL *handle);
|
||||
int curl_easy_setopt(CURL *handle, int option, ...);
|
||||
int curl_easy_perform(CURL *handle);
|
||||
const char *curl_easy_strerror(int code);
|
||||
|
||||
struct curl_slist *curl_slist_append(struct curl_slist *list, const char *string);
|
||||
void curl_slist_free_all(struct curl_slist *list);
|
||||
|
||||
int curl_easy_setopt(CURL *handle, int option, ...);
|
||||
]]
|
||||
|
||||
-- libcurl-dev's unversioned `libcurl.so` symlink isn't assumed; fall back to
|
||||
-- versioned sonames so a runtime-only host (Debian without -dev) just works.
|
||||
local function load_curl()
|
||||
local errs = {}
|
||||
for _, name in ipairs({"curl", "curl.so.4", "curl-gnutls.so.4"}) do
|
||||
local ok, lib = pcall(ffi.load, name)
|
||||
if ok then return lib end
|
||||
errs[#errs+1] = name .. ": " .. tostring(lib)
|
||||
end
|
||||
error("libcurl not loadable: " .. table.concat(errs, "; "))
|
||||
end
|
||||
|
||||
local C = load_curl()
|
||||
|
||||
-- CURLoption codes from curl/curl.h. The bases are:
|
||||
-- CURLOPTTYPE_LONG = 0
|
||||
-- CURLOPTTYPE_OBJECTPOINT = 10000
|
||||
-- CURLOPTTYPE_FUNCTIONPOINT = 20000
|
||||
local OPT = {
|
||||
URL = 10002,
|
||||
POST = 47,
|
||||
POSTFIELDS = 10015,
|
||||
HTTPHEADER = 10023,
|
||||
WRITEFUNCTION = 20011,
|
||||
NOSIGNAL = 99,
|
||||
TIMEOUT_MS = 155,
|
||||
USERAGENT = 10018,
|
||||
}
|
||||
|
||||
-- Variadic FFI calls demand explicit per-argument types. Pre-cast setopt to
|
||||
-- the three concrete signatures Phase 0 needs; bypasses libffi-flavoured
|
||||
-- variadic dispatch entirely.
|
||||
local setopt_str = ffi.cast("int(*)(void*, int, const char*)", C.curl_easy_setopt)
|
||||
local setopt_long = ffi.cast("int(*)(void*, int, long)", C.curl_easy_setopt)
|
||||
local setopt_ptr = ffi.cast("int(*)(void*, int, void*)", C.curl_easy_setopt)
|
||||
|
||||
local M = {}
|
||||
-- Phase 0 stubs; full binding lands with broker.chat() implementation.
|
||||
|
||||
-- POST `body` to `url` with `headers` (list of "Name: value" strings) and an
|
||||
-- optional `timeout_ms`.
|
||||
-- Returns:
|
||||
-- string response body on success
|
||||
-- nil, errmsg on libcurl failure (non-zero CURLcode)
|
||||
function M.post(url, body, headers, timeout_ms)
|
||||
local handle = C.curl_easy_init()
|
||||
if handle == nil then return nil, "curl_easy_init returned NULL" end
|
||||
|
||||
local chunks = {}
|
||||
local write_cb = ffi.cast(
|
||||
"size_t(*)(char*, size_t, size_t, void*)",
|
||||
function(ptr, size, nmemb, _)
|
||||
local n = tonumber(size) * tonumber(nmemb)
|
||||
chunks[#chunks+1] = ffi.string(ptr, n)
|
||||
return n
|
||||
end)
|
||||
|
||||
local slist = nil
|
||||
for _, h in ipairs(headers or {}) do
|
||||
slist = C.curl_slist_append(slist, h)
|
||||
end
|
||||
|
||||
setopt_str (handle, OPT.URL, url)
|
||||
setopt_long(handle, OPT.POST, 1)
|
||||
setopt_str (handle, OPT.POSTFIELDS, body)
|
||||
setopt_ptr (handle, OPT.HTTPHEADER, slist)
|
||||
setopt_ptr (handle, OPT.WRITEFUNCTION, write_cb)
|
||||
setopt_long(handle, OPT.NOSIGNAL, 1)
|
||||
setopt_str (handle, OPT.USERAGENT, "aish/0.0 (luajit-ffi)")
|
||||
if timeout_ms then
|
||||
setopt_long(handle, OPT.TIMEOUT_MS, timeout_ms)
|
||||
end
|
||||
|
||||
local rc = C.curl_easy_perform(handle)
|
||||
local result, err
|
||||
if rc == 0 then
|
||||
result = table.concat(chunks)
|
||||
else
|
||||
err = ffi.string(C.curl_easy_strerror(rc))
|
||||
end
|
||||
|
||||
C.curl_easy_cleanup(handle)
|
||||
if slist ~= nil then C.curl_slist_free_all(slist) end
|
||||
write_cb:free()
|
||||
|
||||
if rc == 0 then return result end
|
||||
return nil, err
|
||||
end
|
||||
|
||||
return M
|
||||
|
||||
Reference in New Issue
Block a user