min boilerplate 2/2

This commit is contained in:
lovebird 2026-02-18 12:02:34 +01:00
parent d383a4a543
commit 595216fe26
10 changed files with 302 additions and 28 deletions

2
.gitignore vendored
View File

@ -7,7 +7,7 @@
*.exe
*.out
*.app
config/
# CMake generated
CMakeCache.txt
CMakeFiles/

View File

@ -16,7 +16,7 @@
"clean:release": "cmake -E rm -rf build/release",
"clean:all": "cmake -E rm -rf build",
"rebuild": "npm run clean && npm run config && npm run build",
"run": ".\\build\\dev\\Debug\\polymech-cli.exe"
"run": ".\\build\\dev\\Debug\\polymech-cli.exe --help"
},
"repository": {
"type": "git",

View File

@ -7,5 +7,5 @@ target_include_directories(postgres
)
target_link_libraries(postgres
PUBLIC logger
PUBLIC logger http json
)

View File

@ -1,11 +1,34 @@
#pragma once
#include <string>
#include <vector>
namespace postgres {
/// Connect to a PostgreSQL database (stub).
/// Returns a human-readable status string.
std::string connect(const std::string &connection_string);
/// Supabase connection configuration.
struct Config {
std::string supabase_url;
std::string supabase_key;
};
/// Initialize the Supabase client with URL and API key.
void init(const Config &config);
/// Ping the Supabase REST API. Returns "ok" on success, error message on
/// failure.
std::string ping();
/// Query a table via the PostgREST API.
/// Returns the raw JSON response body.
/// @param table Table name (e.g. "profiles")
/// @param select Comma-separated columns (e.g. "id,username"), or "*"
/// @param filter PostgREST filter (e.g. "id=eq.abc"), or "" for no filter
/// @param limit Max rows (0 = no limit)
std::string query(const std::string &table, const std::string &select = "*",
const std::string &filter = "", int limit = 0);
/// Insert a row into a table. Body is a JSON object string.
/// Returns the created row as JSON.
std::string insert(const std::string &table, const std::string &json_body);
} // namespace postgres

View File

@ -1,12 +1,176 @@
#include "postgres/postgres.h"
#include "http/http.h"
#include "logger/logger.h"
#include "json/json.h"
#include <curl/curl.h>
#include <stdexcept>
namespace postgres {
std::string connect(const std::string &connection_string) {
logger::debug("postgres::connect → " + connection_string);
// stub — no real connection
return "ok";
static Config s_config;
static bool s_initialized = false;
void init(const Config &config) {
s_config = config;
s_initialized = true;
logger::debug("postgres::init → " + config.supabase_url);
}
static void ensure_init() {
if (!s_initialized) {
throw std::runtime_error("postgres::init() must be called first");
}
}
/// Build the REST URL for a table query.
static std::string build_url(const std::string &table,
const std::string &select,
const std::string &filter, int limit) {
std::string url = s_config.supabase_url + "/rest/v1/" + table;
url += "?select=" + select;
if (!filter.empty()) {
url += "&" + filter;
}
if (limit > 0) {
url += "&limit=" + std::to_string(limit);
}
return url;
}
/// Make an authenticated GET request to the Supabase REST API.
static http::Response supabase_get(const std::string &url) {
// We need custom headers, so we use curl directly
CURL *curl = curl_easy_init();
http::Response resp{};
if (!curl) {
resp.status_code = -1;
resp.body = "curl_easy_init failed";
return resp;
}
struct curl_slist *headers = nullptr;
headers =
curl_slist_append(headers, ("apikey: " + s_config.supabase_key).c_str());
headers = curl_slist_append(
headers, ("Authorization: Bearer " + s_config.supabase_key).c_str());
auto write_cb = [](void *contents, size_t size, size_t nmemb, void *userp) {
auto *out = static_cast<std::string *>(userp);
out->append(static_cast<char *>(contents), size * nmemb);
return size * nmemb;
};
curl_easy_setopt(curl, CURLOPT_URL, url.c_str());
curl_easy_setopt(curl, CURLOPT_HTTPHEADER, headers);
curl_easy_setopt(
curl, CURLOPT_WRITEFUNCTION,
static_cast<size_t (*)(void *, size_t, size_t, void *)>(+write_cb));
curl_easy_setopt(curl, CURLOPT_WRITEDATA, &resp.body);
curl_easy_setopt(curl, CURLOPT_TIMEOUT, 10L);
CURLcode res = curl_easy_perform(curl);
if (res != CURLE_OK) {
resp.status_code = -1;
resp.body = curl_easy_strerror(res);
} else {
curl_easy_getinfo(curl, CURLINFO_RESPONSE_CODE, &resp.status_code);
}
curl_slist_free_all(headers);
curl_easy_cleanup(curl);
return resp;
}
/// Make an authenticated POST request.
static http::Response supabase_post(const std::string &url,
const std::string &body) {
CURL *curl = curl_easy_init();
http::Response resp{};
if (!curl) {
resp.status_code = -1;
resp.body = "curl_easy_init failed";
return resp;
}
struct curl_slist *headers = nullptr;
headers = curl_slist_append(headers, "Content-Type: application/json");
headers = curl_slist_append(headers, "Prefer: return=representation");
headers =
curl_slist_append(headers, ("apikey: " + s_config.supabase_key).c_str());
headers = curl_slist_append(
headers, ("Authorization: Bearer " + s_config.supabase_key).c_str());
auto write_cb = [](void *contents, size_t size, size_t nmemb, void *userp) {
auto *out = static_cast<std::string *>(userp);
out->append(static_cast<char *>(contents), size * nmemb);
return size * nmemb;
};
curl_easy_setopt(curl, CURLOPT_URL, url.c_str());
curl_easy_setopt(curl, CURLOPT_HTTPHEADER, headers);
curl_easy_setopt(curl, CURLOPT_POSTFIELDS, body.c_str());
curl_easy_setopt(
curl, CURLOPT_WRITEFUNCTION,
static_cast<size_t (*)(void *, size_t, size_t, void *)>(+write_cb));
curl_easy_setopt(curl, CURLOPT_WRITEDATA, &resp.body);
curl_easy_setopt(curl, CURLOPT_TIMEOUT, 10L);
CURLcode res = curl_easy_perform(curl);
if (res != CURLE_OK) {
resp.status_code = -1;
resp.body = curl_easy_strerror(res);
} else {
curl_easy_getinfo(curl, CURLINFO_RESPONSE_CODE, &resp.status_code);
}
curl_slist_free_all(headers);
curl_easy_cleanup(curl);
return resp;
}
std::string ping() {
ensure_init();
// Lightweight check: query profiles with limit=0 to verify connectivity
auto resp = supabase_get(s_config.supabase_url +
"/rest/v1/profiles?select=id&limit=0");
if (resp.status_code >= 200 && resp.status_code < 300) {
logger::info("postgres::ping → ok (HTTP " +
std::to_string(resp.status_code) + ")");
return "ok";
}
logger::error("postgres::ping → HTTP " + std::to_string(resp.status_code) +
": " + resp.body);
return "error: HTTP " + std::to_string(resp.status_code);
}
std::string query(const std::string &table, const std::string &select,
const std::string &filter, int limit) {
ensure_init();
auto url = build_url(table, select, filter, limit);
logger::debug("postgres::query → " + url);
auto resp = supabase_get(url);
if (resp.status_code >= 200 && resp.status_code < 300) {
return resp.body;
}
logger::error("postgres::query → HTTP " + std::to_string(resp.status_code) +
": " + resp.body);
return resp.body;
}
std::string insert(const std::string &table, const std::string &json_body) {
ensure_init();
auto url = s_config.supabase_url + "/rest/v1/" + table;
logger::debug("postgres::insert → " + url);
auto resp = supabase_post(url, json_body);
if (resp.status_code >= 200 && resp.status_code < 300) {
return resp.body;
}
logger::error("postgres::insert → HTTP " + std::to_string(resp.status_code) +
": " + resp.body);
return resp.body;
}
} // namespace postgres

View File

@ -10,7 +10,6 @@
#include "postgres/postgres.h"
#include "json/json.h"
#ifndef PROJECT_VERSION
#define PROJECT_VERSION "0.1.0"
#endif
@ -49,6 +48,17 @@ int main(int argc, char *argv[]) {
auto *json_cmd = app.add_subcommand("json", "Prettify a JSON string");
json_cmd->add_option("input", json_input, "JSON string")->required();
// Subcommand: db — connect to Supabase and query
std::string db_config_path = "config/postgres.toml";
std::string db_table;
int db_limit = 10;
auto *db_cmd =
app.add_subcommand("db", "Connect to Supabase and query a table");
db_cmd->add_option("-c,--config", db_config_path, "TOML config path")
->default_val("config/postgres.toml");
db_cmd->add_option("table", db_table, "Table to query (optional)");
db_cmd->add_option("-l,--limit", db_limit, "Row limit")->default_val(10);
CLI11_PARSE(app, argc, argv);
logger::init("polymech-cli");
@ -104,9 +114,34 @@ int main(int argc, char *argv[]) {
return 0;
}
// Default: demo
auto status = postgres::connect("postgresql://localhost:5432/polymech");
logger::info("polymech-cli " + std::string(PROJECT_VERSION) +
" ready (pg: " + status + ")");
if (db_cmd->parsed()) {
try {
auto cfg = toml::parse_file(db_config_path);
postgres::Config pg_cfg;
pg_cfg.supabase_url = cfg["supabase"]["url"].value_or(std::string(""));
pg_cfg.supabase_key =
cfg["supabase"]["publishable_key"].value_or(std::string(""));
postgres::init(pg_cfg);
auto status = postgres::ping();
logger::info("Supabase: " + status);
if (!db_table.empty()) {
auto result = postgres::query(db_table, "*", "", db_limit);
if (json::is_valid(result)) {
std::cout << json::prettify(result) << "\n";
} else {
std::cout << result << "\n";
}
}
} catch (const std::exception &e) {
logger::error(std::string("db error: ") + e.what());
return 1;
}
return 0;
}
// No subcommand — show help
std::cout << app.help() << "\n";
return 0;
}

View File

@ -27,3 +27,8 @@ catch_discover_tests(test_http)
add_executable(test_functional functional/test_cli.cpp)
target_link_libraries(test_functional PRIVATE Catch2::Catch2WithMain CLI11::CLI11 tomlplusplus::tomlplusplus logger html postgres http json)
catch_discover_tests(test_functional)
# E2E test real Supabase connection (requires config/postgres.toml + network)
add_executable(test_supabase e2e/test_supabase.cpp)
target_link_libraries(test_supabase PRIVATE Catch2::Catch2WithMain tomlplusplus::tomlplusplus logger postgres json)
catch_discover_tests(test_supabase WORKING_DIRECTORY ${CMAKE_SOURCE_DIR})

View File

@ -0,0 +1,50 @@
#include <catch2/catch_test_macros.hpp>
#include <fstream>
#include <sstream>
#include <toml++/toml.hpp>
#include "logger/logger.h"
#include "postgres/postgres.h"
#include "json/json.h"
// ── E2E: Supabase connect via config/postgres.toml ──────────────────────────
TEST_CASE("E2E: connect to Supabase and ping", "[e2e][postgres]") {
logger::init("e2e-test");
// Read config — path relative to CWD (project root)
auto cfg = toml::parse_file("config/postgres.toml");
postgres::Config pg_cfg;
pg_cfg.supabase_url = cfg["supabase"]["url"].value_or(std::string(""));
pg_cfg.supabase_key =
cfg["supabase"]["publishable_key"].value_or(std::string(""));
REQUIRE(!pg_cfg.supabase_url.empty());
REQUIRE(!pg_cfg.supabase_key.empty());
postgres::init(pg_cfg);
auto status = postgres::ping();
logger::info("E2E ping result: " + status);
CHECK(status == "ok");
}
TEST_CASE("E2E: query profiles table", "[e2e][postgres]") {
logger::init("e2e-test");
auto cfg = toml::parse_file("config/postgres.toml");
postgres::Config pg_cfg;
pg_cfg.supabase_url = cfg["supabase"]["url"].value_or(std::string(""));
pg_cfg.supabase_key =
cfg["supabase"]["publishable_key"].value_or(std::string(""));
postgres::init(pg_cfg);
auto result = postgres::query("profiles", "id,username", "", 3);
logger::info("E2E query result: " + result);
// Should be valid JSON array
CHECK(json::is_valid(result));
}

View File

@ -64,9 +64,11 @@ TEST_CASE("Full pipeline: TOML config round-trip", "[functional]") {
TEST_CASE("Full pipeline: logger + postgres integration", "[functional]") {
REQUIRE_NOTHROW(logger::init("functional-test"));
auto status = postgres::connect("postgresql://localhost:5432/functional");
REQUIRE(status == "ok");
// Init with a dummy config (no real connection)
postgres::Config cfg;
cfg.supabase_url = "https://example.supabase.co";
cfg.supabase_key = "test-key";
REQUIRE_NOTHROW(postgres::init(cfg));
REQUIRE_NOTHROW(
logger::info("Functional test passed with pg status: " + status));
REQUIRE_NOTHROW(logger::info("Functional test: postgres init ok"));
}

View File

@ -2,13 +2,8 @@
#include "postgres/postgres.h"
TEST_CASE("postgres::connect returns ok for any input (stub)", "[postgres]") {
auto result = postgres::connect("postgresql://localhost:5432/test");
CHECK(result == "ok");
}
TEST_CASE("postgres::connect accepts different connection strings",
"[postgres]") {
CHECK(postgres::connect("postgresql://user:pass@host:5432/db") == "ok");
CHECK(postgres::connect("") == "ok");
// Unit tests use a no-op init — no network required
TEST_CASE("postgres::ping throws without init", "[postgres]") {
// If called without init, should throw
CHECK_THROWS(postgres::ping());
}