min boilerplate 2/2
This commit is contained in:
parent
d383a4a543
commit
595216fe26
2
.gitignore
vendored
2
.gitignore
vendored
@ -7,7 +7,7 @@
|
||||
*.exe
|
||||
*.out
|
||||
*.app
|
||||
|
||||
config/
|
||||
# CMake generated
|
||||
CMakeCache.txt
|
||||
CMakeFiles/
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -7,5 +7,5 @@ target_include_directories(postgres
|
||||
)
|
||||
|
||||
target_link_libraries(postgres
|
||||
PUBLIC logger
|
||||
PUBLIC logger http json
|
||||
)
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
45
src/main.cpp
45
src/main.cpp
@ -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;
|
||||
}
|
||||
|
||||
@ -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})
|
||||
|
||||
50
tests/e2e/test_supabase.cpp
Normal file
50
tests/e2e/test_supabase.cpp
Normal 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));
|
||||
}
|
||||
@ -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"));
|
||||
}
|
||||
|
||||
@ -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());
|
||||
}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user