Ver Fonte

feat(documents): ttl_seconds on insert and patch

Adds an optional ttl_seconds query parameter to POST .../documents and
PATCH .../documents/{id}. On insert, absence means no expiry (0). On
patch, absence leaves the existing expiry untouched (3-arg patch());
presence sets it via patchWithTtl(), where ttl_seconds=0 explicitly
clears the expiry. Non-numeric or out-of-range values are rejected
with 422.
fszontagh há 1 mês atrás
pai
commit
bd35881fd4
2 ficheiros alterados com 56 adições e 2 exclusões
  1. 22 2
      src/handlers/documents.cpp
  2. 34 0
      tests/test_api_integration.cpp

+ 22 - 2
src/handlers/documents.cpp

@@ -3,6 +3,9 @@
 #include "filters.hpp"
 #include "json_http.hpp"
 #include "server.hpp"
+#include <cstdlib>
+#include <limits>
+#include <optional>
 namespace svapi {
 namespace {
 // Authorize + verify the collection exists; returns the qualified collection name.
@@ -13,6 +16,19 @@ std::string scoped(ServerDeps* d, const httplib::Request& req, int projIdx, int
     if (!d->registry.get(project, name)) throw ApiError(ErrCode::NotFound, "not_found", "no such collection");
     return qualify(project, name);
 }
+
+// Parses ?ttl_seconds=. Returns nullopt when absent — the caller must
+// distinguish "not supplied" (leave expiry alone) from 0 (clear the expiry).
+std::optional<uint32_t> ttlParam(const httplib::Request& req) {
+    if (!req.has_param("ttl_seconds")) return std::nullopt;
+    const std::string raw = req.get_param_value("ttl_seconds");
+    if (raw.empty() || raw.find_first_not_of("0123456789") != std::string::npos)
+        throw ApiError(ErrCode::Unprocessable, "validation", "ttl_seconds must be a non-negative integer");
+    unsigned long long wide = std::strtoull(raw.c_str(), nullptr, 10);
+    if (wide > std::numeric_limits<uint32_t>::max())
+        throw ApiError(ErrCode::Unprocessable, "validation", "ttl_seconds must be a non-negative integer");
+    return (uint32_t)wide;
+}
 }
 void registerDocumentRoutes(ApiServer& s) {
     auto& svr = s.raw(); ServerDeps* d = &s.deps();
@@ -23,7 +39,8 @@ void registerDocumentRoutes(ApiServer& s) {
         std::string id = body.value("id", "");
         nlohmann::json data = body.contains("data") ? body["data"] : body;
         if (data.is_object() && data.contains("id")) data.erase("id");
-        std::string newId = d->db.client().insert(c, data, id);
+        const uint32_t ttl = ttlParam(req).value_or(0);
+        std::string newId = d->db.client().insert(c, data, id, ttl);
         if (newId.empty()) throw ApiError(ErrCode::Unavailable, "db_error", "insert failed");
         sendJson(res, 201, {{"id", newId}});
     });
@@ -55,7 +72,10 @@ void registerDocumentRoutes(ApiServer& s) {
     svr.Patch(R"(/api/v1/projects/([^/]+)/collections/([^/]+)/documents/([^/]+))", [d](const httplib::Request& req, httplib::Response& res) {
         std::string c = scoped(d, req, 1, 2, KeyOp::Update); std::string id = req.matches[3];
         if (!d->db.client().exists(c, id)) throw ApiError(ErrCode::NotFound, "not_found", "no such document");
-        uint64_t v = d->db.client().patch(c, id, bodyJson(req));
+        nlohmann::json patchBody = bodyJson(req);
+        const auto ttl = ttlParam(req);
+        uint64_t v = ttl ? d->db.client().patchWithTtl(c, id, patchBody, *ttl)
+                          : d->db.client().patch(c, id, patchBody);
         if (v == 0) throw ApiError(ErrCode::Unavailable, "db_error", "patch failed");
         sendJson(res, 200, {{"id", id}, {"version", v}});
     });

+ 34 - 0
tests/test_api_integration.cpp

@@ -1021,3 +1021,37 @@ TEST_F(ApiFixture, UniqueIndexOnCleanFieldSucceeds) {
     ASSERT_TRUE(made); EXPECT_EQ(made->status, 201);
     EXPECT_TRUE(nlohmann::json::parse(made->body)["unique"].get<bool>());
 }
+
+TEST_F(ApiFixture, DocumentTtlAcceptedOnInsertAndPatch) {
+    auto c = admin();
+    std::string base = "/api/v1/projects/" + project_ + "/collections";
+    ASSERT_EQ(c.Post(base.c_str(), nlohmann::json{{"name","ttlc"},{"kind","json"}}.dump(),
+                     "application/json")->status, 201);
+    std::string docs = base + "/ttlc/documents";
+
+    auto made = c.Post((docs + "?ttl_seconds=3600").c_str(),
+                       nlohmann::json{{"v",1}}.dump(), "application/json");
+    ASSERT_TRUE(made); EXPECT_EQ(made->status, 201);
+    std::string id = nlohmann::json::parse(made->body)["id"];
+
+    // Document is readable while the TTL is in the future.
+    EXPECT_EQ(c.Get((docs + "/" + id).c_str())->status, 200);
+
+    // ttl_seconds=0 on PATCH clears the expiry (does not delete the document).
+    auto cleared = c.Patch((docs + "/" + id + "?ttl_seconds=0").c_str(),
+                           nlohmann::json{{"v",2}}.dump(), "application/json");
+    ASSERT_TRUE(cleared); EXPECT_EQ(cleared->status, 200);
+    auto got = c.Get((docs + "/" + id).c_str());
+    ASSERT_EQ(got->status, 200);
+    EXPECT_EQ(nlohmann::json::parse(got->body)["v"], 2);
+}
+
+TEST_F(ApiFixture, DocumentTtlRejectsNonNumeric) {
+    auto c = admin();
+    std::string base = "/api/v1/projects/" + project_ + "/collections";
+    ASSERT_EQ(c.Post(base.c_str(), nlohmann::json{{"name","ttlbad"},{"kind","json"}}.dump(),
+                     "application/json")->status, 201);
+    auto bad = c.Post((base + "/ttlbad/documents?ttl_seconds=soon").c_str(),
+                      nlohmann::json{{"v",1}}.dump(), "application/json");
+    ASSERT_TRUE(bad); EXPECT_EQ(bad->status, 422);
+}