merge issue/0110: http_request_cpp_core + http_get_json_cpp_core
# Conflicts: # cpp/tests/CMakeLists.txt
This commit is contained in:
@@ -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
|
||||
@@ -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
|
||||
@@ -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.
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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.
|
||||
@@ -266,6 +266,19 @@ target_compile_definitions(test_parse_md_frontmatter PRIVATE
|
||||
add_fn_test(test_compute_ring_layout test_compute_ring_layout.cpp
|
||||
${CMAKE_CURRENT_SOURCE_DIR}/../functions/core/compute_ring_layout.cpp)
|
||||
|
||||
# --- Issue 0110 — http_request: generic cURL-popen client -------------------
|
||||
# Integration test: fork+execs python3 -m http.server fixture in the test
|
||||
# process. Skips gracefully if python3 isn't available (server().pid == 0).
|
||||
add_fn_test(test_http_request test_http_request.cpp
|
||||
${CMAKE_CURRENT_SOURCE_DIR}/../functions/core/http_request.cpp)
|
||||
|
||||
# --- Issue 0110 — http_get_json: nlohmann::json wrapper over http_request ---
|
||||
add_fn_test(test_http_get_json test_http_get_json.cpp
|
||||
${CMAKE_CURRENT_SOURCE_DIR}/../functions/core/http_request.cpp
|
||||
${CMAKE_CURRENT_SOURCE_DIR}/../functions/core/http_get_json.cpp)
|
||||
target_include_directories(test_http_get_json PRIVATE
|
||||
${CMAKE_CURRENT_SOURCE_DIR}/../vendor)
|
||||
|
||||
# --- Visual golden-image diff (issue 0048) ---------------------------------
|
||||
# El binario primitives_gallery se compila con --capture; el test compara los
|
||||
# PNGs generados con los goldens en cpp/tests/golden/. Si no hay goldens o el
|
||||
|
||||
@@ -0,0 +1,145 @@
|
||||
// test_http_get_json.cpp — Catch2 integration tests for fn_http::get_json.
|
||||
// Issue 0110. Uses the same Python HTTP fixture as test_http_request.cpp but
|
||||
// scoped locally to keep tests independent (each binary owns its own server).
|
||||
#include "catch_amalgamated.hpp"
|
||||
#include "core/http_get_json.h"
|
||||
#include "core/http_request.h"
|
||||
#include "nlohmann/json.hpp"
|
||||
|
||||
#include <chrono>
|
||||
#include <cstdio>
|
||||
#include <cstdlib>
|
||||
#include <fstream>
|
||||
#include <string>
|
||||
#include <thread>
|
||||
|
||||
#ifndef _WIN32
|
||||
#include <signal.h>
|
||||
#include <sys/wait.h>
|
||||
#include <unistd.h>
|
||||
#endif
|
||||
|
||||
namespace {
|
||||
|
||||
constexpr const char* kHost = "127.0.0.1";
|
||||
|
||||
int pick_port() {
|
||||
return 47300 + (int)(getpid() % 100);
|
||||
}
|
||||
|
||||
struct PythonServer {
|
||||
pid_t pid = 0;
|
||||
int port = 0;
|
||||
|
||||
bool start() {
|
||||
port = pick_port();
|
||||
std::string script_path = std::tmpnam(nullptr);
|
||||
script_path += ".py";
|
||||
{
|
||||
std::ofstream f(script_path);
|
||||
f <<
|
||||
"import json, sys\n"
|
||||
"from http.server import BaseHTTPRequestHandler, HTTPServer\n"
|
||||
"PORT = int(sys.argv[1])\n"
|
||||
"class H(BaseHTTPRequestHandler):\n"
|
||||
" def log_message(self, *a, **k): pass\n"
|
||||
" def do_GET(self):\n"
|
||||
" if self.path.startswith('/status/'):\n"
|
||||
" code = int(self.path.rsplit('/',1)[1])\n"
|
||||
" msg = ('error %d' % code).encode()\n"
|
||||
" self.send_response(code); self.send_header('content-length',str(len(msg))); self.end_headers(); self.wfile.write(msg); return\n"
|
||||
" if self.path == '/notjson':\n"
|
||||
" self.send_response(200); self.send_header('content-type','text/plain'); self.end_headers(); self.wfile.write(b'<<not json>>'); return\n"
|
||||
" payload = {\n"
|
||||
" 'ok':True,\n"
|
||||
" 'path':self.path,\n"
|
||||
" 'auth':self.headers.get('Authorization','')\n"
|
||||
" }\n"
|
||||
" out = json.dumps(payload).encode()\n"
|
||||
" self.send_response(200); self.send_header('content-type','application/json'); self.send_header('content-length',str(len(out))); self.end_headers(); self.wfile.write(out)\n"
|
||||
"HTTPServer(('127.0.0.1', PORT), H).serve_forever()\n";
|
||||
}
|
||||
|
||||
#ifdef _WIN32
|
||||
return false;
|
||||
#else
|
||||
pid_t p = fork();
|
||||
if (p < 0) return false;
|
||||
if (p == 0) {
|
||||
FILE* dn = std::freopen("/dev/null", "r", stdin); (void)dn;
|
||||
FILE* d2 = std::freopen("/dev/null", "w", stdout); (void)d2;
|
||||
FILE* d3 = std::freopen("/dev/null", "w", stderr); (void)d3;
|
||||
execlp("python3", "python3", "-u", script_path.c_str(),
|
||||
std::to_string(port).c_str(), (char*)nullptr);
|
||||
std::_Exit(127);
|
||||
}
|
||||
pid = p;
|
||||
for (int i = 0; i < 60; ++i) {
|
||||
std::this_thread::sleep_for(std::chrono::milliseconds(50));
|
||||
fn_http::Request r;
|
||||
r.url = std::string("http://") + kHost + ":" + std::to_string(port) + "/ping";
|
||||
r.timeout_ms = 500;
|
||||
auto res = fn_http::request(r);
|
||||
if (res.status == 200) return true;
|
||||
}
|
||||
return false;
|
||||
#endif
|
||||
}
|
||||
|
||||
void stop() {
|
||||
#ifndef _WIN32
|
||||
if (pid > 0) {
|
||||
kill(pid, SIGTERM);
|
||||
int st = 0; waitpid(pid, &st, 0);
|
||||
pid = 0;
|
||||
}
|
||||
#endif
|
||||
}
|
||||
|
||||
std::string base() const {
|
||||
return std::string("http://") + kHost + ":" + std::to_string(port);
|
||||
}
|
||||
};
|
||||
|
||||
PythonServer& server() {
|
||||
static PythonServer s;
|
||||
static bool inited = false;
|
||||
if (!inited) {
|
||||
inited = true;
|
||||
s.start();
|
||||
std::atexit([] { server().stop(); });
|
||||
}
|
||||
return s;
|
||||
}
|
||||
|
||||
bool server_alive() { return server().pid > 0; }
|
||||
|
||||
} // anon
|
||||
|
||||
TEST_CASE("get_json: parses 200 JSON body", "[http_get_json]") {
|
||||
if (!server_alive()) { SUCCEED("python3 fixture not available — skipped"); return; }
|
||||
auto j = fn_http::get_json(server().base() + "/echo");
|
||||
REQUIRE(j.is_object());
|
||||
REQUIRE(j.at("ok").get<bool>() == true);
|
||||
REQUIRE(j.at("path").get<std::string>() == "/echo");
|
||||
}
|
||||
|
||||
TEST_CASE("get_json: throws on 404", "[http_get_json]") {
|
||||
if (!server_alive()) { SUCCEED("python3 fixture not available — skipped"); return; }
|
||||
REQUIRE_THROWS_AS(
|
||||
fn_http::get_json(server().base() + "/status/404"),
|
||||
std::runtime_error);
|
||||
}
|
||||
|
||||
TEST_CASE("get_json: bearer_token reaches server", "[http_get_json]") {
|
||||
if (!server_alive()) { SUCCEED("python3 fixture not available — skipped"); return; }
|
||||
auto j = fn_http::get_json(server().base() + "/whoami", "tok_42");
|
||||
REQUIRE(j.at("auth").get<std::string>() == "Bearer tok_42");
|
||||
}
|
||||
|
||||
TEST_CASE("get_json: throws on invalid JSON", "[http_get_json]") {
|
||||
if (!server_alive()) { SUCCEED("python3 fixture not available — skipped"); return; }
|
||||
REQUIRE_THROWS_AS(
|
||||
fn_http::get_json(server().base() + "/notjson"),
|
||||
std::runtime_error);
|
||||
}
|
||||
@@ -0,0 +1,231 @@
|
||||
// test_http_request.cpp — Catch2 integration tests for fn_http::request.
|
||||
// Issue 0110.
|
||||
//
|
||||
// Strategy: spin up a local Python HTTP server fixture (stdlib BaseHTTPServer)
|
||||
// in a background process for the duration of the suite. Tests hit
|
||||
// http://127.0.0.1:<PORT> instead of httpbin.org so the CI is deterministic
|
||||
// and works offline. The server echoes method, headers, and body as JSON.
|
||||
#include "catch_amalgamated.hpp"
|
||||
#include "core/http_request.h"
|
||||
|
||||
#include <chrono>
|
||||
#include <cstdio>
|
||||
#include <cstdlib>
|
||||
#include <fstream>
|
||||
#include <string>
|
||||
#include <sys/types.h>
|
||||
#include <thread>
|
||||
|
||||
#ifndef _WIN32
|
||||
#include <signal.h>
|
||||
#include <sys/wait.h>
|
||||
#include <unistd.h>
|
||||
#endif
|
||||
|
||||
namespace {
|
||||
|
||||
constexpr const char* kHost = "127.0.0.1";
|
||||
|
||||
// Random-ish port in the upper unprivileged range. Tests are independent —
|
||||
// each suite picks one. Add jitter from PID to reduce collision risk.
|
||||
int pick_port() {
|
||||
int base = 47200;
|
||||
int jitter = (int)(getpid() % 100);
|
||||
return base + jitter;
|
||||
}
|
||||
|
||||
struct PythonServer {
|
||||
pid_t pid = 0;
|
||||
int port = 0;
|
||||
|
||||
bool start() {
|
||||
port = pick_port();
|
||||
// Write the server script to a tmp file.
|
||||
std::string script_path = std::tmpnam(nullptr);
|
||||
script_path += ".py";
|
||||
{
|
||||
std::ofstream f(script_path);
|
||||
f <<
|
||||
"import json, sys\n"
|
||||
"from http.server import BaseHTTPRequestHandler, HTTPServer\n"
|
||||
"PORT = int(sys.argv[1])\n"
|
||||
"class H(BaseHTTPRequestHandler):\n"
|
||||
" def log_message(self, *a, **k): pass\n"
|
||||
" def _echo(self):\n"
|
||||
" n = int(self.headers.get('Content-Length','0') or '0')\n"
|
||||
" body = self.rfile.read(n).decode('utf-8','replace') if n else ''\n"
|
||||
" payload = {\n"
|
||||
" 'method': self.command,\n"
|
||||
" 'path': self.path,\n"
|
||||
" 'headers':{k:v for k,v in self.headers.items()},\n"
|
||||
" 'body': body,\n"
|
||||
" }\n"
|
||||
" out = json.dumps(payload).encode()\n"
|
||||
" self.send_response(200); self.send_header('content-type','application/json')\n"
|
||||
" self.send_header('x-fn-test','ok')\n"
|
||||
" self.send_header('content-length', str(len(out)))\n"
|
||||
" self.end_headers(); self.wfile.write(out)\n"
|
||||
" def do_GET(self):\n"
|
||||
" if self.path.startswith('/status/'):\n"
|
||||
" code = int(self.path.rsplit('/',1)[1])\n"
|
||||
" msg = ('error %d' % code).encode()\n"
|
||||
" self.send_response(code); self.send_header('content-length', str(len(msg))); self.end_headers(); self.wfile.write(msg); return\n"
|
||||
" if self.path == '/notjson':\n"
|
||||
" self.send_response(200); self.send_header('content-type','text/plain'); self.end_headers(); self.wfile.write(b'<<not json>>'); return\n"
|
||||
" if self.path.startswith('/slow/'):\n"
|
||||
" import time; time.sleep(float(self.path.rsplit('/',1)[1]));\n"
|
||||
" self._echo()\n"
|
||||
" def do_POST(self): self._echo()\n"
|
||||
" def do_PUT(self): self._echo()\n"
|
||||
" def do_DELETE(self):self._echo()\n"
|
||||
"HTTPServer(('127.0.0.1', PORT), H).serve_forever()\n";
|
||||
}
|
||||
|
||||
#ifdef _WIN32
|
||||
return false; // tests skipped on Windows for now
|
||||
#else
|
||||
pid_t p = fork();
|
||||
if (p < 0) return false;
|
||||
if (p == 0) {
|
||||
// child — redirect stdio to /dev/null and exec python3
|
||||
FILE* dn = std::freopen("/dev/null", "r", stdin); (void)dn;
|
||||
FILE* d2 = std::freopen("/dev/null", "w", stdout); (void)d2;
|
||||
FILE* d3 = std::freopen("/dev/null", "w", stderr); (void)d3;
|
||||
execlp("python3", "python3", "-u", script_path.c_str(),
|
||||
std::to_string(port).c_str(), (char*)nullptr);
|
||||
std::_Exit(127);
|
||||
}
|
||||
pid = p;
|
||||
// Poll for readiness — up to 3s.
|
||||
for (int i = 0; i < 60; ++i) {
|
||||
std::this_thread::sleep_for(std::chrono::milliseconds(50));
|
||||
fn_http::Request r;
|
||||
r.url = std::string("http://") + kHost + ":" + std::to_string(port) + "/ping";
|
||||
r.timeout_ms = 500;
|
||||
auto res = fn_http::request(r);
|
||||
if (res.status == 200) return true;
|
||||
}
|
||||
return false;
|
||||
#endif
|
||||
}
|
||||
|
||||
void stop() {
|
||||
#ifndef _WIN32
|
||||
if (pid > 0) {
|
||||
kill(pid, SIGTERM);
|
||||
int st = 0;
|
||||
waitpid(pid, &st, 0);
|
||||
pid = 0;
|
||||
}
|
||||
#endif
|
||||
}
|
||||
|
||||
std::string base() const {
|
||||
return std::string("http://") + kHost + ":" + std::to_string(port);
|
||||
}
|
||||
};
|
||||
|
||||
PythonServer& server() {
|
||||
static PythonServer s;
|
||||
static bool inited = false;
|
||||
if (!inited) {
|
||||
inited = true;
|
||||
s.start();
|
||||
// Best-effort cleanup at process exit (Catch2 has no global teardown
|
||||
// hook portable to v3 amalgamated — atexit is fine).
|
||||
std::atexit([] { server().stop(); });
|
||||
}
|
||||
return s;
|
||||
}
|
||||
|
||||
bool server_alive() { return server().pid > 0; }
|
||||
|
||||
} // anon
|
||||
|
||||
TEST_CASE("request: GET 200 status + body", "[http_request]") {
|
||||
if (!server_alive()) { SUCCEED("python3 fixture not available — skipped"); return; }
|
||||
fn_http::Request r;
|
||||
r.method = "GET";
|
||||
r.url = server().base() + "/get";
|
||||
auto res = fn_http::request(r);
|
||||
REQUIRE(res.error.empty());
|
||||
REQUIRE(res.status == 200);
|
||||
REQUIRE(res.body.find("\"method\": \"GET\"") != std::string::npos);
|
||||
REQUIRE(res.duration_ms >= 0);
|
||||
}
|
||||
|
||||
TEST_CASE("request: GET 404 status (HTTP error not transport)", "[http_request]") {
|
||||
if (!server_alive()) { SUCCEED("python3 fixture not available — skipped"); return; }
|
||||
fn_http::Request r;
|
||||
r.method = "GET";
|
||||
r.url = server().base() + "/status/404";
|
||||
auto res = fn_http::request(r);
|
||||
REQUIRE(res.error.empty()); // 404 is NOT a transport error
|
||||
REQUIRE(res.status == 404);
|
||||
}
|
||||
|
||||
TEST_CASE("request: POST with body + content-type header", "[http_request]") {
|
||||
if (!server_alive()) { SUCCEED("python3 fixture not available — skipped"); return; }
|
||||
fn_http::Request r;
|
||||
r.method = "POST";
|
||||
r.url = server().base() + "/echo";
|
||||
r.headers = {{"Content-Type", "application/json"}};
|
||||
r.body = R"({"hello":"world"})";
|
||||
auto res = fn_http::request(r);
|
||||
REQUIRE(res.error.empty());
|
||||
REQUIRE(res.status == 200);
|
||||
REQUIRE(res.body.find("\"method\": \"POST\"") != std::string::npos);
|
||||
REQUIRE(res.body.find("hello") != std::string::npos);
|
||||
}
|
||||
|
||||
TEST_CASE("request: bearer_token shortcut adds Authorization header", "[http_request]") {
|
||||
if (!server_alive()) { SUCCEED("python3 fixture not available — skipped"); return; }
|
||||
fn_http::Request r;
|
||||
r.method = "GET";
|
||||
r.url = server().base() + "/whoami";
|
||||
r.bearer_token = "s3cret_xyz";
|
||||
auto res = fn_http::request(r);
|
||||
REQUIRE(res.error.empty());
|
||||
REQUIRE(res.status == 200);
|
||||
REQUIRE(res.body.find("Bearer s3cret_xyz") != std::string::npos);
|
||||
}
|
||||
|
||||
TEST_CASE("request: basic_user/basic_pass shortcut authenticates", "[http_request]") {
|
||||
if (!server_alive()) { SUCCEED("python3 fixture not available — skipped"); return; }
|
||||
fn_http::Request r;
|
||||
r.method = "GET";
|
||||
r.url = server().base() + "/basic";
|
||||
r.basic_user = "alice";
|
||||
r.basic_pass = "wonderland";
|
||||
auto res = fn_http::request(r);
|
||||
REQUIRE(res.error.empty());
|
||||
REQUIRE(res.status == 200);
|
||||
// curl base64s "alice:wonderland" -> "YWxpY2U6d29uZGVybGFuZA=="
|
||||
REQUIRE(res.body.find("Basic YWxpY2U6d29uZGVybGFuZA==") != std::string::npos);
|
||||
}
|
||||
|
||||
TEST_CASE("request: invalid URL surfaces transport error", "[http_request]") {
|
||||
// No server needed — this exercises curl's DNS/transport layer.
|
||||
fn_http::Request r;
|
||||
r.method = "GET";
|
||||
r.url = "http://127.0.0.1:1/never_listens_here"; // port 1 is reserved
|
||||
r.timeout_ms = 1500;
|
||||
auto res = fn_http::request(r);
|
||||
REQUIRE(res.status == 0);
|
||||
REQUIRE_FALSE(res.error.empty());
|
||||
}
|
||||
|
||||
TEST_CASE("request: timeout_ms honored", "[http_request]") {
|
||||
if (!server_alive()) { SUCCEED("python3 fixture not available — skipped"); return; }
|
||||
fn_http::Request r;
|
||||
r.method = "GET";
|
||||
r.url = server().base() + "/slow/3"; // server sleeps 3s
|
||||
r.timeout_ms = 1000; // we want bail at 1s
|
||||
auto t0 = std::chrono::steady_clock::now();
|
||||
auto res = fn_http::request(r);
|
||||
auto elapsed_ms = std::chrono::duration_cast<std::chrono::milliseconds>(
|
||||
std::chrono::steady_clock::now() - t0).count();
|
||||
REQUIRE(res.status == 0); // timeout = transport error
|
||||
REQUIRE_FALSE(res.error.empty());
|
||||
REQUIRE(elapsed_ms < 2500); // we did NOT wait the full 3s
|
||||
}
|
||||
Vendored
+23
@@ -0,0 +1,23 @@
|
||||
# nlohmann/json
|
||||
|
||||
Single-header JSON library. Vendored for `cpp/functions/core/http_get_json` and
|
||||
future C++ functions that need JSON serialization without an extra dependency.
|
||||
|
||||
- **Source**: https://github.com/nlohmann/json
|
||||
- **Version**: v3.11.3
|
||||
- **File**: `json.hpp` (amalgamated single header)
|
||||
- **License**: MIT
|
||||
- **Download**: `curl -L -o json.hpp https://github.com/nlohmann/json/releases/download/v3.11.3/json.hpp`
|
||||
|
||||
Include with:
|
||||
|
||||
```cpp
|
||||
#include "nlohmann/json.hpp"
|
||||
using json = nlohmann::json;
|
||||
```
|
||||
|
||||
Include path: `cpp/vendor/` (so `nlohmann/json.hpp` resolves). Tests +
|
||||
`fn_framework` already have `cpp/vendor/` on the include search path via the
|
||||
top-level `cpp/CMakeLists.txt` (`vendor/nlohmann` is header-only so it does
|
||||
NOT need a CMake target — just `target_include_directories(<t> PRIVATE
|
||||
${CMAKE_SOURCE_DIR}/vendor)`).
|
||||
Vendored
+24765
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,134 @@
|
||||
---
|
||||
id: "0110"
|
||||
title: "Gap registry: helper HTTP cliente C++ (curl/popen) reutilizable"
|
||||
status: pendiente
|
||||
type: feature
|
||||
domain:
|
||||
- cpp-stack
|
||||
- registry-quality
|
||||
scope: registry
|
||||
priority: media
|
||||
depends: []
|
||||
blocks:
|
||||
- "0111"
|
||||
related:
|
||||
- "0106"
|
||||
created: 2026-05-18
|
||||
updated: 2026-05-18
|
||||
tags: [http, cpp, registry-gap, curl, helper]
|
||||
---
|
||||
|
||||
# 0110 — Helper HTTP cliente C++ en el registry
|
||||
|
||||
## Problema
|
||||
|
||||
Hoy no existe funcion HTTP cliente reutilizable en `cpp/functions/`. Cada app C++ que necesita
|
||||
golpear un endpoint reinventa la capa:
|
||||
|
||||
| App | Fichero | Tecnica | LOC aprox |
|
||||
|---|---|---|---|
|
||||
| `apps/services_monitor/` | `http_client.cpp` | cURL popen/WinHTTP segun plataforma | ~150 |
|
||||
| `apps/dag_engine_ui/` | inline en `main.cpp` | curl CLI via popen + parse | ~80 |
|
||||
| `apps/data_factory/` | inline | popen curl | ~60 |
|
||||
| `cpp/functions/core/llm_anthropic.cpp` | propio | cURL popen — solo Anthropic | — |
|
||||
| `apps/process_explorer/` (issue 0111) | `http_client.cpp` local | pendiente — clonara services_monitor | ~150 esperados |
|
||||
|
||||
El patron ya supera el umbral `>2x` que dispara la regla de promocion (CLAUDE.md
|
||||
"Si patron se repite >2x → propose nueva funcion via fn-constructor"). Cada app duplica:
|
||||
|
||||
- Detection de plataforma (Linux: `popen("curl -s ...")`, Win: `WinHTTPOpen`/popen)
|
||||
- Manejo de basicAuth / Bearer tokens
|
||||
- Timeouts
|
||||
- Captura de body + status code
|
||||
- Manejo de errores transitorios (DNS, conexion rechazada)
|
||||
|
||||
## Decision
|
||||
|
||||
Anadir al registry dos funciones C++ en dominio `core` (o `infra`):
|
||||
|
||||
### `http_request_cpp_core` (impure)
|
||||
|
||||
```cpp
|
||||
namespace fn_http {
|
||||
struct Request {
|
||||
std::string method; // "GET", "POST", "PUT", "DELETE"
|
||||
std::string url;
|
||||
std::vector<std::pair<std::string,std::string>> headers;
|
||||
std::string body; // raw bytes (JSON, etc.)
|
||||
int timeout_ms = 5000;
|
||||
std::string bearer_token; // shortcut: anade Authorization: Bearer <token>
|
||||
std::string basic_user; // shortcut: anade Authorization: Basic base64(user:pass)
|
||||
std::string basic_pass;
|
||||
};
|
||||
struct Response {
|
||||
int status = 0; // 0 = error de transporte
|
||||
std::string body;
|
||||
std::vector<std::pair<std::string,std::string>> headers;
|
||||
std::string error; // vacio si OK
|
||||
int64_t duration_ms = 0;
|
||||
};
|
||||
Response request(const Request& req);
|
||||
}
|
||||
```
|
||||
|
||||
Implementacion: cURL via popen (portable WSL+Win+Linux, igual que `llm_anthropic`).
|
||||
Si en el futuro queremos rendimiento real, swap a libcurl linkado estaticamente
|
||||
o WinHTTP via `#ifdef _WIN32` — interfaz Request/Response no cambia.
|
||||
|
||||
### `http_get_json_cpp_core` (impure, pure wrapper)
|
||||
|
||||
Helper que envuelve `http_request` + parse JSON (via `nlohmann::json` o similar
|
||||
ya disponible en el repo) para los casos comunes:
|
||||
|
||||
```cpp
|
||||
namespace fn_http {
|
||||
// Devuelve parsed JSON o lanza si status != 2xx
|
||||
nlohmann::json get_json(const std::string& url,
|
||||
const std::string& bearer_token = "",
|
||||
int timeout_ms = 5000);
|
||||
}
|
||||
```
|
||||
|
||||
## Plan de migracion
|
||||
|
||||
Tras crear las funciones, abrir issue separado por cada consumer para migrar:
|
||||
|
||||
1. `apps/services_monitor/http_client.cpp` -> usar `fn_http::request`
|
||||
2. `apps/dag_engine_ui/main.cpp` (inline)
|
||||
3. `apps/data_factory/` (inline)
|
||||
4. `cpp/functions/core/llm_anthropic.cpp` — refactor para usar `fn_http::request` por
|
||||
debajo (mantiene API publica)
|
||||
5. `apps/process_explorer/` (issue 0111) — nace ya usando el helper
|
||||
|
||||
## Criterios de aceptacion
|
||||
|
||||
- [ ] `cpp/functions/core/http_request.{cpp,h,md}` registrado en `registry.db`
|
||||
- [ ] `cpp/functions/core/http_get_json.{cpp,h,md}` idem
|
||||
- [ ] Tests visuales o de integracion contra `httpbin.org` (200/404/timeout/auth)
|
||||
- [ ] Frontmatter completo (`params`/`output`/`tags`/`example`)
|
||||
- [ ] `.md` cumple contrato self-doc (`## Ejemplo`, `## Cuando usarla`, `## Gotchas`)
|
||||
- [ ] Al menos 1 consumer migrado para validar API (recomendado `services_monitor`)
|
||||
- [ ] `fn doctor uses-functions` limpio
|
||||
|
||||
## Gotchas conocidos
|
||||
|
||||
- cURL popen en Windows necesita `curl.exe` en PATH — todos los WSL/Win lo tienen,
|
||||
pero documentar en `## Gotchas`.
|
||||
- Bodies binarios: popen complica el escape; primera version solo string bodies.
|
||||
- TLS verify: por defecto on; permitir `req.insecure = true` solo para testing.
|
||||
- Timeouts: cURL `--max-time` cubre handshake+transfer; documentar diferencia con
|
||||
read-timeout puro.
|
||||
|
||||
## Por que no usar libcurl linkado
|
||||
|
||||
- `popen("curl ...")` no requiere anadir libcurl al toolchain MinGW cross-compile
|
||||
(que ya costo configurar). `llm_anthropic` lleva meses funcionando asi.
|
||||
- Cuando aparezca un caso real de latencia (>10 req/s sostenido), abrimos issue
|
||||
separado para swap a libcurl.
|
||||
|
||||
## Out of scope (no en este issue)
|
||||
|
||||
- WebSocket / SSE — cliente WS C++ es otro gap; abrir issue propio cuando aplique.
|
||||
- Cliente gRPC.
|
||||
- Streaming responses (SSE chunk-by-chunk) — usar caso de `dag_engine_ui` para
|
||||
decidir cuando.
|
||||
Reference in New Issue
Block a user