Prechádzať zdrojové kódy

feat(relations): create, list, get and drop relations

Adds admin-only routes for declaring referential-integrity relations
between collections: POST/GET /api/v1/projects/{p}/relations and
GET/DELETE /api/v1/projects/{p}/relations/{name}.

The relation name is project-qualified exactly like a collection name
(the server rejects a bare name whose child/parent collections live in
another project), so createRelation/getRelationInfo/dropRelation all
receive qualify(project, name). Responses are unqualified so the API
never leaks the "project:" prefix outward.

on_delete is validated at the API boundary against restrict/cascade/
set_null/no_action before reaching the DB client.
fszontagh 1 mesiac pred
rodič
commit
592c8888fd

+ 1 - 0
src/CMakeLists.txt

@@ -25,6 +25,7 @@ add_library(vectorapi_core STATIC
     handlers/vectors.cpp
     handlers/stats.cpp
     handlers/settings.cpp
+    handlers/relations.cpp
 )
 target_include_directories(vectorapi_core PUBLIC
     ${CMAKE_CURRENT_SOURCE_DIR}

+ 93 - 0
src/handlers/relations.cpp

@@ -0,0 +1,93 @@
+#include "errors.hpp"
+#include "json_http.hpp"
+#include "server.hpp"
+
+namespace svapi {
+namespace {
+
+// Strip a leading "project:" so the API never leaks qualified names outward.
+std::string unqualify(const std::string& project, const std::string& name) {
+    const std::string pfx = project + ":";
+    return name.rfind(pfx, 0) == 0 ? name.substr(pfx.size()) : name;
+}
+
+nlohmann::json relJson(const std::string& project,
+                       const smartbotic::database::Client::RelationDefinition& r) {
+    return {{"name",        unqualify(project, r.name)},
+            {"child",       unqualify(project, r.child)},
+            {"child_field", r.childField},
+            {"parent",      unqualify(project, r.parent)},
+            {"on_delete",   r.onDelete},
+            {"validate_on_write", r.validateOnWrite},
+            {"created_at",  r.createdAt},
+            {"updated_at",  r.updatedAt}};
+}
+
+} // namespace
+
+void registerRelationRoutes(ApiServer& s) {
+    auto& svr = s.raw(); ServerDeps* d = &s.deps();
+
+    svr.Post(R"(/api/v1/projects/([^/]+)/relations)",
+             [d](const httplib::Request& req, httplib::Response& res) {
+        ApiKey k = requireKey(*d, req); std::string project = req.matches[1];
+        requireAdmin(k); requireProjectAccess(k, project);
+        auto body = bodyJson(req);
+        std::string name  = body.is_object() ? body.value("name", "") : std::string(),
+                    child  = body.is_object() ? body.value("child", "") : std::string(),
+                    field  = body.is_object() ? body.value("child_field", "") : std::string(),
+                    parent = body.is_object() ? body.value("parent", "") : std::string(),
+                    onDelete = body.is_object() ? body.value("on_delete", "restrict") : std::string("restrict");
+        if (name.empty() || child.empty() || field.empty() || parent.empty())
+            throw ApiError(ErrCode::Unprocessable, "validation",
+                           "name, child, child_field and parent are required");
+        if (name.rfind('_', 0) == 0 || name.rfind("vectorapi_", 0) == 0)
+            throw ApiError(ErrCode::Unprocessable, "validation",
+                           "name may not start with '_' or the reserved 'vectorapi_' prefix");
+        if (onDelete != "restrict" && onDelete != "cascade" &&
+            onDelete != "set_null" && onDelete != "no_action")
+            throw ApiError(ErrCode::Unprocessable, "validation",
+                           "on_delete must be restrict, cascade, set_null or no_action");
+        const bool validateOnWrite = body.is_object() ? body.value("validate_on_write", false) : false;
+
+        // The relation NAME is project-qualified exactly like the collections.
+        if (!d->db.client().createRelation(qualify(project, name), qualify(project, child), field,
+                                           qualify(project, parent), onDelete, validateOnWrite))
+            throw ApiError(ErrCode::Unavailable, "db_error", "failed to create relation");
+        sendJson(res, 201, {{"name", name}, {"child", child}, {"child_field", field},
+                            {"parent", parent}, {"on_delete", onDelete},
+                            {"validate_on_write", validateOnWrite}});
+    });
+
+    svr.Get(R"(/api/v1/projects/([^/]+)/relations)",
+            [d](const httplib::Request& req, httplib::Response& res) {
+        ApiKey k = requireKey(*d, req); std::string project = req.matches[1];
+        requireAdmin(k); requireProjectAccess(k, project);
+        auto defs = d->db.client().listRelations(project);
+        nlohmann::json out = nlohmann::json::array();
+        for (const auto& r : defs) out.push_back(relJson(project, r));
+        sendJson(res, 200, {{"relations", out}});
+    });
+
+    svr.Get(R"(/api/v1/projects/([^/]+)/relations/([^/]+))",
+            [d](const httplib::Request& req, httplib::Response& res) {
+        ApiKey k = requireKey(*d, req);
+        std::string project = req.matches[1], name = req.matches[2];
+        requireAdmin(k); requireProjectAccess(k, project);
+        auto info = d->db.client().getRelationInfo(qualify(project, name));
+        if (!info) throw ApiError(ErrCode::NotFound, "not_found", "relation not found");
+        sendJson(res, 200, relJson(project, *info));
+    });
+
+    svr.Delete(R"(/api/v1/projects/([^/]+)/relations/([^/]+))",
+               [d](const httplib::Request& req, httplib::Response& res) {
+        ApiKey k = requireKey(*d, req);
+        std::string project = req.matches[1], name = req.matches[2];
+        requireAdmin(k); requireProjectAccess(k, project);
+        if (!d->db.client().dropRelation(qualify(project, name)))
+            throw ApiError(ErrCode::NotFound, "not_found", "relation not found");
+        sendJson(res, 200, {{"dropped", name}});
+    });
+}
+
+} // namespace svapi

+ 1 - 0
src/server.cpp

@@ -139,6 +139,7 @@ void ApiServer::registerRoutes() {
     registerVectorRoutes(*this);
     registerStatsRoutes(*this);
     registerSettingsRoutes(*this);
+    registerRelationRoutes(*this);
 
     if (!d_.shareDir.empty()) svr_.set_mount_point("/docs", d_.shareDir + "/docs");
     if (!d_.webuiDir.empty()) svr_.set_mount_point("/", d_.webuiDir);

+ 1 - 0
src/server.hpp

@@ -59,5 +59,6 @@ void registerDocumentRoutes(ApiServer&);
 void registerVectorRoutes(ApiServer&);
 void registerStatsRoutes(ApiServer&);
 void registerSettingsRoutes(ApiServer&);
+void registerRelationRoutes(ApiServer&);
 
 } // namespace svapi

+ 39 - 0
tests/test_api_integration.cpp

@@ -1055,3 +1055,42 @@ TEST_F(ApiFixture, DocumentTtlRejectsNonNumeric) {
                       nlohmann::json{{"v",1}}.dump(), "application/json");
     ASSERT_TRUE(bad); EXPECT_EQ(bad->status, 422);
 }
+
+TEST_F(ApiFixture, RelationCrud) {
+    auto c = admin();
+    std::string base = "/api/v1/projects/" + project_ + "/collections";
+    ASSERT_EQ(c.Post(base.c_str(), nlohmann::json{{"name","orders"},{"kind","json"}}.dump(),
+                     "application/json")->status, 201);
+    ASSERT_EQ(c.Post(base.c_str(), nlohmann::json{{"name","customers"},{"kind","json"}}.dump(),
+                     "application/json")->status, 201);
+
+    std::string rel = "/api/v1/projects/" + project_ + "/relations";
+    auto made = c.Post(rel.c_str(), nlohmann::json{
+        {"name","order_customer"}, {"child","orders"}, {"child_field","customer_id"},
+        {"parent","customers"}, {"on_delete","restrict"}}.dump(), "application/json");
+    ASSERT_TRUE(made); EXPECT_EQ(made->status, 201);
+
+    auto list = c.Get(rel.c_str());
+    ASSERT_EQ(list->status, 200);
+    auto listJson = nlohmann::json::parse(list->body);
+    bool found = false;
+    for (auto& e : listJson["relations"])
+        if (e["name"] == "order_customer") { found = true; EXPECT_EQ(e["child"], "orders"); }
+    EXPECT_TRUE(found);
+
+    auto one = c.Get((rel + "/order_customer").c_str());
+    ASSERT_EQ(one->status, 200);
+    EXPECT_EQ(nlohmann::json::parse(one->body)["parent"], "customers");
+
+    EXPECT_EQ(c.Delete((rel + "/order_customer").c_str())->status, 200);
+    EXPECT_EQ(c.Get((rel + "/order_customer").c_str())->status, 404);
+}
+
+TEST_F(ApiFixture, RelationRejectsBadOnDelete) {
+    auto c = admin();
+    std::string rel = "/api/v1/projects/" + project_ + "/relations";
+    auto bad = c.Post(rel.c_str(), nlohmann::json{
+        {"name","r"}, {"child","a"}, {"child_field","b"},
+        {"parent","c"}, {"on_delete","explode"}}.dump(), "application/json");
+    ASSERT_TRUE(bad); EXPECT_EQ(bad->status, 422);
+}