feat(cpp/core): add http_request + http_get_json helpers (issue 0110)
Promotes the inline curl-popen pattern duplicated across apps/services_monitor, dag_engine_ui, data_factory into two reusable functions in cpp/functions/core/: - http_request_cpp_core: generic HTTP client (GET/POST/PUT/DELETE/PATCH) via cURL CLI through popen. Portable Linux/WSL/MinGW (no link-time libcurl). Supports custom headers, raw body, Bearer/Basic auth shortcuts, timeout, optional TLS verify skip. Returns status/body/headers/error/duration_ms. - http_get_json_cpp_core: convenience wrapper over http_request — GET <url>, expect 2xx, parse body as nlohmann::json. Throws std::runtime_error on transport / non-2xx / parse failure. Vendors nlohmann/json v3.11.3 single header at cpp/vendor/nlohmann/json.hpp (MIT). No CMake target needed — header-only; consumers add cpp/vendor/ to include path.
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,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
|
||||
Reference in New Issue
Block a user