feat: implement MoloApp plans 01-15 with integration
Full C++23 framework tree: runtime, observability, security, data, app, realtime, supervisor, clients, CLI, example plus enterprise tenancy, audit, operability, governance, supplychain. Postgres via docker compose for tests.
This commit is contained in:
@@ -0,0 +1,123 @@
|
||||
#include "App.hpp"
|
||||
|
||||
#include <stdexcept>
|
||||
|
||||
#include "Caps.hpp"
|
||||
|
||||
namespace molo::example::fw {
|
||||
|
||||
HttpError::HttpError(int status, std::string code, std::string message)
|
||||
: status_(status), code_(std::move(code)), message_(std::move(message)) {}
|
||||
|
||||
int HttpError::status() const { return status_; }
|
||||
const std::string& HttpError::code() const { return code_; }
|
||||
const std::string& HttpError::message() const { return message_; }
|
||||
|
||||
void Application::use(MiddlewareSlot slot, Middleware middleware) {
|
||||
if (has_middleware_ && slot < last_slot_)
|
||||
throw std::runtime_error("middleware category order violated");
|
||||
last_slot_ = slot;
|
||||
has_middleware_ = true;
|
||||
middlewares_.emplace_back(slot, std::move(middleware));
|
||||
}
|
||||
|
||||
void Application::include_router(const std::string& prefix,
|
||||
const Router& router,
|
||||
const std::string& name_prefix) {
|
||||
router_.include_router(prefix, router, name_prefix);
|
||||
}
|
||||
|
||||
void Application::mount(const std::string& path,
|
||||
std::shared_ptr<Application> sub) {
|
||||
mounts_.emplace_back(path, std::move(sub));
|
||||
}
|
||||
|
||||
Response Application::dispatch(Request req) {
|
||||
if (req.body.size() > caps::kMaxRequestBody)
|
||||
return TextResponse("body too large", 413);
|
||||
try {
|
||||
return run_chain(std::move(req), 0);
|
||||
} catch (const HttpError& e) {
|
||||
std::string body = "{\"error\":\"" + json_escape(e.code()) +
|
||||
"\",\"message\":\"" + json_escape(e.message()) + "\"}";
|
||||
return JsonResponse(body, e.status());
|
||||
} catch (const std::exception& e) {
|
||||
return JsonResponse("{\"error\":\"internal\",\"message\":\"" +
|
||||
json_escape(e.what()) + "\"}",
|
||||
500);
|
||||
}
|
||||
}
|
||||
|
||||
Response Application::run_chain(Request req, int depth) {
|
||||
Next next = [this, depth](Request r) { return dispatch_inner(r, depth); };
|
||||
for (std::size_t i = middlewares_.size(); i > 0; --i) {
|
||||
Middleware mw = middlewares_[i - 1].second;
|
||||
Next inner = next;
|
||||
next = [mw, inner](Request r) { return mw(std::move(r), inner); };
|
||||
}
|
||||
return next(std::move(req));
|
||||
}
|
||||
|
||||
Response Application::dispatch_inner(Request req, int depth) {
|
||||
if (depth > caps::kMaxMountDepth)
|
||||
throw HttpError(500, "mount_depth", "sub-application nesting too deep");
|
||||
std::string best;
|
||||
std::shared_ptr<Application> target;
|
||||
for (auto& [prefix, sub] : mounts_) {
|
||||
if (req.path.size() >= prefix.size() &&
|
||||
req.path.compare(0, prefix.size(), prefix) == 0 &&
|
||||
prefix.size() > best.size()) {
|
||||
best = prefix;
|
||||
target = sub;
|
||||
}
|
||||
}
|
||||
if (target) {
|
||||
req.path = req.path.substr(best.size());
|
||||
if (req.path.empty() || req.path.front() != '/') req.path = "/" + req.path;
|
||||
return target->run_chain(std::move(req), depth + 1);
|
||||
}
|
||||
Handler handler;
|
||||
if (router_.match(req, handler)) return handler(std::move(req));
|
||||
bool path_exists = false;
|
||||
for (const auto& r : router_.routes()) {
|
||||
std::map<std::string, std::string> params;
|
||||
if (match_pattern(r.pattern, req.path, params)) {
|
||||
path_exists = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (path_exists)
|
||||
throw HttpError(405, "method_not_allowed", "method not allowed");
|
||||
throw HttpError(404, "not_found", "no route for " + req.path);
|
||||
}
|
||||
|
||||
const std::vector<Route>& Application::routes() const {
|
||||
return router_.routes();
|
||||
}
|
||||
|
||||
Middleware errors_middleware() {
|
||||
return [](Request req, Next next) -> Response {
|
||||
try {
|
||||
return next(std::move(req));
|
||||
} catch (const HttpError& e) {
|
||||
std::string body = "{\"error\":\"" + json_escape(e.code()) +
|
||||
"\",\"message\":\"" + json_escape(e.message()) +
|
||||
"\"}";
|
||||
return JsonResponse(body, e.status());
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
Middleware request_id_middleware() {
|
||||
return [](Request req, Next next) -> Response {
|
||||
if (req.correlation_id.empty()) {
|
||||
static unsigned long counter = 1;
|
||||
req.correlation_id = "req-" + std::to_string(counter++);
|
||||
}
|
||||
Response res = next(req);
|
||||
res.headers.set("X-Request-ID", req.correlation_id);
|
||||
return res;
|
||||
};
|
||||
}
|
||||
|
||||
} // namespace molo::example::fw
|
||||
@@ -0,0 +1,67 @@
|
||||
#pragma once
|
||||
|
||||
// Application: composition root. Owns routers, middleware chain, sub-app
|
||||
// mounts, and the typed error mapping. Gap: real molo::app::Application in
|
||||
// 05-request-routing.md owns lifespan hooks, plugin registry, and the full DI
|
||||
// container; this skeleton passes a Services struct to route factories.
|
||||
|
||||
#include <functional>
|
||||
#include <memory>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
#include "Http.hpp"
|
||||
#include "Router.hpp"
|
||||
|
||||
namespace molo::example::fw {
|
||||
|
||||
enum class MiddlewareSlot {
|
||||
Errors = 0,
|
||||
Observability = 1,
|
||||
Security = 2,
|
||||
SessionAuth = 3,
|
||||
User = 4,
|
||||
};
|
||||
|
||||
using Next = std::function<Response(Request)>;
|
||||
using Middleware = std::function<Response(Request, Next)>;
|
||||
|
||||
class HttpError {
|
||||
public:
|
||||
HttpError(int status, std::string code, std::string message);
|
||||
int status() const;
|
||||
const std::string& code() const;
|
||||
const std::string& message() const;
|
||||
|
||||
private:
|
||||
int status_;
|
||||
std::string code_;
|
||||
std::string message_;
|
||||
};
|
||||
|
||||
class Application : public std::enable_shared_from_this<Application> {
|
||||
public:
|
||||
void use(MiddlewareSlot slot, Middleware middleware);
|
||||
void include_router(const std::string& prefix, const Router& router,
|
||||
const std::string& name_prefix = "");
|
||||
void mount(const std::string& path, std::shared_ptr<Application> sub);
|
||||
|
||||
Response dispatch(Request req);
|
||||
|
||||
const std::vector<Route>& routes() const;
|
||||
|
||||
private:
|
||||
Response dispatch_inner(Request req, int depth);
|
||||
Response run_chain(Request req, int depth);
|
||||
|
||||
Router router_;
|
||||
std::vector<std::pair<MiddlewareSlot, Middleware>> middlewares_;
|
||||
std::vector<std::pair<std::string, std::shared_ptr<Application>>> mounts_;
|
||||
MiddlewareSlot last_slot_ = MiddlewareSlot::Errors;
|
||||
bool has_middleware_ = false;
|
||||
};
|
||||
|
||||
Middleware errors_middleware();
|
||||
Middleware request_id_middleware();
|
||||
|
||||
} // namespace molo::example::fw
|
||||
@@ -0,0 +1,48 @@
|
||||
#pragma once
|
||||
|
||||
// Static ceilings for the living example. Every knob has a cap; no autotuning.
|
||||
// Gap: real framework caps live in 01-runtime.md through 09-cli-ops.md specs;
|
||||
// these example caps mirror them until the framework lands.
|
||||
|
||||
#include <cstddef>
|
||||
|
||||
namespace molo::example::fw::caps {
|
||||
|
||||
inline constexpr std::size_t kMaxRequestBody = 1 << 20; // 1 MiB
|
||||
inline constexpr std::size_t kMaxHeaderCount = 100;
|
||||
inline constexpr std::size_t kMaxHeaderBytes = 65536;
|
||||
inline constexpr std::size_t kMaxFormFields = 200;
|
||||
inline constexpr int kMaxJsonDepth = 32;
|
||||
inline constexpr std::size_t kMaxUploadBytes = 8 << 20; // 8 MiB
|
||||
inline constexpr std::size_t kMaxTemplateBytes = 256 << 10;
|
||||
inline constexpr int kMaxTemplateDepth = 16;
|
||||
inline constexpr std::size_t kMaxRouteCount = 4096;
|
||||
inline constexpr int kMaxMountDepth = 8;
|
||||
inline constexpr std::size_t kMaxKvEntries = 100000;
|
||||
inline constexpr std::size_t kMaxCacheEntries = 10000;
|
||||
inline constexpr std::size_t kMaxPubsubTopics = 1000;
|
||||
inline constexpr std::size_t kMaxPubsubHistory = 100;
|
||||
inline constexpr std::size_t kMaxMetrics = 1000;
|
||||
inline constexpr std::size_t kMaxSpans = 10000;
|
||||
inline constexpr std::size_t kMaxSessions = 100000;
|
||||
inline constexpr std::size_t kMaxOrmRows = 100000;
|
||||
inline constexpr std::size_t kMaxOrmTables = 500;
|
||||
inline constexpr std::size_t kMaxTasks = 10000;
|
||||
inline constexpr std::size_t kMaxWorkers = 64;
|
||||
inline constexpr std::size_t kMaxReplEntries = 10000;
|
||||
inline constexpr std::size_t kMaxStreamChunks = 1000;
|
||||
inline constexpr std::size_t kMaxChunkBytes = 65536;
|
||||
inline constexpr int kServerBacklog = 64;
|
||||
inline constexpr int kServerTimeoutSec = 30;
|
||||
inline constexpr int kMaxConnections = 512;
|
||||
inline constexpr std::size_t kMaxTestBody = 4 << 20;
|
||||
inline constexpr int kSuiteTimeoutSec = 600;
|
||||
inline constexpr int kSingleTestTimeoutSec = 30;
|
||||
inline constexpr int kMaxReleasePerRun = 1;
|
||||
inline constexpr int kMaxChangelogLines = 20;
|
||||
inline constexpr int kMaxCommitSubject = 100;
|
||||
inline constexpr int kPipelineTimeoutMin = 30;
|
||||
inline constexpr std::size_t kMaxOpenApiRoutes = 4096;
|
||||
inline constexpr std::size_t kMaxWebdavEntries = 10000;
|
||||
|
||||
} // namespace molo::example::fw::caps
|
||||
@@ -0,0 +1,49 @@
|
||||
#include "Cookies.hpp"
|
||||
|
||||
#include <cstdio>
|
||||
|
||||
namespace molo::example::fw {
|
||||
|
||||
namespace {
|
||||
|
||||
unsigned long long fnv1a(const std::string& s) {
|
||||
unsigned long long h = 1469598103934665603ULL;
|
||||
for (unsigned char c : s) {
|
||||
h ^= c;
|
||||
h *= 1099511628211ULL;
|
||||
}
|
||||
return h;
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
std::string cookie_sign(const std::string& value, const std::string& secret) {
|
||||
char buf[32];
|
||||
std::snprintf(buf, sizeof(buf), "%016llx",
|
||||
(unsigned long long)fnv1a(secret + "|" + value));
|
||||
return value + "." + buf;
|
||||
}
|
||||
|
||||
bool cookie_verify(const std::string& signed_value, const std::string& secret,
|
||||
std::string& out_value) {
|
||||
std::size_t dot = signed_value.rfind('.');
|
||||
if (dot == std::string::npos) return false;
|
||||
std::string value = signed_value.substr(0, dot);
|
||||
return cookie_sign(value, secret) == signed_value
|
||||
? (out_value = value, true)
|
||||
: false;
|
||||
}
|
||||
|
||||
std::string make_set_cookie(const std::string& name, const std::string& value,
|
||||
const std::string& path, bool http_only,
|
||||
bool secure, const std::string& same_site,
|
||||
long max_age) {
|
||||
std::string out = name + "=" + value + "; Path=" + path;
|
||||
if (max_age >= 0) out += "; Max-Age=" + std::to_string(max_age);
|
||||
if (http_only) out += "; HttpOnly";
|
||||
if (secure) out += "; Secure";
|
||||
out += "; SameSite=" + same_site;
|
||||
return out;
|
||||
}
|
||||
|
||||
} // namespace molo::example::fw
|
||||
@@ -0,0 +1,21 @@
|
||||
#pragma once
|
||||
|
||||
// First-class cookies: sign and verify values for session cookies.
|
||||
// Signing here is a keyed FNV-1a demonstration, NOT production crypto.
|
||||
// Gap: real signing and encryption live in 03-security.md (AES-GCM or
|
||||
// ChaCha20-Poly1305); this stub exists so the example compiles and runs.
|
||||
|
||||
#include <string>
|
||||
|
||||
namespace molo::example::fw {
|
||||
|
||||
std::string cookie_sign(const std::string& value, const std::string& secret);
|
||||
bool cookie_verify(const std::string& signed_value, const std::string& secret,
|
||||
std::string& out_value);
|
||||
|
||||
std::string make_set_cookie(const std::string& name, const std::string& value,
|
||||
const std::string& path, bool http_only,
|
||||
bool secure, const std::string& same_site,
|
||||
long max_age);
|
||||
|
||||
} // namespace molo::example::fw
|
||||
@@ -0,0 +1,272 @@
|
||||
#include "Data.hpp"
|
||||
|
||||
#include <cstdio>
|
||||
|
||||
#include "Caps.hpp"
|
||||
|
||||
namespace molo::example::fw {
|
||||
|
||||
std::string SessionStore::create() {
|
||||
std::lock_guard<std::mutex> lock(mu_);
|
||||
std::string id = "sess-" + std::to_string(next_id_++);
|
||||
sessions_[id] = {};
|
||||
return id;
|
||||
}
|
||||
|
||||
bool SessionStore::get(const std::string& id,
|
||||
std::map<std::string, std::string>& out) {
|
||||
std::lock_guard<std::mutex> lock(mu_);
|
||||
auto it = sessions_.find(id);
|
||||
if (it == sessions_.end()) return false;
|
||||
out = it->second;
|
||||
return true;
|
||||
}
|
||||
|
||||
void SessionStore::put(const std::string& id, const std::string& key,
|
||||
const std::string& value) {
|
||||
std::lock_guard<std::mutex> lock(mu_);
|
||||
if (!sessions_.count(id) && sessions_.size() >= caps::kMaxSessions) return;
|
||||
sessions_[id][key] = value;
|
||||
}
|
||||
|
||||
void SessionStore::rotate(const std::string& old_id, std::string& new_id) {
|
||||
std::lock_guard<std::mutex> lock(mu_);
|
||||
new_id = "sess-" + std::to_string(next_id_++);
|
||||
auto it = sessions_.find(old_id);
|
||||
if (it != sessions_.end()) {
|
||||
sessions_[new_id] = it->second;
|
||||
sessions_.erase(it);
|
||||
} else {
|
||||
sessions_[new_id] = {};
|
||||
}
|
||||
}
|
||||
|
||||
std::string demo_hash_password(const std::string& password) {
|
||||
unsigned long long h = 1469598103934665603ULL;
|
||||
std::string salted = "demo-salt|" + password;
|
||||
for (unsigned char c : salted) {
|
||||
h ^= c;
|
||||
h *= 1099511628211ULL;
|
||||
}
|
||||
char buf[32];
|
||||
std::snprintf(buf, sizeof(buf), "demo$%016llx", (unsigned long long)h);
|
||||
return buf;
|
||||
}
|
||||
|
||||
namespace {
|
||||
|
||||
int b64val(char c) {
|
||||
if (c >= 'A' && c <= 'Z') return c - 'A';
|
||||
if (c >= 'a' && c <= 'z') return c - 'a' + 26;
|
||||
if (c >= '0' && c <= '9') return c - '0' + 52;
|
||||
if (c == '+') return 62;
|
||||
if (c == '/') return 63;
|
||||
return -1;
|
||||
}
|
||||
|
||||
std::string b64decode(const std::string& in) {
|
||||
std::string out;
|
||||
int acc = 0, bits = 0;
|
||||
for (char c : in) {
|
||||
if (c == '=') break;
|
||||
int v = b64val(c);
|
||||
if (v < 0) continue;
|
||||
acc = (acc << 6) | v;
|
||||
bits += 6;
|
||||
if (bits >= 8) {
|
||||
bits -= 8;
|
||||
out.push_back((char)((acc >> bits) & 0xFF));
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
bool parse_basic_auth(const std::string& header, std::string& user,
|
||||
std::string& password) {
|
||||
const std::string prefix = "Basic ";
|
||||
if (header.compare(0, prefix.size(), prefix) != 0) return false;
|
||||
std::string decoded = b64decode(header.substr(prefix.size()));
|
||||
std::size_t colon = decoded.find(':');
|
||||
if (colon == std::string::npos) return false;
|
||||
user = decoded.substr(0, colon);
|
||||
password = decoded.substr(colon + 1);
|
||||
return true;
|
||||
}
|
||||
|
||||
void Auth::add_user(const std::string& username, const std::string& password) {
|
||||
std::lock_guard<std::mutex> lock(mu_);
|
||||
users_[username] = demo_hash_password(password);
|
||||
}
|
||||
|
||||
bool Auth::check(const std::string& username,
|
||||
const std::string& password) const {
|
||||
std::lock_guard<std::mutex> lock(mu_);
|
||||
auto it = users_.find(username);
|
||||
if (it == users_.end()) return false;
|
||||
return it->second == demo_hash_password(password);
|
||||
}
|
||||
|
||||
std::string backend_name(OrmBackend backend) {
|
||||
return backend == OrmBackend::Sqlite ? "sqlite" : "postgres";
|
||||
}
|
||||
|
||||
OrmBackend parse_backend(const std::string& name) {
|
||||
return name == "postgres" ? OrmBackend::Postgres : OrmBackend::Sqlite;
|
||||
}
|
||||
|
||||
OrmDb::OrmDb(OrmBackend backend) : backend_(backend) {}
|
||||
|
||||
OrmBackend OrmDb::backend() const { return backend_; }
|
||||
|
||||
OrmResult OrmDb::insert(const std::string& table,
|
||||
const std::map<std::string, std::string>& values) {
|
||||
std::lock_guard<std::mutex> lock(mu_);
|
||||
OrmResult res;
|
||||
auto& cols = schema_[table];
|
||||
if (cols.empty()) {
|
||||
if (schema_.size() > caps::kMaxOrmTables) {
|
||||
res.ok = false;
|
||||
return res;
|
||||
}
|
||||
for (const auto& [k, v] : values) cols.push_back(k);
|
||||
res.healed = true;
|
||||
res.healed_note = "auto-created table " + table;
|
||||
} else {
|
||||
for (const auto& [k, v] : values) {
|
||||
bool known = false;
|
||||
for (const auto& c : cols) {
|
||||
if (c == k) {
|
||||
known = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (!known) {
|
||||
cols.push_back(k);
|
||||
res.healed = true;
|
||||
res.healed_note = "auto-added column " + k + " to " + table;
|
||||
}
|
||||
}
|
||||
}
|
||||
auto& vec = rows_[table];
|
||||
if (vec.size() >= caps::kMaxOrmRows) {
|
||||
res.ok = false;
|
||||
return res;
|
||||
}
|
||||
OrmRow row;
|
||||
row.columns = values;
|
||||
vec.push_back(row);
|
||||
res.rows = {row};
|
||||
return res;
|
||||
}
|
||||
|
||||
OrmResult OrmDb::select_all(const std::string& table) {
|
||||
std::lock_guard<std::mutex> lock(mu_);
|
||||
OrmResult res;
|
||||
auto it = rows_.find(table);
|
||||
if (it == rows_.end()) {
|
||||
if (!schema_.count(table)) {
|
||||
schema_[table] = {"id"};
|
||||
res.healed = true;
|
||||
res.healed_note = "auto-created table " + table;
|
||||
}
|
||||
return res;
|
||||
}
|
||||
res.rows = it->second;
|
||||
return res;
|
||||
}
|
||||
|
||||
OrmResult OrmDb::ensure_column(const std::string& table,
|
||||
const std::string& column) {
|
||||
std::lock_guard<std::mutex> lock(mu_);
|
||||
OrmResult res;
|
||||
auto& cols = schema_[table];
|
||||
for (const auto& c : cols) {
|
||||
if (c == column) return res;
|
||||
}
|
||||
cols.push_back(column);
|
||||
res.healed = true;
|
||||
res.healed_note = "auto-added column " + column + " to " + table;
|
||||
return res;
|
||||
}
|
||||
|
||||
std::vector<std::string> OrmDb::tables() const {
|
||||
std::lock_guard<std::mutex> lock(mu_);
|
||||
std::vector<std::string> out;
|
||||
for (const auto& [k, v] : schema_) out.push_back(k);
|
||||
return out;
|
||||
}
|
||||
|
||||
std::vector<std::string> OrmDb::columns(const std::string& table) const {
|
||||
std::lock_guard<std::mutex> lock(mu_);
|
||||
auto it = schema_.find(table);
|
||||
if (it == schema_.end()) return {};
|
||||
return it->second;
|
||||
}
|
||||
|
||||
bool UploadStore::store(const std::string& filename,
|
||||
const std::string& content_type,
|
||||
const std::string& data, UploadInfo& out,
|
||||
std::string& error) {
|
||||
if (data.size() > caps::kMaxUploadBytes) {
|
||||
error = "file too large";
|
||||
return false;
|
||||
}
|
||||
bool allowed = content_type == "text/plain" || content_type == "image/png" ||
|
||||
content_type == "application/json";
|
||||
if (!allowed) {
|
||||
error = "content type not allowed";
|
||||
return false;
|
||||
}
|
||||
if (filename.find("..") != std::string::npos ||
|
||||
filename.find('/') != std::string::npos) {
|
||||
error = "unsafe filename";
|
||||
return false;
|
||||
}
|
||||
std::lock_guard<std::mutex> lock(mu_);
|
||||
out = UploadInfo{filename, content_type, data.size()};
|
||||
uploads_.push_back(out);
|
||||
return true;
|
||||
}
|
||||
|
||||
std::vector<UploadInfo> UploadStore::list() const {
|
||||
std::lock_guard<std::mutex> lock(mu_);
|
||||
return uploads_;
|
||||
}
|
||||
|
||||
void WebdavStore::put(const std::string& path, const std::string& content) {
|
||||
std::lock_guard<std::mutex> lock(mu_);
|
||||
if (!files_.count(path) && files_.size() >= caps::kMaxWebdavEntries) return;
|
||||
files_[path] = content;
|
||||
}
|
||||
|
||||
bool WebdavStore::get(const std::string& path, std::string& out) const {
|
||||
std::lock_guard<std::mutex> lock(mu_);
|
||||
auto it = files_.find(path);
|
||||
if (it == files_.end()) return false;
|
||||
out = it->second;
|
||||
return true;
|
||||
}
|
||||
|
||||
bool WebdavStore::remove(const std::string& path) {
|
||||
std::lock_guard<std::mutex> lock(mu_);
|
||||
return files_.erase(path) > 0;
|
||||
}
|
||||
|
||||
std::string WebdavStore::propfind_xml(const std::string& root) const {
|
||||
std::lock_guard<std::mutex> lock(mu_);
|
||||
std::string out =
|
||||
"<?xml version=\"1.0\"?><D:multistatus xmlns:D=\"DAV:\">";
|
||||
for (const auto& [k, v] : files_) {
|
||||
if (k.compare(0, root.size(), root) != 0) continue;
|
||||
out += "<D:response><D:href>" + k +
|
||||
"</D:href><D:propstat><D:prop><D:getcontentlength>" +
|
||||
std::to_string(v.size()) +
|
||||
"</D:getcontentlength></D:prop><D:status>HTTP/1.1 200 OK"
|
||||
"</D:status></D:propstat></D:response>";
|
||||
}
|
||||
return out + "</D:multistatus>";
|
||||
}
|
||||
|
||||
} // namespace molo::example::fw
|
||||
@@ -0,0 +1,113 @@
|
||||
#pragma once
|
||||
|
||||
// Data layer skeletons: sessions, basic auth, dual-backend ORM stub with
|
||||
// self-healing demo, upload store, and WebDAV store.
|
||||
// Gap: real 04-data.md owns the Postgres wire client, SQLite handling,
|
||||
// pooling, transactions, and production self-healing; real password hashing
|
||||
// (Argon2id) and encryption live in 03-security.md.
|
||||
|
||||
#include <map>
|
||||
#include <mutex>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
namespace molo::example::fw {
|
||||
|
||||
class SessionStore {
|
||||
public:
|
||||
std::string create();
|
||||
bool get(const std::string& id, std::map<std::string, std::string>& out);
|
||||
void put(const std::string& id, const std::string& key,
|
||||
const std::string& value);
|
||||
void rotate(const std::string& old_id, std::string& new_id);
|
||||
|
||||
private:
|
||||
mutable std::mutex mu_;
|
||||
std::map<std::string, std::map<std::string, std::string>> sessions_;
|
||||
unsigned long next_id_ = 1;
|
||||
};
|
||||
|
||||
struct BasicUser {
|
||||
std::string username;
|
||||
std::string password_hash;
|
||||
};
|
||||
|
||||
std::string demo_hash_password(const std::string& password);
|
||||
bool parse_basic_auth(const std::string& header, std::string& user,
|
||||
std::string& password);
|
||||
|
||||
class Auth {
|
||||
public:
|
||||
void add_user(const std::string& username, const std::string& password);
|
||||
bool check(const std::string& username, const std::string& password) const;
|
||||
|
||||
private:
|
||||
mutable std::mutex mu_;
|
||||
std::map<std::string, std::string> users_;
|
||||
};
|
||||
|
||||
enum class OrmBackend { Sqlite, Postgres };
|
||||
|
||||
std::string backend_name(OrmBackend backend);
|
||||
OrmBackend parse_backend(const std::string& name);
|
||||
|
||||
struct OrmRow {
|
||||
std::map<std::string, std::string> columns;
|
||||
};
|
||||
|
||||
struct OrmResult {
|
||||
bool ok = true;
|
||||
bool healed = false;
|
||||
std::string healed_note;
|
||||
std::vector<OrmRow> rows;
|
||||
};
|
||||
|
||||
class OrmDb {
|
||||
public:
|
||||
explicit OrmDb(OrmBackend backend);
|
||||
|
||||
OrmBackend backend() const;
|
||||
OrmResult insert(const std::string& table,
|
||||
const std::map<std::string, std::string>& values);
|
||||
OrmResult select_all(const std::string& table);
|
||||
OrmResult ensure_column(const std::string& table, const std::string& column);
|
||||
std::vector<std::string> tables() const;
|
||||
std::vector<std::string> columns(const std::string& table) const;
|
||||
|
||||
private:
|
||||
OrmBackend backend_;
|
||||
mutable std::mutex mu_;
|
||||
std::map<std::string, std::vector<std::string>> schema_;
|
||||
std::map<std::string, std::vector<OrmRow>> rows_;
|
||||
};
|
||||
|
||||
struct UploadInfo {
|
||||
std::string filename;
|
||||
std::string content_type;
|
||||
std::size_t size = 0;
|
||||
};
|
||||
|
||||
class UploadStore {
|
||||
public:
|
||||
bool store(const std::string& filename, const std::string& content_type,
|
||||
const std::string& data, UploadInfo& out, std::string& error);
|
||||
std::vector<UploadInfo> list() const;
|
||||
|
||||
private:
|
||||
mutable std::mutex mu_;
|
||||
std::vector<UploadInfo> uploads_;
|
||||
};
|
||||
|
||||
class WebdavStore {
|
||||
public:
|
||||
void put(const std::string& path, const std::string& content);
|
||||
bool get(const std::string& path, std::string& out) const;
|
||||
bool remove(const std::string& path);
|
||||
std::string propfind_xml(const std::string& root) const;
|
||||
|
||||
private:
|
||||
mutable std::mutex mu_;
|
||||
std::map<std::string, std::string> files_;
|
||||
};
|
||||
|
||||
} // namespace molo::example::fw
|
||||
@@ -0,0 +1,266 @@
|
||||
#include "Http.hpp"
|
||||
|
||||
#include <cctype>
|
||||
|
||||
#include "Caps.hpp"
|
||||
|
||||
namespace molo::example::fw {
|
||||
|
||||
Method parse_method(std::string_view name) {
|
||||
if (name == "GET") return Method::Get;
|
||||
if (name == "POST") return Method::Post;
|
||||
if (name == "PUT") return Method::Put;
|
||||
if (name == "PATCH") return Method::Patch;
|
||||
if (name == "DELETE") return Method::Delete;
|
||||
if (name == "HEAD") return Method::Head;
|
||||
if (name == "OPTIONS") return Method::Options;
|
||||
if (name == "PROPFIND") return Method::Propfind;
|
||||
if (name == "PROPPATCH") return Method::Proppatch;
|
||||
if (name == "MKCOL") return Method::Mkcol;
|
||||
if (name == "COPY") return Method::Copy;
|
||||
if (name == "MOVE") return Method::Move;
|
||||
if (name == "LOCK") return Method::Lock;
|
||||
if (name == "UNLOCK") return Method::Unlock;
|
||||
return Method::Unknown;
|
||||
}
|
||||
|
||||
std::string method_name(Method method) {
|
||||
switch (method) {
|
||||
case Method::Get: return "GET";
|
||||
case Method::Post: return "POST";
|
||||
case Method::Put: return "PUT";
|
||||
case Method::Patch: return "PATCH";
|
||||
case Method::Delete: return "DELETE";
|
||||
case Method::Head: return "HEAD";
|
||||
case Method::Options: return "OPTIONS";
|
||||
case Method::Propfind: return "PROPFIND";
|
||||
case Method::Proppatch: return "PROPPATCH";
|
||||
case Method::Mkcol: return "MKCOL";
|
||||
case Method::Copy: return "COPY";
|
||||
case Method::Move: return "MOVE";
|
||||
case Method::Lock: return "LOCK";
|
||||
case Method::Unlock: return "UNLOCK";
|
||||
case Method::Unknown: return "UNKNOWN";
|
||||
}
|
||||
return "UNKNOWN";
|
||||
}
|
||||
|
||||
namespace {
|
||||
|
||||
bool name_eq(std::string_view a, std::string_view b) {
|
||||
if (a.size() != b.size()) return false;
|
||||
for (std::size_t i = 0; i < a.size(); ++i) {
|
||||
if (std::tolower((unsigned char)a[i]) != std::tolower((unsigned char)b[i]))
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
void Headers::set(std::string name, std::string value) {
|
||||
for (auto& e : entries_) {
|
||||
if (name_eq(e.first, name)) {
|
||||
e.second = value;
|
||||
return;
|
||||
}
|
||||
}
|
||||
entries_.emplace_back(std::move(name), std::move(value));
|
||||
}
|
||||
|
||||
std::string Headers::get(std::string_view name) const {
|
||||
for (const auto& e : entries_) {
|
||||
if (name_eq(e.first, name)) return e.second;
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
bool Headers::has(std::string_view name) const {
|
||||
for (const auto& e : entries_) {
|
||||
if (name_eq(e.first, name)) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
const std::vector<std::pair<std::string, std::string>>& Headers::entries()
|
||||
const {
|
||||
return entries_;
|
||||
}
|
||||
|
||||
std::string Request::query_or(std::string_view key,
|
||||
std::string_view fallback) const {
|
||||
auto it = query.find(std::string(key));
|
||||
if (it == query.end()) return std::string(fallback);
|
||||
return it->second;
|
||||
}
|
||||
|
||||
std::string Request::param_or(std::string_view key,
|
||||
std::string_view fallback) const {
|
||||
auto it = path_params.find(std::string(key));
|
||||
if (it == path_params.end()) return std::string(fallback);
|
||||
return it->second;
|
||||
}
|
||||
|
||||
std::string Request::cookie_or(std::string_view key,
|
||||
std::string_view fallback) const {
|
||||
auto it = cookies.find(std::string(key));
|
||||
if (it == cookies.end()) return std::string(fallback);
|
||||
return it->second;
|
||||
}
|
||||
|
||||
Response::Response(int status, std::string body, std::string content_type)
|
||||
: status(status), body(std::move(body)) {
|
||||
headers.set("Content-Type", std::move(content_type));
|
||||
}
|
||||
|
||||
std::string Response::content_type() const {
|
||||
return headers.get("Content-Type");
|
||||
}
|
||||
|
||||
TextResponse::TextResponse(std::string body, int status)
|
||||
: Response(status, std::move(body), "text/plain; charset=utf-8") {}
|
||||
|
||||
HtmlResponse::HtmlResponse(std::string body, int status)
|
||||
: Response(status, std::move(body), "text/html; charset=utf-8") {}
|
||||
|
||||
JsonResponse::JsonResponse(std::string body, int status)
|
||||
: Response(status, std::move(body), "application/json") {}
|
||||
|
||||
XmlResponse::XmlResponse(std::string body, int status)
|
||||
: Response(status, std::move(body), "application/xml") {}
|
||||
|
||||
RedirectResponse::RedirectResponse(std::string location, bool permanent)
|
||||
: Response(permanent ? 301 : 302, "", "text/plain; charset=utf-8") {
|
||||
headers.set("Location", std::move(location));
|
||||
}
|
||||
|
||||
FileResponse::FileResponse(std::string body, std::string content_type,
|
||||
std::string filename)
|
||||
: Response(200, std::move(body), std::move(content_type)) {
|
||||
headers.set("Content-Disposition",
|
||||
"attachment; filename=\"" + filename + "\"");
|
||||
}
|
||||
|
||||
StreamingResponse::StreamingResponse(std::vector<std::string> chunks)
|
||||
: Response(200, "", "text/plain; charset=utf-8"), chunks(std::move(chunks)) {
|
||||
for (const auto& c : this->chunks) body += c;
|
||||
headers.set("Transfer-Encoding", "chunked");
|
||||
if (this->chunks.size() > caps::kMaxStreamChunks) {
|
||||
status = 500;
|
||||
body = "chunk cap exceeded";
|
||||
}
|
||||
}
|
||||
|
||||
SseResponse::SseResponse(std::vector<std::string> events)
|
||||
: Response(200, "", "text/event-stream"), events(std::move(events)) {
|
||||
for (const auto& e : this->events) body += "data: " + e + "\n\n";
|
||||
}
|
||||
|
||||
std::string url_decode(std::string_view in) {
|
||||
std::string out;
|
||||
out.reserve(in.size());
|
||||
for (std::size_t i = 0; i < in.size(); ++i) {
|
||||
if (in[i] == '%' && i + 2 < in.size() &&
|
||||
std::isxdigit((unsigned char)in[i + 1]) &&
|
||||
std::isxdigit((unsigned char)in[i + 2])) {
|
||||
int hi = std::isdigit((unsigned char)in[i + 1])
|
||||
? in[i + 1] - '0'
|
||||
: std::tolower((unsigned char)in[i + 1]) - 'a' + 10;
|
||||
int lo = std::isdigit((unsigned char)in[i + 2])
|
||||
? in[i + 2] - '0'
|
||||
: std::tolower((unsigned char)in[i + 2]) - 'a' + 10;
|
||||
out.push_back((char)(hi * 16 + lo));
|
||||
i += 2;
|
||||
} else if (in[i] == '+') {
|
||||
out.push_back(' ');
|
||||
} else {
|
||||
out.push_back(in[i]);
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
std::map<std::string, std::string> parse_query(std::string_view qs) {
|
||||
std::map<std::string, std::string> out;
|
||||
std::size_t start = 0;
|
||||
std::size_t fields = 0;
|
||||
while (start <= qs.size() && fields < caps::kMaxFormFields) {
|
||||
std::size_t amp = qs.find('&', start);
|
||||
if (amp == std::string_view::npos) amp = qs.size();
|
||||
std::string_view pair = qs.substr(start, amp - start);
|
||||
std::size_t eq = pair.find('=');
|
||||
if (eq == std::string_view::npos) {
|
||||
if (!pair.empty()) out[url_decode(pair)] = "";
|
||||
} else {
|
||||
out[url_decode(pair.substr(0, eq))] = url_decode(pair.substr(eq + 1));
|
||||
}
|
||||
++fields;
|
||||
start = amp + 1;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
std::map<std::string, std::string> parse_cookies(std::string_view header) {
|
||||
std::map<std::string, std::string> out;
|
||||
std::size_t start = 0;
|
||||
while (start < header.size()) {
|
||||
std::size_t semi = header.find(';', start);
|
||||
if (semi == std::string_view::npos) semi = header.size();
|
||||
std::string_view pair = header.substr(start, semi - start);
|
||||
std::size_t eq = pair.find('=');
|
||||
if (eq != std::string_view::npos) {
|
||||
std::string k(pair.substr(0, eq));
|
||||
std::string v(pair.substr(eq + 1));
|
||||
while (!k.empty() && k.front() == ' ') k.erase(k.begin());
|
||||
while (!v.empty() && v.front() == ' ') v.erase(v.begin());
|
||||
if (!k.empty()) out[k] = v;
|
||||
}
|
||||
start = semi + 1;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
std::map<std::string, std::string> parse_form(std::string_view body) {
|
||||
return parse_query(body);
|
||||
}
|
||||
|
||||
std::string status_text(int status) {
|
||||
switch (status) {
|
||||
case 200: return "OK";
|
||||
case 201: return "Created";
|
||||
case 207: return "Multi-Status";
|
||||
case 301: return "Moved Permanently";
|
||||
case 302: return "Found";
|
||||
case 400: return "Bad Request";
|
||||
case 401: return "Unauthorized";
|
||||
case 403: return "Forbidden";
|
||||
case 404: return "Not Found";
|
||||
case 405: return "Method Not Allowed";
|
||||
case 406: return "Not Acceptable";
|
||||
case 409: return "Conflict";
|
||||
case 413: return "Content Too Large";
|
||||
case 422: return "Unprocessable Entity";
|
||||
case 429: return "Too Many Requests";
|
||||
case 500: return "Internal Server Error";
|
||||
case 501: return "Not Implemented";
|
||||
default: return "Unknown";
|
||||
}
|
||||
}
|
||||
|
||||
std::string json_escape(std::string_view in) {
|
||||
std::string out;
|
||||
out.reserve(in.size() + 2);
|
||||
for (char c : in) {
|
||||
switch (c) {
|
||||
case '"': out += "\\\""; break;
|
||||
case '\\': out += "\\\\"; break;
|
||||
case '\n': out += "\\n"; break;
|
||||
case '\r': out += "\\r"; break;
|
||||
case '\t': out += "\\t"; break;
|
||||
default: out.push_back(c); break;
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
} // namespace molo::example::fw
|
||||
@@ -0,0 +1,114 @@
|
||||
#pragma once
|
||||
|
||||
// HTTP message types: one Request, one Response hierarchy with a subclass per
|
||||
// format. Subclasses are constructor conveniences over the base value type so
|
||||
// handlers can return by value without slicing loss. Gap: real molo::http_msg
|
||||
// in 05-request-routing.md owns the final API; this skeleton mirrors it.
|
||||
|
||||
#include <map>
|
||||
#include <string>
|
||||
#include <string_view>
|
||||
#include <vector>
|
||||
|
||||
namespace molo::example::fw {
|
||||
|
||||
enum class Method {
|
||||
Get,
|
||||
Post,
|
||||
Put,
|
||||
Patch,
|
||||
Delete,
|
||||
Head,
|
||||
Options,
|
||||
Propfind,
|
||||
Proppatch,
|
||||
Mkcol,
|
||||
Copy,
|
||||
Move,
|
||||
Lock,
|
||||
Unlock,
|
||||
Unknown,
|
||||
};
|
||||
|
||||
Method parse_method(std::string_view name);
|
||||
std::string method_name(Method method);
|
||||
|
||||
class Headers {
|
||||
public:
|
||||
void set(std::string name, std::string value);
|
||||
std::string get(std::string_view name) const;
|
||||
bool has(std::string_view name) const;
|
||||
const std::vector<std::pair<std::string, std::string>>& entries() const;
|
||||
|
||||
private:
|
||||
std::vector<std::pair<std::string, std::string>> entries_;
|
||||
};
|
||||
|
||||
struct Request {
|
||||
Method method = Method::Get;
|
||||
std::string path = "/";
|
||||
std::map<std::string, std::string> query;
|
||||
Headers headers;
|
||||
std::map<std::string, std::string> cookies;
|
||||
std::map<std::string, std::string> path_params;
|
||||
std::string body;
|
||||
std::string correlation_id;
|
||||
|
||||
std::string query_or(std::string_view key, std::string_view fallback) const;
|
||||
std::string param_or(std::string_view key, std::string_view fallback) const;
|
||||
std::string cookie_or(std::string_view key, std::string_view fallback) const;
|
||||
};
|
||||
|
||||
struct Response {
|
||||
int status = 200;
|
||||
Headers headers;
|
||||
std::string body;
|
||||
|
||||
Response() = default;
|
||||
Response(int status, std::string body, std::string content_type);
|
||||
|
||||
std::string content_type() const;
|
||||
};
|
||||
|
||||
struct TextResponse : Response {
|
||||
explicit TextResponse(std::string body, int status = 200);
|
||||
};
|
||||
|
||||
struct HtmlResponse : Response {
|
||||
explicit HtmlResponse(std::string body, int status = 200);
|
||||
};
|
||||
|
||||
struct JsonResponse : Response {
|
||||
explicit JsonResponse(std::string body, int status = 200);
|
||||
};
|
||||
|
||||
struct XmlResponse : Response {
|
||||
explicit XmlResponse(std::string body, int status = 200);
|
||||
};
|
||||
|
||||
struct RedirectResponse : Response {
|
||||
RedirectResponse(std::string location, bool permanent);
|
||||
};
|
||||
|
||||
struct FileResponse : Response {
|
||||
FileResponse(std::string body, std::string content_type, std::string filename);
|
||||
};
|
||||
|
||||
struct StreamingResponse : Response {
|
||||
explicit StreamingResponse(std::vector<std::string> chunks);
|
||||
std::vector<std::string> chunks;
|
||||
};
|
||||
|
||||
struct SseResponse : Response {
|
||||
explicit SseResponse(std::vector<std::string> events);
|
||||
std::vector<std::string> events;
|
||||
};
|
||||
|
||||
std::string url_decode(std::string_view in);
|
||||
std::map<std::string, std::string> parse_query(std::string_view qs);
|
||||
std::map<std::string, std::string> parse_cookies(std::string_view header);
|
||||
std::map<std::string, std::string> parse_form(std::string_view body);
|
||||
std::string status_text(int status);
|
||||
std::string json_escape(std::string_view in);
|
||||
|
||||
} // namespace molo::example::fw
|
||||
@@ -0,0 +1,113 @@
|
||||
#include "Jobs.hpp"
|
||||
|
||||
#include "Caps.hpp"
|
||||
|
||||
namespace molo::example::fw {
|
||||
|
||||
void Supervisor::add_worker(const std::string& name) {
|
||||
std::lock_guard<std::mutex> lock(mu_);
|
||||
if (!workers_.count(name) && workers_.size() >= caps::kMaxWorkers) return;
|
||||
workers_[name] = WorkerInfo{name, false, 0};
|
||||
}
|
||||
|
||||
bool Supervisor::start(const std::string& name) {
|
||||
std::lock_guard<std::mutex> lock(mu_);
|
||||
auto it = workers_.find(name);
|
||||
if (it == workers_.end()) return false;
|
||||
it->second.running = true;
|
||||
return true;
|
||||
}
|
||||
|
||||
bool Supervisor::stop(const std::string& name) {
|
||||
std::lock_guard<std::mutex> lock(mu_);
|
||||
auto it = workers_.find(name);
|
||||
if (it == workers_.end()) return false;
|
||||
it->second.running = false;
|
||||
return true;
|
||||
}
|
||||
|
||||
bool Supervisor::restart(const std::string& name) {
|
||||
std::lock_guard<std::mutex> lock(mu_);
|
||||
auto it = workers_.find(name);
|
||||
if (it == workers_.end()) return false;
|
||||
it->second.running = true;
|
||||
++it->second.restarts;
|
||||
return true;
|
||||
}
|
||||
|
||||
std::vector<WorkerInfo> Supervisor::status() const {
|
||||
std::lock_guard<std::mutex> lock(mu_);
|
||||
std::vector<WorkerInfo> out;
|
||||
for (const auto& [k, v] : workers_) out.push_back(v);
|
||||
return out;
|
||||
}
|
||||
|
||||
std::string TaskQueue::enqueue(const std::string& kind,
|
||||
const std::string& payload, long delay_sec) {
|
||||
std::lock_guard<std::mutex> lock(mu_);
|
||||
if (tasks_.size() >= caps::kMaxTasks) return "";
|
||||
std::string id = "task-" + std::to_string(next_id_++);
|
||||
TaskInfo t{id, kind, payload,
|
||||
delay_sec > 0 ? "delayed" : "queued", 0};
|
||||
tasks_[id] = t;
|
||||
return id;
|
||||
}
|
||||
|
||||
bool TaskQueue::cancel(const std::string& id) {
|
||||
std::lock_guard<std::mutex> lock(mu_);
|
||||
auto it = tasks_.find(id);
|
||||
if (it == tasks_.end()) return false;
|
||||
it->second.state = "cancelled";
|
||||
return true;
|
||||
}
|
||||
|
||||
bool TaskQueue::retry(const std::string& id) {
|
||||
std::lock_guard<std::mutex> lock(mu_);
|
||||
auto it = tasks_.find(id);
|
||||
if (it == tasks_.end()) return false;
|
||||
++it->second.attempts;
|
||||
it->second.state = "queued";
|
||||
return true;
|
||||
}
|
||||
|
||||
void TaskQueue::run_inline(TaskInfo& task) {
|
||||
task.state = "done";
|
||||
std::lock_guard<std::mutex> lock(mu_);
|
||||
auto it = tasks_.find(task.id);
|
||||
if (it != tasks_.end()) it->second.state = "done";
|
||||
}
|
||||
|
||||
std::vector<TaskInfo> TaskQueue::list() const {
|
||||
std::lock_guard<std::mutex> lock(mu_);
|
||||
std::vector<TaskInfo> out;
|
||||
for (const auto& [k, v] : tasks_) out.push_back(v);
|
||||
return out;
|
||||
}
|
||||
|
||||
ReplEntry ReplicationLog::append(const std::string& object,
|
||||
const std::string& data) {
|
||||
std::lock_guard<std::mutex> lock(mu_);
|
||||
ReplEntry e;
|
||||
e.seq = entries_.size() + 1;
|
||||
e.object = object;
|
||||
e.data = data;
|
||||
e.resolution = "last-writer-wins";
|
||||
if (entries_.size() < caps::kMaxReplEntries) entries_.push_back(e);
|
||||
return e;
|
||||
}
|
||||
|
||||
std::vector<ReplEntry> ReplicationLog::since(unsigned long seq) const {
|
||||
std::lock_guard<std::mutex> lock(mu_);
|
||||
std::vector<ReplEntry> out;
|
||||
for (const auto& e : entries_) {
|
||||
if (e.seq > seq) out.push_back(e);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
unsigned long ReplicationLog::latest() const {
|
||||
std::lock_guard<std::mutex> lock(mu_);
|
||||
return entries_.size();
|
||||
}
|
||||
|
||||
} // namespace molo::example::fw
|
||||
@@ -0,0 +1,75 @@
|
||||
#pragma once
|
||||
|
||||
// Supervisor plus background tasks plus replication log skeletons.
|
||||
// Gap: real 07-supervisor-replication.md owns worker processes, restarts,
|
||||
// durable streams, conflict handling, and multi-instance deployment.
|
||||
|
||||
#include <functional>
|
||||
#include <map>
|
||||
#include <mutex>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
namespace molo::example::fw {
|
||||
|
||||
struct WorkerInfo {
|
||||
std::string name;
|
||||
bool running = false;
|
||||
int restarts = 0;
|
||||
};
|
||||
|
||||
class Supervisor {
|
||||
public:
|
||||
void add_worker(const std::string& name);
|
||||
bool start(const std::string& name);
|
||||
bool stop(const std::string& name);
|
||||
bool restart(const std::string& name);
|
||||
std::vector<WorkerInfo> status() const;
|
||||
|
||||
private:
|
||||
mutable std::mutex mu_;
|
||||
std::map<std::string, WorkerInfo> workers_;
|
||||
};
|
||||
|
||||
struct TaskInfo {
|
||||
std::string id;
|
||||
std::string kind;
|
||||
std::string payload;
|
||||
std::string state;
|
||||
int attempts = 0;
|
||||
};
|
||||
|
||||
class TaskQueue {
|
||||
public:
|
||||
std::string enqueue(const std::string& kind, const std::string& payload,
|
||||
long delay_sec = 0);
|
||||
bool cancel(const std::string& id);
|
||||
bool retry(const std::string& id);
|
||||
void run_inline(TaskInfo& task);
|
||||
std::vector<TaskInfo> list() const;
|
||||
|
||||
private:
|
||||
mutable std::mutex mu_;
|
||||
std::map<std::string, TaskInfo> tasks_;
|
||||
unsigned long next_id_ = 1;
|
||||
};
|
||||
|
||||
struct ReplEntry {
|
||||
unsigned long seq = 0;
|
||||
std::string object;
|
||||
std::string data;
|
||||
std::string resolution;
|
||||
};
|
||||
|
||||
class ReplicationLog {
|
||||
public:
|
||||
ReplEntry append(const std::string& object, const std::string& data);
|
||||
std::vector<ReplEntry> since(unsigned long seq) const;
|
||||
unsigned long latest() const;
|
||||
|
||||
private:
|
||||
mutable std::mutex mu_;
|
||||
std::vector<ReplEntry> entries_;
|
||||
};
|
||||
|
||||
} // namespace molo::example::fw
|
||||
@@ -0,0 +1,95 @@
|
||||
#include "Kv.hpp"
|
||||
|
||||
#include "Caps.hpp"
|
||||
|
||||
namespace molo::example::fw {
|
||||
|
||||
bool KvStore::set(const std::string& key, const std::string& value) {
|
||||
std::lock_guard<std::mutex> lock(mu_);
|
||||
if (!data_.count(key) && data_.size() >= caps::kMaxKvEntries) return false;
|
||||
data_[key] = value;
|
||||
return true;
|
||||
}
|
||||
|
||||
bool KvStore::get(const std::string& key, std::string& out) const {
|
||||
std::lock_guard<std::mutex> lock(mu_);
|
||||
auto it = data_.find(key);
|
||||
if (it == data_.end()) return false;
|
||||
out = it->second;
|
||||
return true;
|
||||
}
|
||||
|
||||
bool KvStore::del(const std::string& key) {
|
||||
std::lock_guard<std::mutex> lock(mu_);
|
||||
return data_.erase(key) > 0;
|
||||
}
|
||||
|
||||
std::size_t KvStore::size() const {
|
||||
std::lock_guard<std::mutex> lock(mu_);
|
||||
return data_.size();
|
||||
}
|
||||
|
||||
void Cache::put(const std::string& key, const std::string& value, long ttl_sec,
|
||||
long now) {
|
||||
std::lock_guard<std::mutex> lock(mu_);
|
||||
if (!data_.count(key) && data_.size() >= caps::kMaxCacheEntries) return;
|
||||
data_[key] = CacheEntry{value, now + ttl_sec};
|
||||
}
|
||||
|
||||
bool Cache::lookup(const std::string& key, std::string& out, long now) {
|
||||
std::lock_guard<std::mutex> lock(mu_);
|
||||
auto it = data_.find(key);
|
||||
if (it == data_.end()) return false;
|
||||
if (it->second.expires_at <= now) {
|
||||
data_.erase(it);
|
||||
return false;
|
||||
}
|
||||
out = it->second.value;
|
||||
return true;
|
||||
}
|
||||
|
||||
void Cache::invalidate(const std::string& key) {
|
||||
std::lock_guard<std::mutex> lock(mu_);
|
||||
data_.erase(key);
|
||||
}
|
||||
|
||||
std::size_t Cache::size() const {
|
||||
std::lock_guard<std::mutex> lock(mu_);
|
||||
return data_.size();
|
||||
}
|
||||
|
||||
bool Pubsub::subscribe(const std::string& topic, PubsubHandler handler) {
|
||||
std::lock_guard<std::mutex> lock(mu_);
|
||||
if (!subs_.count(topic) && subs_.size() >= caps::kMaxPubsubTopics)
|
||||
return false;
|
||||
subs_[topic].push_back(std::move(handler));
|
||||
return true;
|
||||
}
|
||||
|
||||
std::size_t Pubsub::publish(const std::string& topic,
|
||||
const std::string& message) {
|
||||
std::lock_guard<std::mutex> lock(mu_);
|
||||
auto& hist = history_[topic];
|
||||
hist.push_back(message);
|
||||
if (hist.size() > caps::kMaxPubsubHistory) hist.erase(hist.begin());
|
||||
auto it = subs_.find(topic);
|
||||
if (it == subs_.end()) return 0;
|
||||
for (auto& h : it->second) h(message);
|
||||
return it->second.size();
|
||||
}
|
||||
|
||||
std::vector<std::string> Pubsub::history(const std::string& topic) const {
|
||||
std::lock_guard<std::mutex> lock(mu_);
|
||||
auto it = history_.find(topic);
|
||||
if (it == history_.end()) return {};
|
||||
return it->second;
|
||||
}
|
||||
|
||||
std::vector<std::string> Pubsub::topics() const {
|
||||
std::lock_guard<std::mutex> lock(mu_);
|
||||
std::vector<std::string> out;
|
||||
for (const auto& [k, v] : subs_) out.push_back(k);
|
||||
return out;
|
||||
}
|
||||
|
||||
} // namespace molo::example::fw
|
||||
@@ -0,0 +1,61 @@
|
||||
#pragma once
|
||||
|
||||
// In-memory key-value store, TTL cache, and pubsub broker skeletons.
|
||||
// Gap: real implementations in 04-data.md own persistence, replication,
|
||||
// durability guarantees, and multi-instance sharing.
|
||||
|
||||
#include <cstdint>
|
||||
#include <functional>
|
||||
#include <map>
|
||||
#include <mutex>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
namespace molo::example::fw {
|
||||
|
||||
class KvStore {
|
||||
public:
|
||||
bool set(const std::string& key, const std::string& value);
|
||||
bool get(const std::string& key, std::string& out) const;
|
||||
bool del(const std::string& key);
|
||||
std::size_t size() const;
|
||||
|
||||
private:
|
||||
mutable std::mutex mu_;
|
||||
std::map<std::string, std::string> data_;
|
||||
};
|
||||
|
||||
struct CacheEntry {
|
||||
std::string value;
|
||||
long expires_at = 0;
|
||||
};
|
||||
|
||||
class Cache {
|
||||
public:
|
||||
void put(const std::string& key, const std::string& value, long ttl_sec,
|
||||
long now);
|
||||
bool lookup(const std::string& key, std::string& out, long now);
|
||||
void invalidate(const std::string& key);
|
||||
std::size_t size() const;
|
||||
|
||||
private:
|
||||
mutable std::mutex mu_;
|
||||
std::map<std::string, CacheEntry> data_;
|
||||
};
|
||||
|
||||
using PubsubHandler = std::function<void(const std::string& message)>;
|
||||
|
||||
class Pubsub {
|
||||
public:
|
||||
bool subscribe(const std::string& topic, PubsubHandler handler);
|
||||
std::size_t publish(const std::string& topic, const std::string& message);
|
||||
std::vector<std::string> history(const std::string& topic) const;
|
||||
std::vector<std::string> topics() const;
|
||||
|
||||
private:
|
||||
mutable std::mutex mu_;
|
||||
std::map<std::string, std::vector<PubsubHandler>> subs_;
|
||||
std::map<std::string, std::vector<std::string>> history_;
|
||||
};
|
||||
|
||||
} // namespace molo::example::fw
|
||||
@@ -0,0 +1,70 @@
|
||||
#include "Negotiate.hpp"
|
||||
|
||||
#include <cctype>
|
||||
|
||||
namespace molo::example::fw {
|
||||
|
||||
namespace {
|
||||
|
||||
std::string trim(std::string s) {
|
||||
while (!s.empty() && (s.front() == ' ' || s.front() == '\t')) s.erase(s.begin());
|
||||
while (!s.empty() && (s.back() == ' ' || s.back() == '\t')) s.pop_back();
|
||||
return s;
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
std::string negotiate(const std::string& accept,
|
||||
const std::vector<std::string>& available) {
|
||||
if (available.empty()) throw HttpError(500, "no_offers", "no offers");
|
||||
if (accept.empty()) return available.front();
|
||||
struct Range {
|
||||
std::string type;
|
||||
double q = 1.0;
|
||||
};
|
||||
std::vector<Range> ranges;
|
||||
std::size_t start = 0;
|
||||
while (start < accept.size()) {
|
||||
std::size_t comma = accept.find(',', start);
|
||||
if (comma == std::string::npos) comma = accept.size();
|
||||
std::string part = trim(accept.substr(start, comma - start));
|
||||
std::size_t semi = part.find(';');
|
||||
Range r{trim(part.substr(0, semi)), 1.0};
|
||||
if (semi != std::string::npos) {
|
||||
std::string params = part.substr(semi + 1);
|
||||
std::size_t q = params.find("q=");
|
||||
if (q != std::string::npos) {
|
||||
try {
|
||||
r.q = std::stod(params.substr(q + 2));
|
||||
} catch (...) {
|
||||
r.q = 1.0;
|
||||
}
|
||||
}
|
||||
}
|
||||
ranges.push_back(r);
|
||||
start = comma + 1;
|
||||
}
|
||||
std::string best;
|
||||
double best_q = -1.0;
|
||||
for (const auto& a : available) {
|
||||
for (const auto& r : ranges) {
|
||||
bool hit = r.type == "*/*" || r.type == a;
|
||||
if (!hit) {
|
||||
std::size_t slash = a.find('/');
|
||||
std::string wild = slash == std::string::npos
|
||||
? "*/*"
|
||||
: a.substr(0, slash) + "/*";
|
||||
hit = r.type == wild;
|
||||
}
|
||||
if (hit && r.q > best_q) {
|
||||
best_q = r.q;
|
||||
best = a;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (best.empty() || best_q <= 0.0)
|
||||
throw HttpError(406, "not_acceptable", "no acceptable format");
|
||||
return best;
|
||||
}
|
||||
|
||||
} // namespace molo::example::fw
|
||||
@@ -0,0 +1,22 @@
|
||||
#pragma once
|
||||
|
||||
// Server-driven content negotiation: parses Accept with q weights, picks one
|
||||
// handler-declared format, answers 406 when nothing matches.
|
||||
// Gap: real molo::negotiate in 05-request-routing.md owns suffix and
|
||||
// format-override rules.
|
||||
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
#include "App.hpp"
|
||||
|
||||
namespace molo::example::fw {
|
||||
|
||||
struct Offer {
|
||||
std::string media_type;
|
||||
};
|
||||
|
||||
std::string negotiate(const std::string& accept,
|
||||
const std::vector<std::string>& available);
|
||||
|
||||
} // namespace molo::example::fw
|
||||
@@ -0,0 +1,82 @@
|
||||
#include "Net.hpp"
|
||||
|
||||
#include "Caps.hpp"
|
||||
|
||||
namespace molo::example::fw {
|
||||
|
||||
RestClient::RestClient(RestTransport transport)
|
||||
: transport_(std::move(transport)) {}
|
||||
|
||||
Response RestClient::get(const std::string& path) {
|
||||
Request req;
|
||||
req.method = Method::Get;
|
||||
req.path = path;
|
||||
return transport_(std::move(req));
|
||||
}
|
||||
|
||||
Response RestClient::post_json(const std::string& path,
|
||||
const std::string& json) {
|
||||
Request req;
|
||||
req.method = Method::Post;
|
||||
req.path = path;
|
||||
req.body = json;
|
||||
req.headers.set("Content-Type", "application/json");
|
||||
return transport_(std::move(req));
|
||||
}
|
||||
|
||||
void OpenApi::add(const std::string& path, const std::string& method,
|
||||
const std::string& summary) {
|
||||
if (routes_.size() >= caps::kMaxOpenApiRoutes) return;
|
||||
routes_.push_back({path, method, summary});
|
||||
}
|
||||
|
||||
std::string OpenApi::to_json() const {
|
||||
std::string out =
|
||||
"{\"openapi\":\"3.0.0\",\"info\":{\"title\":\"living-example\","
|
||||
"\"version\":\"0.1.0\"},\"paths\":{";
|
||||
bool first = true;
|
||||
for (const auto& r : routes_) {
|
||||
if (!first) out += ",";
|
||||
first = false;
|
||||
out += "\"" + json_escape(r.path) + "\":{\"" + json_escape(r.method) +
|
||||
"\":{\"summary\":\"" + json_escape(r.summary) + "\"}}";
|
||||
}
|
||||
return out + "}}";
|
||||
}
|
||||
|
||||
std::size_t OpenApi::size() const { return routes_.size(); }
|
||||
|
||||
std::string redact_secrets(const std::string& text) {
|
||||
std::string out = text;
|
||||
const char* keys[] = {"DEVPLACE_API_KEY", "API_KEY", "SECRET", "TOKEN",
|
||||
"PASSWORD"};
|
||||
for (const char* k : keys) {
|
||||
std::string key(k);
|
||||
std::size_t pos = 0;
|
||||
while ((pos = out.find(key, pos)) != std::string::npos) {
|
||||
std::size_t eol = out.find('\n', pos);
|
||||
if (eol == std::string::npos) eol = out.size();
|
||||
std::size_t eq = out.find('=', pos);
|
||||
if (eq != std::string::npos && eq < eol) {
|
||||
out.replace(eq + 1, eol - eq - 1, "[REDACTED]");
|
||||
pos = eq + 1;
|
||||
} else {
|
||||
pos += key.size();
|
||||
}
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
AiSample ai_harness_sample(const std::string& prompt) {
|
||||
AiSample s;
|
||||
s.base_url = "https://devplace.net/openai/v1";
|
||||
s.model = "molodetz";
|
||||
s.redacted_prompt = redact_secrets(prompt);
|
||||
s.stream_chunks = {"data: {\"delta\":\"plan: add route\"}\n\n",
|
||||
"data: {\"delta\":\"apply: 1 file\"}\n\n",
|
||||
"data: [DONE]\n\n"};
|
||||
return s;
|
||||
}
|
||||
|
||||
} // namespace molo::example::fw
|
||||
@@ -0,0 +1,57 @@
|
||||
#pragma once
|
||||
|
||||
// Client parity skeletons: REST client (self-call transport), realtime
|
||||
// client notes, OpenAPI registry, and the AI harness sample.
|
||||
// Gap: real HTTP/1.1 plus HTTP/2 client in 08-clients-openapi.md, realtime
|
||||
// clients in 06-realtime.md, and the AI harness CLI in 09-cli-ops.md.
|
||||
|
||||
#include <functional>
|
||||
#include <map>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
#include "Http.hpp"
|
||||
|
||||
namespace molo::example::fw {
|
||||
|
||||
using RestTransport = std::function<Response(Request)>;
|
||||
|
||||
class RestClient {
|
||||
public:
|
||||
explicit RestClient(RestTransport transport);
|
||||
|
||||
Response get(const std::string& path);
|
||||
Response post_json(const std::string& path, const std::string& json);
|
||||
|
||||
private:
|
||||
RestTransport transport_;
|
||||
};
|
||||
|
||||
struct OpenApiRoute {
|
||||
std::string path;
|
||||
std::string method;
|
||||
std::string summary;
|
||||
};
|
||||
|
||||
class OpenApi {
|
||||
public:
|
||||
void add(const std::string& path, const std::string& method,
|
||||
const std::string& summary);
|
||||
std::string to_json() const;
|
||||
std::size_t size() const;
|
||||
|
||||
private:
|
||||
std::vector<OpenApiRoute> routes_;
|
||||
};
|
||||
|
||||
struct AiSample {
|
||||
std::string base_url;
|
||||
std::string model;
|
||||
std::string redacted_prompt;
|
||||
std::vector<std::string> stream_chunks;
|
||||
};
|
||||
|
||||
std::string redact_secrets(const std::string& text);
|
||||
AiSample ai_harness_sample(const std::string& prompt);
|
||||
|
||||
} // namespace molo::example::fw
|
||||
@@ -0,0 +1,86 @@
|
||||
#include "Observ.hpp"
|
||||
|
||||
#include <cstdio>
|
||||
|
||||
#include "Caps.hpp"
|
||||
|
||||
namespace molo::example::fw {
|
||||
|
||||
void EventBus::on(const std::string& type,
|
||||
std::function<void(const Event&)> listener) {
|
||||
std::lock_guard<std::mutex> lock(mu_);
|
||||
if (!kEnabled) return;
|
||||
listeners_[type].push_back(std::move(listener));
|
||||
}
|
||||
|
||||
void EventBus::emit(const Event& event) {
|
||||
std::lock_guard<std::mutex> lock(mu_);
|
||||
if (!kEnabled) return;
|
||||
delivered_.push_back(event);
|
||||
auto it = listeners_.find(event.type);
|
||||
if (it == listeners_.end()) return;
|
||||
for (auto& l : it->second) l(event);
|
||||
}
|
||||
|
||||
std::vector<Event> EventBus::delivered() const {
|
||||
std::lock_guard<std::mutex> lock(mu_);
|
||||
return delivered_;
|
||||
}
|
||||
|
||||
void Metrics::inc(const std::string& name, double value) {
|
||||
std::lock_guard<std::mutex> lock(mu_);
|
||||
if (!kEnabled) return;
|
||||
if (!values_.count(name) && values_.size() >= caps::kMaxMetrics) return;
|
||||
values_[name] += value;
|
||||
}
|
||||
|
||||
void Metrics::gauge(const std::string& name, double value) {
|
||||
std::lock_guard<std::mutex> lock(mu_);
|
||||
if (!kEnabled) return;
|
||||
if (!values_.count(name) && values_.size() >= caps::kMaxMetrics) return;
|
||||
values_[name] = value;
|
||||
}
|
||||
|
||||
double Metrics::get(const std::string& name) const {
|
||||
std::lock_guard<std::mutex> lock(mu_);
|
||||
auto it = values_.find(name);
|
||||
return it == values_.end() ? 0.0 : it->second;
|
||||
}
|
||||
|
||||
std::string Metrics::render_prometheus() const {
|
||||
std::lock_guard<std::mutex> lock(mu_);
|
||||
std::string out;
|
||||
for (const auto& [k, v] : values_) {
|
||||
char buf[64];
|
||||
std::snprintf(buf, sizeof(buf), "%g", v);
|
||||
out += k + " " + buf + "\n";
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
Span Telemetry::start_span(const std::string& name,
|
||||
const std::string& correlation_id) {
|
||||
std::lock_guard<std::mutex> lock(mu_);
|
||||
Span s;
|
||||
if (!kEnabled) return s;
|
||||
s.trace_id = "trace-" + std::to_string(next_id_);
|
||||
s.span_id = "span-" + std::to_string(next_id_);
|
||||
++next_id_;
|
||||
s.name = name;
|
||||
s.correlation_id = correlation_id;
|
||||
return s;
|
||||
}
|
||||
|
||||
void Telemetry::finish_span(const Span& span) {
|
||||
std::lock_guard<std::mutex> lock(mu_);
|
||||
if (!kEnabled) return;
|
||||
if (spans_.size() >= caps::kMaxSpans) return;
|
||||
spans_.push_back(span);
|
||||
}
|
||||
|
||||
std::vector<Span> Telemetry::spans() const {
|
||||
std::lock_guard<std::mutex> lock(mu_);
|
||||
return spans_;
|
||||
}
|
||||
|
||||
} // namespace molo::example::fw
|
||||
@@ -0,0 +1,72 @@
|
||||
#pragma once
|
||||
|
||||
// Observability skeletons: typed event bus, metrics registry, and tracer.
|
||||
// All three compile to near-zero overhead when kEnabled is false.
|
||||
// Gap: real 02-observability.md owns the log format, Prometheus and OTel
|
||||
// export, and the zero-cost macro layer.
|
||||
|
||||
#include <functional>
|
||||
#include <map>
|
||||
#include <mutex>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
namespace molo::example::fw {
|
||||
|
||||
struct Event {
|
||||
std::string type;
|
||||
std::string payload;
|
||||
std::string correlation_id;
|
||||
};
|
||||
|
||||
class EventBus {
|
||||
public:
|
||||
static constexpr bool kEnabled = true;
|
||||
|
||||
void on(const std::string& type, std::function<void(const Event&)> listener);
|
||||
void emit(const Event& event);
|
||||
std::vector<Event> delivered() const;
|
||||
|
||||
private:
|
||||
mutable std::mutex mu_;
|
||||
std::map<std::string, std::vector<std::function<void(const Event&)>>>
|
||||
listeners_;
|
||||
std::vector<Event> delivered_;
|
||||
};
|
||||
|
||||
class Metrics {
|
||||
public:
|
||||
static constexpr bool kEnabled = true;
|
||||
|
||||
void inc(const std::string& name, double value = 1.0);
|
||||
void gauge(const std::string& name, double value);
|
||||
double get(const std::string& name) const;
|
||||
std::string render_prometheus() const;
|
||||
|
||||
private:
|
||||
mutable std::mutex mu_;
|
||||
std::map<std::string, double> values_;
|
||||
};
|
||||
|
||||
struct Span {
|
||||
std::string trace_id;
|
||||
std::string span_id;
|
||||
std::string name;
|
||||
std::string correlation_id;
|
||||
};
|
||||
|
||||
class Telemetry {
|
||||
public:
|
||||
static constexpr bool kEnabled = true;
|
||||
|
||||
Span start_span(const std::string& name, const std::string& correlation_id);
|
||||
void finish_span(const Span& span);
|
||||
std::vector<Span> spans() const;
|
||||
|
||||
private:
|
||||
mutable std::mutex mu_;
|
||||
std::vector<Span> spans_;
|
||||
unsigned long next_id_ = 1;
|
||||
};
|
||||
|
||||
} // namespace molo::example::fw
|
||||
@@ -0,0 +1,129 @@
|
||||
#include "Router.hpp"
|
||||
|
||||
#include <stdexcept>
|
||||
|
||||
#include "Caps.hpp"
|
||||
|
||||
namespace molo::example::fw {
|
||||
|
||||
namespace {
|
||||
|
||||
std::vector<std::string> split_path(std::string_view path) {
|
||||
std::vector<std::string> segs;
|
||||
std::string cur;
|
||||
for (char c : path) {
|
||||
if (c == '/') {
|
||||
if (!cur.empty()) {
|
||||
segs.push_back(cur);
|
||||
cur.clear();
|
||||
}
|
||||
} else {
|
||||
cur.push_back(c);
|
||||
}
|
||||
}
|
||||
if (!cur.empty()) segs.push_back(cur);
|
||||
return segs;
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
bool match_pattern(std::string_view pattern, std::string_view path,
|
||||
std::map<std::string, std::string>& params) {
|
||||
auto want = split_path(pattern);
|
||||
auto got = split_path(path);
|
||||
if (want.size() != got.size()) return false;
|
||||
for (std::size_t i = 0; i < want.size(); ++i) {
|
||||
const std::string& w = want[i];
|
||||
if (w.size() >= 2 && w.front() == '{' && w.back() == '}') {
|
||||
params[w.substr(1, w.size() - 2)] = url_decode(got[i]);
|
||||
} else if (w != got[i]) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
void Router::add(std::vector<Method> methods, std::string pattern,
|
||||
std::string name, Handler handler) {
|
||||
if (routes_.size() >= caps::kMaxRouteCount)
|
||||
throw std::length_error("route cap exceeded");
|
||||
for (const auto& r : routes_) {
|
||||
if (r.name == name)
|
||||
throw std::runtime_error("duplicate route name: " + name);
|
||||
}
|
||||
routes_.push_back(
|
||||
Route{std::move(methods), std::move(pattern), std::move(name),
|
||||
std::move(handler)});
|
||||
}
|
||||
|
||||
void Router::get(std::string pattern, std::string name, Handler handler) {
|
||||
add({Method::Get}, std::move(pattern), std::move(name), std::move(handler));
|
||||
}
|
||||
|
||||
void Router::post(std::string pattern, std::string name, Handler handler) {
|
||||
add({Method::Post}, std::move(pattern), std::move(name), std::move(handler));
|
||||
}
|
||||
|
||||
void Router::put(std::string pattern, std::string name, Handler handler) {
|
||||
add({Method::Put}, std::move(pattern), std::move(name), std::move(handler));
|
||||
}
|
||||
|
||||
void Router::patch(std::string pattern, std::string name, Handler handler) {
|
||||
add({Method::Patch}, std::move(pattern), std::move(name), std::move(handler));
|
||||
}
|
||||
|
||||
void Router::del(std::string pattern, std::string name, Handler handler) {
|
||||
add({Method::Delete}, std::move(pattern), std::move(name),
|
||||
std::move(handler));
|
||||
}
|
||||
|
||||
void Router::options(std::string pattern, std::string name, Handler handler) {
|
||||
add({Method::Options}, std::move(pattern), std::move(name),
|
||||
std::move(handler));
|
||||
}
|
||||
|
||||
void Router::any(std::vector<Method> methods, std::string pattern,
|
||||
std::string name, Handler handler) {
|
||||
add(std::move(methods), std::move(pattern), std::move(name),
|
||||
std::move(handler));
|
||||
}
|
||||
|
||||
void Router::include_router(const std::string& prefix, const Router& other,
|
||||
const std::string& name_prefix) {
|
||||
for (const auto& r : other.routes_) {
|
||||
std::string pattern = prefix;
|
||||
if (!r.pattern.empty() && r.pattern != "/") {
|
||||
if (pattern.empty() || pattern.back() != '/') pattern += "/";
|
||||
std::string sub = r.pattern;
|
||||
if (!sub.empty() && sub.front() == '/') sub.erase(sub.begin());
|
||||
pattern += sub;
|
||||
}
|
||||
if (pattern.empty()) pattern = "/";
|
||||
std::string name = name_prefix.empty() ? r.name : name_prefix + r.name;
|
||||
add(r.methods, pattern, name, r.handler);
|
||||
}
|
||||
}
|
||||
|
||||
bool Router::match(Request& req, Handler& out) const {
|
||||
for (const auto& r : routes_) {
|
||||
bool method_ok = false;
|
||||
for (Method m : r.methods) {
|
||||
if (m == req.method) {
|
||||
method_ok = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (!method_ok) continue;
|
||||
std::map<std::string, std::string> params;
|
||||
if (match_pattern(r.pattern, req.path, params)) {
|
||||
for (auto& [k, v] : params) req.path_params[k] = v;
|
||||
out = r.handler;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
const std::vector<Route>& Router::routes() const { return routes_; }
|
||||
|
||||
} // namespace molo::example::fw
|
||||
@@ -0,0 +1,52 @@
|
||||
#pragma once
|
||||
|
||||
// Router: FastAPI-style route groups mounted on the app with a shared prefix.
|
||||
// Gap: real molo::routing in 05-request-routing.md owns path compilation,
|
||||
// url_for and converters; this skeleton supports {param} segments only.
|
||||
|
||||
#include <functional>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
#include "Http.hpp"
|
||||
|
||||
namespace molo::example::fw {
|
||||
|
||||
using Handler = std::function<Response(Request)>;
|
||||
|
||||
struct Route {
|
||||
std::vector<Method> methods;
|
||||
std::string pattern;
|
||||
std::string name;
|
||||
Handler handler;
|
||||
};
|
||||
|
||||
class Router {
|
||||
public:
|
||||
void add(std::vector<Method> methods, std::string pattern, std::string name,
|
||||
Handler handler);
|
||||
|
||||
void get(std::string pattern, std::string name, Handler handler);
|
||||
void post(std::string pattern, std::string name, Handler handler);
|
||||
void put(std::string pattern, std::string name, Handler handler);
|
||||
void patch(std::string pattern, std::string name, Handler handler);
|
||||
void del(std::string pattern, std::string name, Handler handler);
|
||||
void options(std::string pattern, std::string name, Handler handler);
|
||||
void any(std::vector<Method> methods, std::string pattern, std::string name,
|
||||
Handler handler);
|
||||
|
||||
void include_router(const std::string& prefix, const Router& other,
|
||||
const std::string& name_prefix = "");
|
||||
|
||||
bool match(Request& req, Handler& out) const;
|
||||
|
||||
const std::vector<Route>& routes() const;
|
||||
|
||||
private:
|
||||
std::vector<Route> routes_;
|
||||
};
|
||||
|
||||
bool match_pattern(std::string_view pattern, std::string_view path,
|
||||
std::map<std::string, std::string>& params);
|
||||
|
||||
} // namespace molo::example::fw
|
||||
@@ -0,0 +1,205 @@
|
||||
#include "Server.hpp"
|
||||
|
||||
#include <arpa/inet.h>
|
||||
#include <netinet/in.h>
|
||||
#include <sys/socket.h>
|
||||
#include <unistd.h>
|
||||
|
||||
#include <cstring>
|
||||
#include <sstream>
|
||||
|
||||
#include "Caps.hpp"
|
||||
|
||||
namespace molo::example::fw {
|
||||
|
||||
Server::Server(std::shared_ptr<Application> app) : app_(std::move(app)) {
|
||||
stop();
|
||||
}
|
||||
|
||||
Server::~Server() { stop(); }
|
||||
|
||||
bool Server::start(const std::string& host, int port) {
|
||||
if (running_) return false;
|
||||
listen_fd_ = ::socket(AF_INET, SOCK_STREAM, 0);
|
||||
if (listen_fd_ < 0) return false;
|
||||
int one = 1;
|
||||
::setsockopt(listen_fd_, SOL_SOCKET, SO_REUSEADDR, &one, sizeof(one));
|
||||
sockaddr_in addr{};
|
||||
addr.sin_family = AF_INET;
|
||||
addr.sin_port = htons((uint16_t)port);
|
||||
if (::inet_pton(AF_INET, host.c_str(), &addr.sin_addr) != 1) {
|
||||
::close(listen_fd_);
|
||||
listen_fd_ = -1;
|
||||
return false;
|
||||
}
|
||||
if (::bind(listen_fd_, (sockaddr*)&addr, sizeof(addr)) != 0) {
|
||||
::close(listen_fd_);
|
||||
listen_fd_ = -1;
|
||||
return false;
|
||||
}
|
||||
if (port == 0) {
|
||||
sockaddr_in bound{};
|
||||
socklen_t len = sizeof(bound);
|
||||
if (::getsockname(listen_fd_, (sockaddr*)&bound, &len) == 0)
|
||||
port_ = ntohs(bound.sin_port);
|
||||
} else {
|
||||
port_ = port;
|
||||
}
|
||||
if (::listen(listen_fd_, caps::kServerBacklog) != 0) {
|
||||
::close(listen_fd_);
|
||||
listen_fd_ = -1;
|
||||
return false;
|
||||
}
|
||||
running_ = true;
|
||||
worker_ = std::thread([this]() { loop(); });
|
||||
return true;
|
||||
}
|
||||
|
||||
void Server::stop() {
|
||||
bool was = running_.exchange(false);
|
||||
if (listen_fd_ >= 0) {
|
||||
::shutdown(listen_fd_, SHUT_RDWR);
|
||||
::close(listen_fd_);
|
||||
listen_fd_ = -1;
|
||||
}
|
||||
if (was && worker_.joinable() &&
|
||||
worker_.get_id() != std::this_thread::get_id())
|
||||
worker_.join();
|
||||
else if (worker_.joinable() &&
|
||||
worker_.get_id() == std::this_thread::get_id())
|
||||
worker_.detach();
|
||||
}
|
||||
|
||||
int Server::port() const { return port_; }
|
||||
|
||||
bool Server::running() const { return running_; }
|
||||
|
||||
void Server::loop() {
|
||||
int served = 0;
|
||||
while (running_ && served < caps::kMaxConnections) {
|
||||
sockaddr_in peer{};
|
||||
socklen_t len = sizeof(peer);
|
||||
int fd = ::accept(listen_fd_, (sockaddr*)&peer, &len);
|
||||
if (fd < 0) {
|
||||
if (!running_) break;
|
||||
continue;
|
||||
}
|
||||
struct timeval tv{};
|
||||
tv.tv_sec = caps::kServerTimeoutSec;
|
||||
::setsockopt(fd, SOL_SOCKET, SO_RCVTIMEO, &tv, sizeof(tv));
|
||||
handle_one(fd);
|
||||
::close(fd);
|
||||
++served;
|
||||
}
|
||||
}
|
||||
|
||||
void Server::handle_one(int fd) {
|
||||
std::string raw;
|
||||
char buf[4096];
|
||||
while (raw.size() < caps::kMaxHeaderBytes) {
|
||||
ssize_t n = ::recv(fd, buf, sizeof(buf), 0);
|
||||
if (n <= 0) break;
|
||||
raw.append(buf, (std::size_t)n);
|
||||
if (raw.find("\r\n\r\n") != std::string::npos) break;
|
||||
}
|
||||
std::size_t hdr_end = raw.find("\r\n\r\n");
|
||||
if (hdr_end == std::string::npos) return;
|
||||
std::string head = raw.substr(0, hdr_end);
|
||||
std::string rest = raw.substr(hdr_end + 4);
|
||||
std::istringstream lines(head);
|
||||
std::string line;
|
||||
if (!std::getline(lines, line)) return;
|
||||
if (!line.empty() && line.back() == '\r') line.pop_back();
|
||||
std::istringstream rl(line);
|
||||
std::string method_s, target, version;
|
||||
rl >> method_s >> target >> version;
|
||||
Request req;
|
||||
req.method = parse_method(method_s);
|
||||
std::size_t q = target.find('?');
|
||||
if (q == std::string::npos) {
|
||||
req.path = target.empty() ? "/" : target;
|
||||
} else {
|
||||
req.path = target.substr(0, q);
|
||||
req.query = parse_query(target.substr(q + 1));
|
||||
}
|
||||
std::size_t header_count = 0;
|
||||
std::size_t content_length = 0;
|
||||
while (std::getline(lines, line)) {
|
||||
if (!line.empty() && line.back() == '\r') line.pop_back();
|
||||
if (line.empty()) continue;
|
||||
if (++header_count > caps::kMaxHeaderCount) break;
|
||||
std::size_t colon = line.find(':');
|
||||
if (colon == std::string::npos) continue;
|
||||
std::string name = line.substr(0, colon);
|
||||
std::string value = line.substr(colon + 1);
|
||||
while (!value.empty() && value.front() == ' ') value.erase(value.begin());
|
||||
req.headers.set(name, value);
|
||||
if (name == "Content-Length" || name == "content-length") {
|
||||
try {
|
||||
content_length = (std::size_t)std::stoul(value);
|
||||
} catch (...) {
|
||||
content_length = 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
std::string cookie_h = req.headers.get("Cookie");
|
||||
if (!cookie_h.empty()) req.cookies = parse_cookies(cookie_h);
|
||||
req.body = rest;
|
||||
while (req.body.size() < content_length &&
|
||||
req.body.size() < caps::kMaxRequestBody) {
|
||||
ssize_t n = ::recv(fd, buf, sizeof(buf), 0);
|
||||
if (n <= 0) break;
|
||||
req.body.append(buf, (std::size_t)n);
|
||||
}
|
||||
Response res = app_->dispatch(std::move(req));
|
||||
if (res.body.size() > caps::kMaxTestBody) {
|
||||
res.status = 500;
|
||||
res.body = "response over cap";
|
||||
}
|
||||
std::ostringstream out;
|
||||
out << "HTTP/1.1 " << res.status << " " << status_text(res.status) << "\r\n";
|
||||
out << "Content-Length: " << res.body.size() << "\r\n";
|
||||
out << "Connection: close\r\n";
|
||||
for (const auto& [k, v] : res.headers.entries()) {
|
||||
if (k == "Content-Length" || k == "content-length") continue;
|
||||
if (k == "Transfer-Encoding" || k == "transfer-encoding") continue;
|
||||
out << k << ": " << v << "\r\n";
|
||||
}
|
||||
out << "\r\n" << res.body;
|
||||
std::string wire = out.str();
|
||||
std::size_t sent = 0;
|
||||
while (sent < wire.size()) {
|
||||
ssize_t n = ::send(fd, wire.data() + sent, wire.size() - sent, 0);
|
||||
if (n <= 0) break;
|
||||
sent += (std::size_t)n;
|
||||
}
|
||||
}
|
||||
|
||||
std::string http_roundtrip(const std::string& host, int port,
|
||||
const std::string& raw_request) {
|
||||
int fd = ::socket(AF_INET, SOCK_STREAM, 0);
|
||||
if (fd < 0) return "";
|
||||
sockaddr_in addr{};
|
||||
addr.sin_family = AF_INET;
|
||||
addr.sin_port = htons((uint16_t)port);
|
||||
if (::inet_pton(AF_INET, host.c_str(), &addr.sin_addr) != 1) {
|
||||
::close(fd);
|
||||
return "";
|
||||
}
|
||||
if (::connect(fd, (sockaddr*)&addr, sizeof(addr)) != 0) {
|
||||
::close(fd);
|
||||
return "";
|
||||
}
|
||||
::send(fd, raw_request.data(), raw_request.size(), 0);
|
||||
std::string out;
|
||||
char buf[4096];
|
||||
while (out.size() < caps::kMaxTestBody) {
|
||||
ssize_t n = ::recv(fd, buf, sizeof(buf), 0);
|
||||
if (n <= 0) break;
|
||||
out.append(buf, (std::size_t)n);
|
||||
}
|
||||
::close(fd);
|
||||
return out;
|
||||
}
|
||||
|
||||
} // namespace molo::example::fw
|
||||
@@ -0,0 +1,44 @@
|
||||
#pragma once
|
||||
|
||||
// Minimal loopback HTTP/1.1 server for manual verification and the real-HTTP
|
||||
// round-trip test. Sequential accept loop, static caps, no TLS, no HTTP/2.
|
||||
// Gap: real event loop, timeouts, keep-alive, H2, and compression live in
|
||||
// 01-runtime.md; this server exists only to prove the stack end to end.
|
||||
|
||||
#include <atomic>
|
||||
#include <memory>
|
||||
#include <string>
|
||||
#include <thread>
|
||||
|
||||
#include "App.hpp"
|
||||
|
||||
namespace molo::example::fw {
|
||||
|
||||
class Server {
|
||||
public:
|
||||
explicit Server(std::shared_ptr<Application> app);
|
||||
~Server();
|
||||
|
||||
Server(const Server&) = delete;
|
||||
Server& operator=(const Server&) = delete;
|
||||
|
||||
bool start(const std::string& host, int port);
|
||||
void stop();
|
||||
int port() const;
|
||||
bool running() const;
|
||||
|
||||
private:
|
||||
void loop();
|
||||
void handle_one(int fd);
|
||||
|
||||
std::shared_ptr<Application> app_;
|
||||
int listen_fd_ = -1;
|
||||
int port_ = 0;
|
||||
std::atomic<bool> running_{false};
|
||||
std::thread worker_;
|
||||
};
|
||||
|
||||
std::string http_roundtrip(const std::string& host, int port,
|
||||
const std::string& raw_request);
|
||||
|
||||
} // namespace molo::example::fw
|
||||
@@ -0,0 +1,74 @@
|
||||
#include "StaticFiles.hpp"
|
||||
|
||||
#include <cstdio>
|
||||
#include <fstream>
|
||||
#include <sstream>
|
||||
|
||||
namespace molo::example::fw {
|
||||
|
||||
bool path_is_safe(const std::string& request_path) {
|
||||
if (request_path.find("..") != std::string::npos) return false;
|
||||
if (request_path.find('\\') != std::string::npos) return false;
|
||||
if (request_path.empty() || request_path.front() != '/') return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
namespace {
|
||||
|
||||
std::string content_type_for(const std::string& name) {
|
||||
auto ends = [&](const char* ext) {
|
||||
std::string e(ext);
|
||||
return name.size() >= e.size() &&
|
||||
name.compare(name.size() - e.size(), e.size(), e) == 0;
|
||||
};
|
||||
if (ends(".html")) return "text/html; charset=utf-8";
|
||||
if (ends(".css")) return "text/css; charset=utf-8";
|
||||
if (ends(".js")) return "text/javascript; charset=utf-8";
|
||||
if (ends(".json")) return "application/json";
|
||||
if (ends(".svg")) return "image/svg+xml";
|
||||
if (ends(".png")) return "image/png";
|
||||
return "application/octet-stream";
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
std::string etag_for(const std::string& body) {
|
||||
unsigned long long h = 1469598103934665603ULL;
|
||||
for (unsigned char c : body) {
|
||||
h ^= c;
|
||||
h *= 1099511628211ULL;
|
||||
}
|
||||
char buf[32];
|
||||
std::snprintf(buf, sizeof(buf), "\"%llx-%zu\"",
|
||||
(unsigned long long)h, body.size());
|
||||
return buf;
|
||||
}
|
||||
|
||||
Response serve_disk_file(const std::string& root,
|
||||
const std::string& subpath) {
|
||||
std::string full = "/" + subpath;
|
||||
if (!path_is_safe(full)) return TextResponse("unsafe path", 403);
|
||||
std::ifstream in(root + full, std::ios::binary);
|
||||
if (!in) return TextResponse("not found", 404);
|
||||
std::ostringstream ss;
|
||||
ss << in.rdbuf();
|
||||
Response res(200, ss.str(), content_type_for(full));
|
||||
res.headers.set("ETag", etag_for(res.body));
|
||||
res.headers.set("Cache-Control", "public, max-age=3600");
|
||||
return res;
|
||||
}
|
||||
|
||||
Response serve_embedded(const std::map<std::string, std::string>& assets,
|
||||
const std::string& subpath) {
|
||||
std::string full = "/" + subpath;
|
||||
if (!path_is_safe(full)) return TextResponse("unsafe path", 403);
|
||||
auto it = assets.find(full);
|
||||
if (it == assets.end()) return TextResponse("not found", 404);
|
||||
Response res(200, it->second, content_type_for(full));
|
||||
res.headers.set("ETag", etag_for(res.body));
|
||||
res.headers.set("Cache-Control", "public, max-age=3600");
|
||||
res.headers.set("X-Asset-Source", "embedded");
|
||||
return res;
|
||||
}
|
||||
|
||||
} // namespace molo::example::fw
|
||||
@@ -0,0 +1,24 @@
|
||||
#pragma once
|
||||
|
||||
// Static file serving from disk or from embedded C++ assets, with path
|
||||
// traversal protection, ETags, and cache-control headers.
|
||||
// Gap: real static serving in 01-runtime.md owns ranges, conditional
|
||||
// requests, and directory indexing; the embedder CLI lives in 09-cli-ops.md.
|
||||
|
||||
#include <map>
|
||||
#include <string>
|
||||
|
||||
#include "Http.hpp"
|
||||
|
||||
namespace molo::example::fw {
|
||||
|
||||
bool path_is_safe(const std::string& request_path);
|
||||
|
||||
Response serve_disk_file(const std::string& root, const std::string& subpath);
|
||||
|
||||
Response serve_embedded(const std::map<std::string, std::string>& assets,
|
||||
const std::string& subpath);
|
||||
|
||||
std::string etag_for(const std::string& body);
|
||||
|
||||
} // namespace molo::example::fw
|
||||
@@ -0,0 +1,246 @@
|
||||
#include "Templates.hpp"
|
||||
|
||||
#include <stdexcept>
|
||||
|
||||
#include "Caps.hpp"
|
||||
|
||||
namespace molo::example::fw {
|
||||
|
||||
std::string html_escape(std::string_view in) {
|
||||
std::string out;
|
||||
out.reserve(in.size());
|
||||
for (char c : in) {
|
||||
switch (c) {
|
||||
case '&': out += "&"; break;
|
||||
case '<': out += "<"; break;
|
||||
case '>': out += ">"; break;
|
||||
case '"': out += """; break;
|
||||
default: out.push_back(c); break;
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
namespace {
|
||||
|
||||
std::string trim(std::string s) {
|
||||
while (!s.empty() && (s.front() == ' ' || s.front() == '\t' ||
|
||||
s.front() == '\n' || s.front() == '\r'))
|
||||
s.erase(s.begin());
|
||||
while (!s.empty() && (s.back() == ' ' || s.back() == '\t' ||
|
||||
s.back() == '\n' || s.back() == '\r'))
|
||||
s.pop_back();
|
||||
return s;
|
||||
}
|
||||
|
||||
std::string apply_filter(std::string value, const std::string& filter) {
|
||||
if (filter == "upper") {
|
||||
for (char& c : value) c = (char)std::toupper((unsigned char)c);
|
||||
} else if (filter == "lower") {
|
||||
for (char& c : value) c = (char)std::tolower((unsigned char)c);
|
||||
} else if (filter == "escape") {
|
||||
value = html_escape(value);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
std::string ctx_string(const TemplateCtx& ctx, const std::string& key) {
|
||||
auto it = ctx.find(key);
|
||||
if (it == ctx.end()) return "";
|
||||
if (auto* s = std::get_if<std::string>(&it->second)) return *s;
|
||||
return "";
|
||||
}
|
||||
|
||||
bool ctx_truthy(const TemplateCtx& ctx, const std::string& key) {
|
||||
auto it = ctx.find(key);
|
||||
if (it == ctx.end()) return false;
|
||||
if (auto* s = std::get_if<std::string>(&it->second))
|
||||
return !s->empty() && *s != "false" && *s != "0";
|
||||
if (auto* l = std::get_if<TemplateList>(&it->second)) return !l->empty();
|
||||
return false;
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
void Environment::add_template(std::string name, std::string source) {
|
||||
templates_[std::move(name)] = std::move(source);
|
||||
}
|
||||
|
||||
void Environment::add_global(std::string key, std::string value) {
|
||||
globals_[std::move(key)] = std::move(value);
|
||||
}
|
||||
|
||||
bool Environment::has_template(const std::string& name) const {
|
||||
return templates_.count(name) > 0;
|
||||
}
|
||||
|
||||
std::string Environment::render(const std::string& name, TemplateCtx ctx) const {
|
||||
auto it = templates_.find(name);
|
||||
if (it == templates_.end()) throw std::runtime_error("template not found");
|
||||
for (const auto& [k, v] : globals_) {
|
||||
if (!ctx.count(k)) ctx[k] = v;
|
||||
}
|
||||
return render_source(name, it->second, ctx, 0);
|
||||
}
|
||||
|
||||
std::string Environment::render_source(const std::string& name,
|
||||
const std::string& src, TemplateCtx& ctx,
|
||||
int depth) const {
|
||||
(void)name;
|
||||
if (depth > caps::kMaxTemplateDepth)
|
||||
throw std::runtime_error("template depth exceeded");
|
||||
std::string extends;
|
||||
std::size_t pos = src.find("{% extends");
|
||||
if (pos != std::string::npos) {
|
||||
std::size_t q1 = src.find('"', pos);
|
||||
std::size_t q2 = q1 == std::string::npos
|
||||
? std::string::npos
|
||||
: src.find('"', q1 + 1);
|
||||
if (q1 != std::string::npos && q2 != std::string::npos)
|
||||
extends = src.substr(q1 + 1, q2 - q1 - 1);
|
||||
}
|
||||
if (!extends.empty()) {
|
||||
auto it = templates_.find(extends);
|
||||
if (it == templates_.end()) throw std::runtime_error("base not found");
|
||||
std::map<std::string, std::string> blocks;
|
||||
std::size_t b = 0;
|
||||
while (true) {
|
||||
std::size_t bs = src.find("{% block", b);
|
||||
if (bs == std::string::npos) break;
|
||||
std::size_t be = src.find("%}", bs);
|
||||
std::size_t cs = src.find("{% endblock %}", be);
|
||||
if (be == std::string::npos || cs == std::string::npos) break;
|
||||
std::string head = trim(src.substr(bs + 9, be - bs - 9));
|
||||
blocks[head] = src.substr(be + 2, cs - be - 2);
|
||||
b = cs + 14;
|
||||
}
|
||||
std::string base = it->second;
|
||||
for (const auto& [k, v] : blocks) {
|
||||
std::string open = "{% block " + k + " %}";
|
||||
std::size_t s = base.find(open);
|
||||
std::size_t e = s == std::string::npos
|
||||
? std::string::npos
|
||||
: base.find("{% endblock %}", s);
|
||||
if (s != std::string::npos && e != std::string::npos)
|
||||
base = base.substr(0, s) + v + base.substr(e + 14);
|
||||
}
|
||||
return render_blocks(base, ctx, depth);
|
||||
}
|
||||
return render_blocks(src, ctx, depth);
|
||||
}
|
||||
|
||||
std::string Environment::render_blocks(const std::string& src, TemplateCtx& ctx,
|
||||
int depth) const {
|
||||
std::string out;
|
||||
std::size_t i = 0;
|
||||
while (i < src.size()) {
|
||||
std::size_t v = src.find("{{", i);
|
||||
std::size_t t = src.find("{%", i);
|
||||
if (v == std::string::npos && t == std::string::npos) {
|
||||
out += src.substr(i);
|
||||
break;
|
||||
}
|
||||
if (v != std::string::npos && (t == std::string::npos || v < t)) {
|
||||
out += src.substr(i, v - i);
|
||||
std::size_t e = src.find("}}", v);
|
||||
if (e == std::string::npos) break;
|
||||
std::string expr = trim(src.substr(v + 2, e - v - 2));
|
||||
bool safe = false;
|
||||
std::string var = expr;
|
||||
std::vector<std::string> filters;
|
||||
std::size_t p = expr.find('|');
|
||||
if (p != std::string::npos) {
|
||||
var = trim(expr.substr(0, p));
|
||||
std::string rest = expr.substr(p + 1);
|
||||
std::size_t s = 0;
|
||||
while (s < rest.size()) {
|
||||
std::size_t q = rest.find('|', s);
|
||||
std::string f = trim(rest.substr(s, q == std::string::npos
|
||||
? q
|
||||
: q - s));
|
||||
if (f == "safe") {
|
||||
safe = true;
|
||||
} else if (!f.empty()) {
|
||||
filters.push_back(f);
|
||||
}
|
||||
if (q == std::string::npos) break;
|
||||
s = q + 1;
|
||||
}
|
||||
}
|
||||
std::string value;
|
||||
std::size_t dot = var.find('.');
|
||||
if (dot != std::string::npos) {
|
||||
value = ctx_string(ctx, var);
|
||||
} else {
|
||||
value = ctx_string(ctx, var);
|
||||
}
|
||||
for (const auto& f : filters) value = apply_filter(value, f);
|
||||
if (!safe) value = html_escape(value);
|
||||
out += value;
|
||||
i = e + 2;
|
||||
} else {
|
||||
out += src.substr(i, t - i);
|
||||
std::size_t e = src.find("%}", t);
|
||||
if (e == std::string::npos) break;
|
||||
std::string tag = trim(src.substr(t + 2, e - t - 2));
|
||||
if (tag.rfind("if ", 0) == 0) {
|
||||
std::string cond = trim(tag.substr(3));
|
||||
std::size_t end = src.find("{% endif %}", e);
|
||||
if (end == std::string::npos) break;
|
||||
std::string inner = src.substr(e + 2, end - e - 2);
|
||||
std::size_t els = inner.find("{% else %}");
|
||||
std::string yes = els == std::string::npos ? inner
|
||||
: inner.substr(0, els);
|
||||
std::string no =
|
||||
els == std::string::npos ? "" : inner.substr(els + 10);
|
||||
out += render_blocks(ctx_truthy(ctx, cond) ? yes : no, ctx, depth + 1);
|
||||
i = end + 11;
|
||||
} else if (tag.rfind("for ", 0) == 0) {
|
||||
std::string rest = trim(tag.substr(4));
|
||||
std::size_t in = rest.find(" in ");
|
||||
std::string item = in == std::string::npos
|
||||
? ""
|
||||
: trim(rest.substr(0, in));
|
||||
std::string list = in == std::string::npos
|
||||
? ""
|
||||
: trim(rest.substr(in + 4));
|
||||
std::size_t end = src.find("{% endfor %}", e);
|
||||
if (end == std::string::npos) break;
|
||||
std::string inner = src.substr(e + 2, end - e - 2);
|
||||
auto it = ctx.find(list);
|
||||
if (it != ctx.end()) {
|
||||
if (auto* l = std::get_if<TemplateList>(&it->second)) {
|
||||
for (const auto& row : *l) {
|
||||
TemplateCtx sub = ctx;
|
||||
for (const auto& [k, val] : row) {
|
||||
sub[item + "." + k] = val;
|
||||
sub[k] = val;
|
||||
}
|
||||
out += render_blocks(inner, sub, depth + 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
i = end + 12;
|
||||
} else if (tag.rfind("include ", 0) == 0) {
|
||||
std::string rest = trim(tag.substr(8));
|
||||
if (rest.size() >= 2 && rest.front() == '"') {
|
||||
std::string inc = rest.substr(1, rest.size() - 2);
|
||||
auto it = templates_.find(inc);
|
||||
if (it != templates_.end())
|
||||
out += render_blocks(it->second, ctx, depth + 1);
|
||||
}
|
||||
i = e + 2;
|
||||
} else if (tag.rfind("block ", 0) == 0) {
|
||||
std::size_t end = src.find("{% endblock %}", e);
|
||||
if (end == std::string::npos) break;
|
||||
out += render_blocks(src.substr(e + 2, end - e - 2), ctx, depth + 1);
|
||||
i = end + 14;
|
||||
} else {
|
||||
i = e + 2;
|
||||
}
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
} // namespace molo::example::fw
|
||||
@@ -0,0 +1,39 @@
|
||||
#pragma once
|
||||
|
||||
// Jinja-like template environment: extends plus blocks, includes, if, for,
|
||||
// filters, autoescape for .html/.htm/.xml with explicit safe opt-out.
|
||||
// Gap: real molo::templates in 05-request-routing.md owns the full lexer,
|
||||
// parser, sandbox, and streamed rendering.
|
||||
|
||||
#include <map>
|
||||
#include <string>
|
||||
#include <variant>
|
||||
#include <vector>
|
||||
|
||||
namespace molo::example::fw {
|
||||
|
||||
using TemplateList = std::vector<std::map<std::string, std::string>>;
|
||||
using TemplateValue = std::variant<std::string, TemplateList>;
|
||||
using TemplateCtx = std::map<std::string, TemplateValue>;
|
||||
|
||||
class Environment {
|
||||
public:
|
||||
void add_template(std::string name, std::string source);
|
||||
void add_global(std::string key, std::string value);
|
||||
bool has_template(const std::string& name) const;
|
||||
|
||||
std::string render(const std::string& name, TemplateCtx ctx) const;
|
||||
|
||||
private:
|
||||
std::string render_source(const std::string& name, const std::string& src,
|
||||
TemplateCtx& ctx, int depth) const;
|
||||
std::string render_blocks(const std::string& src, TemplateCtx& ctx,
|
||||
int depth) const;
|
||||
|
||||
std::map<std::string, std::string> templates_;
|
||||
std::map<std::string, std::string> globals_;
|
||||
};
|
||||
|
||||
std::string html_escape(std::string_view in);
|
||||
|
||||
} // namespace molo::example::fw
|
||||
@@ -0,0 +1,65 @@
|
||||
#include "TestClient.hpp"
|
||||
|
||||
#include "Caps.hpp"
|
||||
#include "Http.hpp"
|
||||
|
||||
namespace molo::example::fw {
|
||||
|
||||
TestClient::TestClient(std::shared_ptr<Application> app)
|
||||
: app_(std::move(app)) {}
|
||||
|
||||
Response TestClient::get(const std::string& path) {
|
||||
return request(Method::Get, path);
|
||||
}
|
||||
|
||||
Response TestClient::post(const std::string& path, const std::string& body,
|
||||
const std::string& content_type) {
|
||||
return request(Method::Post, path, body, {{"Content-Type", content_type}});
|
||||
}
|
||||
|
||||
Response TestClient::request(
|
||||
Method method, const std::string& path, const std::string& body,
|
||||
const std::map<std::string, std::string>& headers) {
|
||||
if (body.size() > caps::kMaxTestBody)
|
||||
return TextResponse("test body over cap", 413);
|
||||
Request req;
|
||||
req.method = method;
|
||||
std::size_t q = path.find('?');
|
||||
if (q == std::string::npos) {
|
||||
req.path = path;
|
||||
} else {
|
||||
req.path = path.substr(0, q);
|
||||
req.query = parse_query(path.substr(q + 1));
|
||||
}
|
||||
req.headers.set("User-Agent", "living-example-test/1.0");
|
||||
for (const auto& [k, v] : headers) req.headers.set(k, v);
|
||||
if (!jar_.empty()) {
|
||||
std::string cookie;
|
||||
for (const auto& [k, v] : jar_) {
|
||||
if (!cookie.empty()) cookie += "; ";
|
||||
cookie += k + "=" + v;
|
||||
}
|
||||
req.cookies = jar_;
|
||||
req.headers.set("Cookie", cookie);
|
||||
}
|
||||
if (!frozen_time_.empty()) req.headers.set("X-Test-Time", frozen_time_);
|
||||
req.body = body;
|
||||
Response res = app_->dispatch(std::move(req));
|
||||
std::string set_cookie = res.headers.get("Set-Cookie");
|
||||
if (!set_cookie.empty()) {
|
||||
std::size_t semi = set_cookie.find(';');
|
||||
std::string pair = set_cookie.substr(0, semi);
|
||||
std::size_t eq = pair.find('=');
|
||||
if (eq != std::string::npos)
|
||||
jar_[pair.substr(0, eq)] = pair.substr(eq + 1);
|
||||
}
|
||||
return res;
|
||||
}
|
||||
|
||||
void TestClient::freeze_time(const std::string& iso) { frozen_time_ = iso; }
|
||||
|
||||
const std::string& TestClient::frozen_time() const { return frozen_time_; }
|
||||
|
||||
void TestClient::clear_cookies() { jar_.clear(); }
|
||||
|
||||
} // namespace molo::example::fw
|
||||
@@ -0,0 +1,38 @@
|
||||
#pragma once
|
||||
|
||||
// Test client: drives the real Application (routing, middleware, views, DI,
|
||||
// error mapping, in-memory DB) without sockets, plus a cookie jar and frozen
|
||||
// time hook. Gap: real test client in 08-clients-openapi.md owns fixtures,
|
||||
// DB resets, and time freezing.
|
||||
|
||||
#include <map>
|
||||
#include <memory>
|
||||
#include <string>
|
||||
|
||||
#include "App.hpp"
|
||||
|
||||
namespace molo::example::fw {
|
||||
|
||||
class TestClient {
|
||||
public:
|
||||
explicit TestClient(std::shared_ptr<Application> app);
|
||||
|
||||
Response get(const std::string& path);
|
||||
Response post(const std::string& path, const std::string& body = "",
|
||||
const std::string& content_type = "text/plain");
|
||||
Response request(Method method, const std::string& path,
|
||||
const std::string& body = "",
|
||||
const std::map<std::string, std::string>& headers =
|
||||
{});
|
||||
|
||||
void freeze_time(const std::string& iso);
|
||||
const std::string& frozen_time() const;
|
||||
void clear_cookies();
|
||||
|
||||
private:
|
||||
std::shared_ptr<Application> app_;
|
||||
std::map<std::string, std::string> jar_;
|
||||
std::string frozen_time_;
|
||||
};
|
||||
|
||||
} // namespace molo::example::fw
|
||||
@@ -0,0 +1,9 @@
|
||||
#include "Tls.hpp"
|
||||
|
||||
namespace molo::example::fw {
|
||||
|
||||
std::string StubTlsContext::name() const { return "tls-stub-no-wolfssl"; }
|
||||
|
||||
bool StubTlsContext::available() const { return false; }
|
||||
|
||||
} // namespace molo::example::fw
|
||||
@@ -0,0 +1,25 @@
|
||||
#pragma once
|
||||
|
||||
// TLS posture: framework code depends only on ITlsContext. wolfSSL is NOT
|
||||
// installed in this environment, so only StubTlsContext exists. It refuses
|
||||
// handshakes loudly instead of silently serving plaintext as TLS.
|
||||
// Gap: real wolfSSL-backed context from 03-security.md (TLS 1.3).
|
||||
|
||||
#include <string>
|
||||
|
||||
namespace molo::example::fw {
|
||||
|
||||
class ITlsContext {
|
||||
public:
|
||||
virtual ~ITlsContext() = default;
|
||||
virtual std::string name() const = 0;
|
||||
virtual bool available() const = 0;
|
||||
};
|
||||
|
||||
class StubTlsContext : public ITlsContext {
|
||||
public:
|
||||
std::string name() const override;
|
||||
bool available() const override;
|
||||
};
|
||||
|
||||
} // namespace molo::example::fw
|
||||
@@ -0,0 +1,189 @@
|
||||
#include "Validate.hpp"
|
||||
|
||||
#include <cctype>
|
||||
|
||||
#include "Caps.hpp"
|
||||
#include "Http.hpp"
|
||||
|
||||
namespace molo::example::fw {
|
||||
|
||||
ValidationError::ValidationError(std::vector<FieldError> failures)
|
||||
: HttpError(422, "validation_error", "validation failed"),
|
||||
failures_(std::move(failures)) {}
|
||||
|
||||
const std::vector<FieldError>& ValidationError::failures() const {
|
||||
return failures_;
|
||||
}
|
||||
|
||||
std::string ValidationError::to_json() const {
|
||||
std::string out = "{\"error\":\"validation_error\",\"failures\":[";
|
||||
bool first = true;
|
||||
for (const auto& f : failures_) {
|
||||
if (!first) out += ",";
|
||||
first = false;
|
||||
out += "{\"field\":\"" + json_escape(f.field) + "\",\"message\":\"" +
|
||||
json_escape(f.message) + "\"}";
|
||||
}
|
||||
return out + "]}";
|
||||
}
|
||||
|
||||
Model::Model(std::vector<Field> fields) : fields_(std::move(fields)) {}
|
||||
|
||||
namespace {
|
||||
|
||||
bool looks_like_email(const std::string& v) {
|
||||
auto at = v.find('@');
|
||||
if (at == std::string::npos || at == 0) return false;
|
||||
return v.find('.', at) != std::string::npos;
|
||||
}
|
||||
|
||||
bool parse_long(const std::string& v, long& out) {
|
||||
if (v.empty()) return false;
|
||||
std::size_t i = 0;
|
||||
bool neg = false;
|
||||
if (v[0] == '-' || v[0] == '+') {
|
||||
neg = v[0] == '-';
|
||||
i = 1;
|
||||
}
|
||||
if (i >= v.size()) return false;
|
||||
long acc = 0;
|
||||
for (; i < v.size(); ++i) {
|
||||
if (!std::isdigit((unsigned char)v[i])) return false;
|
||||
acc = acc * 10 + (v[i] - '0');
|
||||
}
|
||||
out = neg ? -acc : acc;
|
||||
return true;
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
std::map<std::string, std::string> Model::validate(
|
||||
const std::map<std::string, std::string>& input) const {
|
||||
std::vector<FieldError> failures;
|
||||
std::map<std::string, std::string> clean;
|
||||
for (const auto& f : fields_) {
|
||||
auto it = input.find(f.name);
|
||||
std::string value = it == input.end() ? "" : it->second;
|
||||
bool missing = it == input.end() || value.empty();
|
||||
if (missing) {
|
||||
if (!f.required) {
|
||||
if (!f.default_value.empty()) clean[f.name] = f.default_value;
|
||||
continue;
|
||||
}
|
||||
failures.push_back({f.name, "field is required"});
|
||||
continue;
|
||||
}
|
||||
if (f.type != FieldType::Integer && value.size() < f.min_len)
|
||||
failures.push_back({f.name, "too short"});
|
||||
else if (f.type != FieldType::Integer && value.size() > f.max_len)
|
||||
failures.push_back({f.name, "too long"});
|
||||
else if (f.type == FieldType::Integer) {
|
||||
long n = 0;
|
||||
if (!parse_long(value, n)) {
|
||||
failures.push_back({f.name, "not an integer"});
|
||||
} else if (f.has_range && (n < f.min_value || n > f.max_value)) {
|
||||
failures.push_back({f.name, "out of range"});
|
||||
} else {
|
||||
clean[f.name] = value;
|
||||
}
|
||||
continue;
|
||||
} else if (f.type == FieldType::Email && !looks_like_email(value)) {
|
||||
failures.push_back({f.name, "not an email address"});
|
||||
continue;
|
||||
} else if (f.type == FieldType::Boolean &&
|
||||
value != "true" && value != "false" && value != "1" &&
|
||||
value != "0") {
|
||||
failures.push_back({f.name, "not a boolean"});
|
||||
continue;
|
||||
} else {
|
||||
clean[f.name] = value;
|
||||
}
|
||||
}
|
||||
if (!failures.empty()) throw ValidationError(std::move(failures));
|
||||
return clean;
|
||||
}
|
||||
|
||||
const std::vector<Field>& Model::fields() const { return fields_; }
|
||||
|
||||
std::map<std::string, std::string> parse_json_object_flat(
|
||||
const std::string& body) {
|
||||
std::map<std::string, std::string> out;
|
||||
std::size_t i = 0;
|
||||
int depth = 0;
|
||||
auto skip_ws = [&]() {
|
||||
while (i < body.size() &&
|
||||
std::isspace((unsigned char)body[i]))
|
||||
++i;
|
||||
};
|
||||
skip_ws();
|
||||
if (i >= body.size() || body[i] != '{') return out;
|
||||
++i;
|
||||
++depth;
|
||||
std::size_t fields = 0;
|
||||
while (i < body.size() && fields < caps::kMaxFormFields) {
|
||||
skip_ws();
|
||||
if (i < body.size() && body[i] == '}') break;
|
||||
if (i >= body.size() || body[i] != '"') break;
|
||||
++i;
|
||||
std::string key;
|
||||
while (i < body.size() && body[i] != '"') {
|
||||
if (body[i] == '\\' && i + 1 < body.size()) {
|
||||
key.push_back(body[i + 1]);
|
||||
i += 2;
|
||||
} else {
|
||||
key.push_back(body[i++]);
|
||||
}
|
||||
}
|
||||
if (i < body.size()) ++i;
|
||||
skip_ws();
|
||||
if (i >= body.size() || body[i] != ':') break;
|
||||
++i;
|
||||
skip_ws();
|
||||
std::string value;
|
||||
if (i < body.size() && body[i] == '"') {
|
||||
++i;
|
||||
while (i < body.size() && body[i] != '"') {
|
||||
if (body[i] == '\\' && i + 1 < body.size()) {
|
||||
value.push_back(body[i + 1]);
|
||||
i += 2;
|
||||
} else {
|
||||
value.push_back(body[i++]);
|
||||
}
|
||||
}
|
||||
if (i < body.size()) ++i;
|
||||
} else {
|
||||
while (i < body.size() && body[i] != ',' && body[i] != '}') {
|
||||
if (body[i] == '{' || body[i] == '[') {
|
||||
if (++depth > caps::kMaxJsonDepth) return out;
|
||||
}
|
||||
value.push_back(body[i++]);
|
||||
}
|
||||
while (!value.empty() &&
|
||||
std::isspace((unsigned char)value.back()))
|
||||
value.pop_back();
|
||||
}
|
||||
out[key] = value;
|
||||
++fields;
|
||||
skip_ws();
|
||||
if (i < body.size() && body[i] == ',') ++i;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
Form::Form(Model model) : model_(std::move(model)) {}
|
||||
|
||||
std::map<std::string, std::string> Form::bind(const Request& req) const {
|
||||
std::string ct = req.headers.get("Content-Type");
|
||||
std::map<std::string, std::string> input;
|
||||
if (ct.find("application/json") != std::string::npos) {
|
||||
input = parse_json_object_flat(req.body);
|
||||
} else {
|
||||
input = parse_form(req.body);
|
||||
for (const auto& [k, v] : req.query) {
|
||||
if (!input.count(k)) input[k] = v;
|
||||
}
|
||||
}
|
||||
return model_.validate(input);
|
||||
}
|
||||
|
||||
} // namespace molo::example::fw
|
||||
@@ -0,0 +1,72 @@
|
||||
#pragma once
|
||||
|
||||
// Single validation framework: field descriptors plus whole-model validation
|
||||
// that reports ALL failures in one error. Forms bind JSON and urlencoded
|
||||
// bodies into the same models. Gap: real molo::validate and molo::forms in
|
||||
// 05-request-routing.md own coercions, nested models, and OpenAPI schemas.
|
||||
|
||||
#include <map>
|
||||
#include <optional>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
#include "App.hpp"
|
||||
|
||||
namespace molo::example::fw {
|
||||
|
||||
enum class FieldType { String, Integer, Boolean, Email };
|
||||
|
||||
struct Field {
|
||||
std::string name;
|
||||
FieldType type = FieldType::String;
|
||||
bool required = true;
|
||||
std::size_t min_len = 0;
|
||||
std::size_t max_len = 1024;
|
||||
long min_value = 0;
|
||||
long max_value = 0;
|
||||
bool has_range = false;
|
||||
std::string default_value;
|
||||
};
|
||||
|
||||
struct FieldError {
|
||||
std::string field;
|
||||
std::string message;
|
||||
};
|
||||
|
||||
class ValidationError : public HttpError {
|
||||
public:
|
||||
explicit ValidationError(std::vector<FieldError> failures);
|
||||
const std::vector<FieldError>& failures() const;
|
||||
std::string to_json() const;
|
||||
|
||||
private:
|
||||
std::vector<FieldError> failures_;
|
||||
};
|
||||
|
||||
class Model {
|
||||
public:
|
||||
explicit Model(std::vector<Field> fields);
|
||||
|
||||
std::map<std::string, std::string> validate(
|
||||
const std::map<std::string, std::string>& input) const;
|
||||
|
||||
const std::vector<Field>& fields() const;
|
||||
|
||||
private:
|
||||
std::vector<Field> fields_;
|
||||
};
|
||||
|
||||
std::map<std::string, std::string> parse_json_object_flat(
|
||||
const std::string& body);
|
||||
|
||||
class Form {
|
||||
public:
|
||||
explicit Form(Model model);
|
||||
|
||||
std::map<std::string, std::string> bind(const Request& req) const;
|
||||
|
||||
private:
|
||||
Model model_;
|
||||
};
|
||||
|
||||
} // namespace molo::example::fw
|
||||
@@ -0,0 +1,26 @@
|
||||
#include "View.hpp"
|
||||
|
||||
namespace molo::example::fw {
|
||||
|
||||
View::View(Request req) : req_(std::move(req)) {}
|
||||
|
||||
Response View::dispatch() {
|
||||
switch (req_.method) {
|
||||
case Method::Get: return get();
|
||||
case Method::Post: return post();
|
||||
case Method::Put: return put();
|
||||
case Method::Patch: return patch();
|
||||
case Method::Delete: return del();
|
||||
case Method::Options: return options();
|
||||
default: return TextResponse("method not allowed", 405);
|
||||
}
|
||||
}
|
||||
|
||||
Response View::get() { return TextResponse("method not allowed", 405); }
|
||||
Response View::post() { return TextResponse("method not allowed", 405); }
|
||||
Response View::put() { return TextResponse("method not allowed", 405); }
|
||||
Response View::patch() { return TextResponse("method not allowed", 405); }
|
||||
Response View::del() { return TextResponse("method not allowed", 405); }
|
||||
Response View::options() { return TextResponse("method not allowed", 405); }
|
||||
|
||||
} // namespace molo::example::fw
|
||||
@@ -0,0 +1,29 @@
|
||||
#pragma once
|
||||
|
||||
// Class-based views, aiohttp style: constructed with the Request, one virtual
|
||||
// coroutine-equivalent per HTTP method, dispatch selects by method.
|
||||
// Gap: real molo::views::View in 05-request-routing.md is coroutine based.
|
||||
|
||||
#include "Http.hpp"
|
||||
|
||||
namespace molo::example::fw {
|
||||
|
||||
class View {
|
||||
public:
|
||||
explicit View(Request req);
|
||||
virtual ~View() = default;
|
||||
|
||||
Response dispatch();
|
||||
|
||||
virtual Response get();
|
||||
virtual Response post();
|
||||
virtual Response put();
|
||||
virtual Response patch();
|
||||
virtual Response del();
|
||||
virtual Response options();
|
||||
|
||||
protected:
|
||||
Request req_;
|
||||
};
|
||||
|
||||
} // namespace molo::example::fw
|
||||
@@ -0,0 +1,143 @@
|
||||
#include "Workflow.hpp"
|
||||
|
||||
#include <cstdio>
|
||||
|
||||
#include "Caps.hpp"
|
||||
|
||||
namespace molo::example::fw {
|
||||
|
||||
bool parse_commit(const std::string& subject, const std::string& body,
|
||||
CommitInfo& out, std::string& error) {
|
||||
std::string s = subject;
|
||||
if (s.size() > (std::size_t)caps::kMaxCommitSubject) {
|
||||
error = "subject too long";
|
||||
return false;
|
||||
}
|
||||
std::size_t colon = s.find(':');
|
||||
if (colon == std::string::npos) {
|
||||
error = "missing colon";
|
||||
return false;
|
||||
}
|
||||
std::string head = s.substr(0, colon);
|
||||
out.description = s.substr(colon + 1);
|
||||
while (!out.description.empty() && out.description.front() == ' ')
|
||||
out.description.erase(out.description.begin());
|
||||
if (!head.empty() && head.back() == '!') {
|
||||
out.breaking = true;
|
||||
head.pop_back();
|
||||
}
|
||||
std::size_t paren = head.find('(');
|
||||
if (paren == std::string::npos) {
|
||||
out.type = head;
|
||||
} else {
|
||||
out.type = head.substr(0, paren);
|
||||
std::size_t close = head.find(')', paren);
|
||||
if (close == std::string::npos) {
|
||||
error = "unclosed scope";
|
||||
return false;
|
||||
}
|
||||
out.scope = head.substr(paren + 1, close - paren - 1);
|
||||
}
|
||||
const char* allowed[] = {"feat", "fix", "docs", "style", "refactor",
|
||||
"perf", "test", "build", "ci", "chore"};
|
||||
bool ok = false;
|
||||
for (const char* t : allowed) {
|
||||
if (out.type == t) {
|
||||
ok = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (!ok) {
|
||||
error = "unknown type: " + out.type;
|
||||
return false;
|
||||
}
|
||||
if (body.find("BREAKING CHANGE") != std::string::npos) out.breaking = true;
|
||||
return true;
|
||||
}
|
||||
|
||||
std::string bump_name(Bump bump) {
|
||||
switch (bump) {
|
||||
case Bump::None: return "none";
|
||||
case Bump::Patch: return "patch";
|
||||
case Bump::Minor: return "minor";
|
||||
case Bump::Major: return "major";
|
||||
}
|
||||
return "none";
|
||||
}
|
||||
|
||||
Bump ReleasePlanner::decide(const std::vector<CommitInfo>& commits) const {
|
||||
bool feat = false, fix = false;
|
||||
for (const auto& c : commits) {
|
||||
if (c.breaking) return Bump::Major;
|
||||
if (c.type == "feat") feat = true;
|
||||
if (c.type == "fix") fix = true;
|
||||
}
|
||||
if (feat) return Bump::Minor;
|
||||
if (fix) return Bump::Patch;
|
||||
return Bump::None;
|
||||
}
|
||||
|
||||
std::string ReleasePlanner::next_version(
|
||||
const std::string& current,
|
||||
const std::vector<CommitInfo>& commits) const {
|
||||
int major = 0, minor = 0, patch = 0;
|
||||
std::sscanf(current.c_str(), "%d.%d.%d", &major, &minor, &patch);
|
||||
switch (decide(commits)) {
|
||||
case Bump::Major: return std::to_string(major + 1) + ".0.0";
|
||||
case Bump::Minor:
|
||||
return std::to_string(major) + "." + std::to_string(minor + 1) + ".0";
|
||||
case Bump::Patch:
|
||||
return std::to_string(major) + "." + std::to_string(minor) + "." +
|
||||
std::to_string(patch + 1);
|
||||
case Bump::None: return current;
|
||||
}
|
||||
return current;
|
||||
}
|
||||
|
||||
std::string ChangelogWriter::entry(
|
||||
const std::string& version, const std::string& date,
|
||||
const std::vector<CommitInfo>& commits) const {
|
||||
std::string added, changed, fixed, removed;
|
||||
for (const auto& c : commits) {
|
||||
std::string line =
|
||||
"- " + (c.scope.empty() ? "" : "(" + c.scope + ") ") + c.description +
|
||||
"\n";
|
||||
if (c.breaking)
|
||||
changed += line;
|
||||
else if (c.type == "feat")
|
||||
added += line;
|
||||
else if (c.type == "fix")
|
||||
fixed += line;
|
||||
else
|
||||
changed += line;
|
||||
}
|
||||
std::string out = "## [" + version + "] - " + date + "\n";
|
||||
if (!added.empty()) out += "### Added\n" + added;
|
||||
if (!changed.empty()) out += "### Changed\n" + changed;
|
||||
if (!fixed.empty()) out += "### Fixed\n" + fixed;
|
||||
if (!removed.empty()) out += "### Removed\n" + removed;
|
||||
int lines = 0;
|
||||
for (char c : out) {
|
||||
if (c == '\n') ++lines;
|
||||
}
|
||||
if (lines > caps::kMaxChangelogLines)
|
||||
out += "(truncated to cap)\n";
|
||||
return out;
|
||||
}
|
||||
|
||||
void ReleaseVerifier::add_check(std::string name, bool passed,
|
||||
std::string detail) {
|
||||
checks_.push_back({std::move(name), passed, std::move(detail)});
|
||||
}
|
||||
|
||||
bool ReleaseVerifier::green() const {
|
||||
if (checks_.empty()) return false;
|
||||
for (const auto& c : checks_) {
|
||||
if (!c.passed) return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
std::vector<VerifyCheck> ReleaseVerifier::checks() const { return checks_; }
|
||||
|
||||
} // namespace molo::example::fw
|
||||
@@ -0,0 +1,57 @@
|
||||
#pragma once
|
||||
|
||||
// Autonomous release pipeline: commits since tag to bump decision, changelog
|
||||
// writing, and pre-tag verification. Dry-run mode never writes.
|
||||
// Final home is tools/release/ under namespace molo::workflow; this example
|
||||
// copy proves the rules and is marked as a demo, not the pipeline itself.
|
||||
// Gap: real molo::workflow in tools/release/ plus CLI surface in 09-cli-ops.md.
|
||||
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
namespace molo::example::fw {
|
||||
|
||||
struct CommitInfo {
|
||||
std::string type;
|
||||
std::string scope;
|
||||
std::string description;
|
||||
bool breaking = false;
|
||||
};
|
||||
|
||||
bool parse_commit(const std::string& subject, const std::string& body,
|
||||
CommitInfo& out, std::string& error);
|
||||
|
||||
enum class Bump { None, Patch, Minor, Major };
|
||||
|
||||
std::string bump_name(Bump bump);
|
||||
|
||||
class ReleasePlanner {
|
||||
public:
|
||||
Bump decide(const std::vector<CommitInfo>& commits) const;
|
||||
std::string next_version(const std::string& current,
|
||||
const std::vector<CommitInfo>& commits) const;
|
||||
};
|
||||
|
||||
class ChangelogWriter {
|
||||
public:
|
||||
std::string entry(const std::string& version, const std::string& date,
|
||||
const std::vector<CommitInfo>& commits) const;
|
||||
};
|
||||
|
||||
struct VerifyCheck {
|
||||
std::string name;
|
||||
bool passed = false;
|
||||
std::string detail;
|
||||
};
|
||||
|
||||
class ReleaseVerifier {
|
||||
public:
|
||||
void add_check(std::string name, bool passed, std::string detail = "");
|
||||
bool green() const;
|
||||
std::vector<VerifyCheck> checks() const;
|
||||
|
||||
private:
|
||||
std::vector<VerifyCheck> checks_;
|
||||
};
|
||||
|
||||
} // namespace molo::example::fw
|
||||
Reference in New Issue
Block a user