merge issue/0110: http_request_cpp_core + http_get_json_cpp_core

# Conflicts:
#	cpp/tests/CMakeLists.txt
This commit is contained in:
2026-05-18 18:18:03 +02:00
12 changed files with 25790 additions and 0 deletions
+41
View File
@@ -0,0 +1,41 @@
// http_get_json.cpp — wrapper over fn_http::request that returns parsed JSON.
// Issue 0110.
#include "core/http_get_json.h"
#include "core/http_request.h"
#include <stdexcept>
#include <string>
namespace fn_http {
nlohmann::json get_json(const std::string& url,
const std::string& bearer_token,
int timeout_ms) {
Request req;
req.method = "GET";
req.url = url;
req.timeout_ms = timeout_ms;
req.bearer_token = bearer_token;
req.headers = {{"Accept", "application/json"}};
Response res = request(req);
if (!res.error.empty()) {
throw std::runtime_error("http_get_json: transport error: " + res.error);
}
if (res.status / 100 != 2) {
throw std::runtime_error(
"http_get_json: HTTP " + std::to_string(res.status) +
" from " + url +
(res.body.empty() ? std::string() : ": " + res.body.substr(0, 200)));
}
try {
return nlohmann::json::parse(res.body);
} catch (const nlohmann::json::parse_error& e) {
throw std::runtime_error(
"http_get_json: invalid JSON from " + url + ": " + e.what());
}
}
} // namespace fn_http
+26
View File
@@ -0,0 +1,26 @@
// http_get_json.h — convenience wrapper over fn_http::request for "GET JSON".
//
// Issue 0110. Throws on transport error or non-2xx status. Parses the body
// with nlohmann::json. For full control (POST, custom headers, status
// inspection), use fn_http::request directly.
#pragma once
#include "nlohmann/json.hpp"
#include <stdexcept>
#include <string>
namespace fn_http {
// GET <url>, expect 2xx + JSON body. Throws std::runtime_error on:
// - transport failure (Response.status == 0)
// - non-2xx HTTP status
// - body fails to parse as JSON
//
// `bearer_token` is optional. Pass empty string for unauthenticated.
// `timeout_ms` default 5000.
nlohmann::json get_json(const std::string& url,
const std::string& bearer_token = "",
int timeout_ms = 5000);
} // namespace fn_http
+74
View File
@@ -0,0 +1,74 @@
---
name: http_get_json
kind: function
lang: cpp
domain: core
version: "1.0.0"
purity: impure
signature: "nlohmann::json fn_http::get_json(const std::string& url, const std::string& bearer_token = \"\", int timeout_ms = 5000)"
description: "Convenience wrapper over fn_http::request for the common case: GET <url> with optional Bearer auth, expect 2xx, parse body as JSON. Returns nlohmann::json. Throws std::runtime_error on transport failure, non-2xx status, or JSON parse error. For non-GET, custom headers, or status-aware control flow use http_request directly."
tags: [http, json, client, curl, network, registry-gap, core, helper]
uses_functions:
- http_request_cpp_core
uses_types: []
returns: []
returns_optional: false
error_type: "error_go_core"
imports:
- "core/http_get_json.h"
tested: true
tests:
- "get_json: parses 200 JSON body"
- "get_json: throws on 404"
- "get_json: bearer_token reaches server"
- "get_json: throws on invalid JSON"
test_file_path: "cpp/tests/test_http_get_json.cpp"
file_path: "cpp/functions/core/http_get_json.cpp"
params:
- name: url
desc: "Full URL incl. scheme (https://api.example.com/path?q=1)."
- name: bearer_token
desc: "Optional. If non-empty, sent as Authorization: Bearer <token>. Pass empty string for unauthenticated."
- name: timeout_ms
desc: "Hard timeout. Default 5000ms."
output: "nlohmann::json — the parsed body. Whatever shape the endpoint returns (object, array, scalar). Use json[\"key\"] / .at() / .get<T>() to traverse. Throws std::runtime_error on any failure."
---
## Ejemplo
```cpp
#include "core/http_get_json.h"
try {
auto data = fn_http::get_json("https://httpbin.org/get");
std::cout << "url echoed: " << data["url"].get<std::string>() << "\n";
} catch (const std::exception& e) {
std::cerr << "fetch failed: " << e.what() << "\n";
}
```
Con Bearer token + timeout corto (sqlite_api `/api/databases`):
```cpp
auto dbs = fn_http::get_json(
"http://localhost:8484/api/databases",
std::getenv("SQLITE_API_TOKEN"),
2000);
for (const auto& db : dbs) {
std::cout << db["name"].get<std::string>() << "\n";
}
```
## Cuando usarla
- Cuando una app C++ necesita GET a un endpoint JSON y prefiere fail-fast (throw) en vez de inspeccionar `Response.status` a mano.
- Para pollers / heartbeats / health checks contra APIs internas (sqlite_api, services_api, registry_api).
- NO usar cuando necesites POST, headers custom, o distinguir entre 404 y 500 sin try/catch — usa `http_request` directo.
## Gotchas
- **Throws**, no devuelve un Result. Si llamas en un loop tight, envuelve en try/catch o tu app peta al primer 5xx.
- **Hereda gotchas de `http_request`**: requiere `curl` en PATH, sin streaming, sin cookies, TLS verify activo por defecto.
- **No reusa `get_json` cuando un endpoint devuelve algo que NO es JSON** (HTML, texto plano). La excepcion sera `invalid JSON` y perderas el status real. Usa `http_request` para esos casos.
- **`bearer_token` vacio es valido** (no se manda header). No pasar el `Authorization: Bearer ` literal — eso ya lo construye `http_request`.
- **No se ofrece `post_json`** intencionalmente — la combinatoria de POST + JSON + auth + retry es lo suficientemente rica como para que `http_request` directo sea mas claro que un wrapper con N parametros opcionales.
+211
View File
@@ -0,0 +1,211 @@
// http_request.cpp — generic HTTP via cURL CLI (popen).
//
// Issue 0110. Same transport strategy as cpp/functions/core/llm_anthropic.cpp
// (curl in PATH, request body written to tmp file, response captured to tmp
// file, headers + body separated by a marker). No link-time libcurl.
#include "core/http_request.h"
#include <chrono>
#include <cstdio>
#include <cstdlib>
#include <cstring>
#include <sstream>
#include <string>
#include <vector>
namespace fn_http {
namespace {
// Shell-escape single argument for a POSIX-ish shell. Wraps in single quotes
// and escapes embedded single quotes via '\''. Used for URL + header values.
std::string sh_q(const std::string& s) {
std::string o;
o.reserve(s.size() + 2);
o += '\'';
for (char c : s) {
if (c == '\'') o += "'\\''";
else o += c;
}
o += '\'';
return o;
}
// Read entire file into string. Empty on missing.
std::string slurp(const std::string& path) {
std::string out;
FILE* f = std::fopen(path.c_str(), "rb");
if (!f) return out;
char buf[4096];
size_t n;
while ((n = std::fread(buf, 1, sizeof(buf), f)) > 0) out.append(buf, n);
std::fclose(f);
return out;
}
// Parse the headers block written by `curl -D <file>`. Each non-empty line
// is "Key: Value". The first line is the HTTP status line ("HTTP/1.1 200 OK")
// which we skip. Folded headers (RFC 7230 obsolete) are not handled — modern
// servers never emit them.
std::vector<std::pair<std::string, std::string>>
parse_headers(const std::string& raw) {
std::vector<std::pair<std::string, std::string>> out;
size_t i = 0;
while (i < raw.size()) {
size_t eol = raw.find('\n', i);
if (eol == std::string::npos) eol = raw.size();
std::string line = raw.substr(i, eol - i);
// strip trailing \r
while (!line.empty() && (line.back() == '\r' || line.back() == '\n')) line.pop_back();
i = eol + 1;
if (line.empty()) continue;
// Skip status line(s) (curl emits one per response when following redirects).
if (line.size() >= 5 && line.compare(0, 5, "HTTP/") == 0) continue;
size_t colon = line.find(':');
if (colon == std::string::npos) continue;
std::string k = line.substr(0, colon);
std::string v = line.substr(colon + 1);
// strip leading spaces in value
size_t vs = 0;
while (vs < v.size() && (v[vs] == ' ' || v[vs] == '\t')) ++vs;
v = v.substr(vs);
out.emplace_back(std::move(k), std::move(v));
}
return out;
}
// Extract status from the LAST "HTTP/x.y NNN ..." line in the headers dump.
int parse_status(const std::string& raw) {
int code = 0;
size_t i = 0;
while (i < raw.size()) {
size_t eol = raw.find('\n', i);
if (eol == std::string::npos) eol = raw.size();
if (raw.compare(i, 5, "HTTP/") == 0) {
size_t sp = raw.find(' ', i);
if (sp != std::string::npos && sp < eol) {
int v = std::atoi(raw.c_str() + sp + 1);
if (v > 0) code = v;
}
}
i = eol + 1;
}
return code;
}
// Compute now-ms.
int64_t now_ms() {
using clock = std::chrono::steady_clock;
return std::chrono::duration_cast<std::chrono::milliseconds>(
clock::now().time_since_epoch())
.count();
}
} // anon
Response request(const Request& req) {
Response r;
int64_t t0 = now_ms();
if (req.url.empty()) {
r.error = "http_request: url is empty";
return r;
}
std::string method = req.method.empty() ? std::string("GET") : req.method;
// Tmp files: request body + response body + response headers.
std::string tmp_body_in = std::tmpnam(nullptr);
std::string tmp_body_out = std::tmpnam(nullptr);
std::string tmp_hdr_out = std::tmpnam(nullptr);
bool have_body = !req.body.empty();
if (have_body) {
FILE* f = std::fopen(tmp_body_in.c_str(), "wb");
if (!f) {
r.error = "http_request: cannot write tmp body file";
return r;
}
std::fwrite(req.body.data(), 1, req.body.size(), f);
std::fclose(f);
}
// Build curl command.
// -sS: silent + show errors
// -X <METHOD>
// -D <hdr_file>: dump response headers
// --max-time <s>: hard timeout (rounded up from ms; min 1s)
// -k: insecure (only if requested)
std::ostringstream cmd;
cmd << "curl -sS -X " << sh_q(method)
<< " -D " << sh_q(tmp_hdr_out);
int timeout_s = (req.timeout_ms + 999) / 1000;
if (timeout_s < 1) timeout_s = 1;
cmd << " --max-time " << timeout_s;
if (req.insecure) cmd << " -k";
// Headers — explicit ones first; shortcuts after so they don't override.
for (const auto& h : req.headers) {
std::string line = h.first + ": " + h.second;
cmd << " -H " << sh_q(line);
}
if (!req.bearer_token.empty()) {
std::string line = "Authorization: Bearer " + req.bearer_token;
cmd << " -H " << sh_q(line);
}
if (!req.basic_user.empty()) {
// Let curl base64-encode for us via --user.
std::string up = req.basic_user + ":" + req.basic_pass;
cmd << " --user " << sh_q(up);
}
if (have_body) {
cmd << " --data-binary @" << sh_q(tmp_body_in);
}
cmd << ' ' << sh_q(req.url)
<< " -o " << sh_q(tmp_body_out)
<< " 2>&1";
// Capture stderr (curl prints transport errors to stderr with -sS).
FILE* p = popen(cmd.str().c_str(), "r");
std::string curl_stderr;
if (p) {
char buf[1024];
while (fgets(buf, sizeof(buf), p)) curl_stderr.append(buf);
}
int rc = p ? pclose(p) : -1;
// Read response files.
r.body = slurp(tmp_body_out);
std::string hdr_raw = slurp(tmp_hdr_out);
// Cleanup.
if (have_body) std::remove(tmp_body_in.c_str());
std::remove(tmp_body_out.c_str());
std::remove(tmp_hdr_out.c_str());
r.duration_ms = now_ms() - t0;
if (rc != 0) {
// Trim trailing newline of stderr.
while (!curl_stderr.empty() &&
(curl_stderr.back() == '\n' || curl_stderr.back() == '\r')) {
curl_stderr.pop_back();
}
r.error = "curl exit " + std::to_string(rc);
if (!curl_stderr.empty()) r.error += ": " + curl_stderr;
// status stays 0 -> transport error.
return r;
}
r.status = parse_status(hdr_raw);
r.headers = parse_headers(hdr_raw);
if (r.status == 0) {
// curl exit 0 but no parseable status line — treat as transport-ish.
r.error = "http_request: no status line in headers";
}
return r;
}
} // namespace fn_http
+43
View File
@@ -0,0 +1,43 @@
// http_request.h — generic HTTP client (cURL via popen).
//
// Issue 0110. Replaces the inline curl popen patterns scattered across
// apps/services_monitor, apps/dag_engine_ui, apps/data_factory.
//
// Portable across Linux / WSL / MinGW (no link-time libcurl required —
// curl(1) must be in PATH at runtime).
#pragma once
#include <cstdint>
#include <string>
#include <utility>
#include <vector>
namespace fn_http {
struct Request {
std::string method; // "GET", "POST", "PUT", "DELETE", ...
std::string url; // full URL incl. scheme
std::vector<std::pair<std::string, std::string>> headers; // extra request headers
std::string body; // raw body bytes (JSON, form, etc.)
int timeout_ms = 5000; // hard timeout (curl --max-time)
std::string bearer_token; // shortcut: Authorization: Bearer <token>
std::string basic_user; // shortcut: Authorization: Basic base64(user:pass)
std::string basic_pass;
bool insecure = false; // skip TLS verify (curl -k) — TESTING ONLY
};
struct Response {
int status = 0; // HTTP status; 0 = transport error
std::string body; // response body bytes
std::vector<std::pair<std::string, std::string>> headers; // response headers
std::string error; // non-empty on transport / curl failure
int64_t duration_ms = 0; // wall-clock of the call
};
// Synchronously execute `req`. Never throws. On transport failure (curl
// non-zero exit, missing binary, DNS fail) `Response.status==0` and `error`
// describes the cause. On HTTP error (4xx/5xx) `status` is set normally and
// `error` is empty — callers decide whether non-2xx is a failure.
Response request(const Request& req);
} // namespace fn_http
+84
View File
@@ -0,0 +1,84 @@
---
name: http_request
kind: function
lang: cpp
domain: core
version: "1.0.0"
purity: impure
signature: "fn_http::Response fn_http::request(const fn_http::Request& req)"
description: "Generic HTTP client via cURL CLI (popen). Supports GET/POST/PUT/DELETE/PATCH, custom headers, raw body, Bearer/Basic auth shortcuts, timeout, optional TLS-verify skip. Portable across Linux / WSL / MinGW — requires only `curl` in PATH at runtime, no link-time libcurl. Returns status, body, response headers, error, duration_ms. Replaces inline curl-popen patterns in apps/services_monitor, dag_engine_ui, data_factory."
tags: [http, client, curl, network, registry-gap, core, helper]
uses_functions: []
uses_types: []
returns: []
returns_optional: false
error_type: "error_go_core"
imports:
- "core/http_request.h"
tested: true
tests:
- "request: GET 200 status + body"
- "request: GET 404 status (HTTP error not transport)"
- "request: POST with body + content-type header"
- "request: bearer_token shortcut adds Authorization header"
- "request: basic_user/basic_pass shortcut authenticates"
- "request: invalid URL surfaces transport error"
- "request: timeout_ms honored"
test_file_path: "cpp/tests/test_http_request.cpp"
file_path: "cpp/functions/core/http_request.cpp"
params:
- name: req
desc: "Request struct: method (GET/POST/PUT/DELETE/PATCH, empty defaults to GET), url (full URL incl. scheme), headers (vector of {key,value}), body (raw request body bytes), timeout_ms (hard timeout, default 5000), bearer_token (shortcut for Authorization: Bearer), basic_user + basic_pass (shortcut for HTTP Basic, curl --user), insecure (bool — skip TLS verify, TESTING ONLY)."
output: "Response struct: status (HTTP code; 0 = transport error), body (response bytes), headers (vector of {key,value} as returned by server), error (empty on success, populated on transport failure / non-zero curl exit), duration_ms (wall clock of the call)."
---
## Ejemplo
```cpp
#include "core/http_request.h"
fn_http::Request req;
req.method = "GET";
req.url = "https://httpbin.org/get";
req.headers = {{"Accept", "application/json"}};
req.timeout_ms = 3000;
fn_http::Response res = fn_http::request(req);
if (!res.error.empty()) {
fprintf(stderr, "transport error: %s\n", res.error.c_str());
} else if (res.status / 100 != 2) {
fprintf(stderr, "http %d: %s\n", res.status, res.body.c_str());
} else {
printf("OK %d (%lld ms): %.200s\n", res.status,
(long long)res.duration_ms, res.body.c_str());
}
```
POST con JSON + Bearer auth:
```cpp
fn_http::Request req;
req.method = "POST";
req.url = "https://api.example.com/items";
req.headers = {{"Content-Type", "application/json"}};
req.body = R"({"name":"hello"})";
req.bearer_token = std::getenv("API_TOKEN");
auto res = fn_http::request(req);
```
## Cuando usarla
- Cuando una app C++ necesita hablar HTTP con un endpoint (REST API local, servicio en VPS, webhook).
- Antes de copiar otra vez el patron `popen("curl -s ...")` desde `apps/services_monitor/http_client.cpp` o `apps/dag_engine_ui/main.cpp` — esto es exactamente la promocion que hace falta (issue 0110).
- Como base para wrappers JSON especificos (`http_get_json` ya existe; los siguientes deberian apilarse encima de `request`, no de curl directo).
## Gotchas
- **Requiere `curl` en `PATH`** en runtime. No hay fallback; si falta, `Response.error` lo dice y `status==0`.
- **Sin streaming**: la respuesta entera entra en memoria. No usar para downloads grandes (>50 MB).
- **`insecure=true` desactiva TLS verify** — solo para tests contra mocks self-signed. NUNCA en produccion.
- **Status `0` significa transport error** (DNS, conexion rechazada, timeout, curl ausente). Status `>=400` es respuesta HTTP normal — el caller decide si es fallo.
- **Headers son case-insensitive en HTTP**; aqui los devolvemos tal como los manda el servidor. Si necesitas lookup case-insensitive, normaliza en el caller.
- **Body multipart / form-encoded**: no implementado como shortcut. Construye el body a mano y setea `Content-Type` apropiado en `headers`.
- **Cookies**: no se mantienen sesion. Cada llamada es independiente. Si necesitas cookie jar, pasala via header `Cookie` explicito.
- **Shell injection**: los argumentos pasan via `popen` con quoting POSIX (single-quote escape). URLs y headers con comillas raras se manejan bien, pero NO pases input no confiable como `url` si el binario corre con privilegios.