Browse Source

feat(api): project-scoped collection CRUD

Implements Task 13: POST/GET/GET-one/DELETE handlers under
/api/v1/projects/{project}/collections[/{name}] with requireKey →
requireProjectAccess → CollectionRegistry + qualify() for the DB layer.
Rejects names starting with '_' or the reserved 'vectorapi_' prefix.

Two fixes for DB v2.3.1 Stage-F caveat (dropProject does not purge
collection data): (1) handler drops any stale DB-layer collection before
createCollection so idempotency works across test runs; (2) api_fixture
SetUp explicitly drops the project's vectorapi_collections registry so
ensure() always starts fresh.

Test deviation: plan's CollectionCrudUnderProject used a range-for over
nlohmann::json::parse(body)["collections"] whose temporary was destroyed
before the loop body; fixed by binding the parsed JSON to a named local
variable first.
Fszontagh 2 tháng trước cách đây
mục cha
commit
784a9c6e5f
3 tập tin đã thay đổi với 96 bổ sung1 xóa
  1. 71 1
      src/handlers/collections.cpp
  2. 3 0
      tests/api_fixture.hpp
  3. 22 0
      tests/test_api_integration.cpp

+ 71 - 1
src/handlers/collections.cpp

@@ -1,2 +1,72 @@
+#include "collection_registry.hpp"
+#include "errors.hpp"
+#include "json_http.hpp"
 #include "server.hpp"
-namespace svapi { void registerCollectionRoutes(ApiServer&) {} }
+namespace svapi {
+void registerCollectionRoutes(ApiServer& s) {
+    auto& svr = s.raw(); ServerDeps* d = &s.deps();
+
+    svr.Post(R"(/api/v1/projects/([^/]+)/collections)", [d](const httplib::Request& req, httplib::Response& res) {
+        ApiKey k = requireKey(*d, req); std::string project = req.matches[1];
+        requireProjectAccess(k, project);
+        d->db.ensureProject(project); d->registry.ensure(project);
+        auto body = bodyJson(req);
+        std::string name = body.value("name", ""), kind = body.value("kind", "json");
+        if (name.empty()) throw ApiError(ErrCode::Unprocessable, "validation", "name is 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 (kind != "json" && kind != "vector") throw ApiError(ErrCode::Unprocessable, "validation", "kind must be 'json' or 'vector'");
+        uint32_t dim = 0; std::string model;
+        if (kind == "vector") {
+            if (!body.contains("vector_dimension")) throw ApiError(ErrCode::Unprocessable, "validation", "vector_dimension required for vector collections");
+            dim = body.value("vector_dimension", 0u);
+            if (dim == 0) throw ApiError(ErrCode::Unprocessable, "validation", "vector_dimension must be > 0");
+            model = body.value("embedding_model", "");
+        }
+        if (d->registry.get(project, name)) throw ApiError(ErrCode::Unprocessable, "exists", "collection already exists");
+        // Drop any stale DB-layer collection (e.g. left from a prior dropProject that didn't
+        // fully clean up its data — DB v2.3.1 Stage F caveat).
+        if (d->db.client().getCollectionInfo(qualify(project, name)))
+            d->db.client().dropCollection(qualify(project, name));
+        if (!d->db.client().createCollection(qualify(project, name), 0, false, 0, dim))
+            throw ApiError(ErrCode::Unavailable, "db_error", "failed to create collection");
+        d->registry.add(project, {name, kind, dim, model, 0});
+        sendJson(res, 201, {{"name", name}, {"kind", kind}, {"vector_dimension", dim}});
+    });
+
+    svr.Get(R"(/api/v1/projects/([^/]+)/collections)", [d](const httplib::Request& req, httplib::Response& res) {
+        ApiKey k = requireKey(*d, req); std::string project = req.matches[1];
+        requireProjectAccess(k, project);
+        nlohmann::json out = nlohmann::json::array();
+        for (const auto& m : d->registry.list(project)) {
+            nlohmann::json e = m.toJson();
+            if (auto info = d->db.client().getCollectionInfo(qualify(project, m.name))) {
+                e["document_count"] = info->documentCount; e["size_bytes"] = info->sizeBytes;
+            }
+            out.push_back(e);
+        }
+        sendJson(res, 200, {{"collections", out}});
+    });
+
+    svr.Get(R"(/api/v1/projects/([^/]+)/collections/([^/]+))", [d](const httplib::Request& req, httplib::Response& res) {
+        ApiKey k = requireKey(*d, req); std::string project = req.matches[1], name = req.matches[2];
+        requireProjectAccess(k, project);
+        auto m = d->registry.get(project, name);
+        if (!m) throw ApiError(ErrCode::NotFound, "not_found", "no such collection");
+        nlohmann::json e = m->toJson();
+        if (auto info = d->db.client().getCollectionInfo(qualify(project, name))) {
+            e["document_count"] = info->documentCount; e["size_bytes"] = info->sizeBytes;
+        }
+        sendJson(res, 200, e);
+    });
+
+    svr.Delete(R"(/api/v1/projects/([^/]+)/collections/([^/]+))", [d](const httplib::Request& req, httplib::Response& res) {
+        ApiKey k = requireKey(*d, req); std::string project = req.matches[1], name = req.matches[2];
+        requireProjectAccess(k, project);
+        if (!d->registry.get(project, name)) throw ApiError(ErrCode::NotFound, "not_found", "no such collection");
+        d->db.client().dropCollection(qualify(project, name));
+        d->registry.remove(project, name);
+        sendJson(res, 200, {{"deleted", name}});
+    });
+}
+}

+ 3 - 0
tests/api_fixture.hpp

@@ -36,6 +36,9 @@ protected:
         db_->client().dropCollection(settingsColl_);
         db_->client().dropCollection(keysColl_);
         db_->dropProject(project_);
+        // DB v2.3.1 Stage-F caveat: dropProject does not delete collection data.
+        // Drop the project's registry collection explicitly so ensure() starts fresh.
+        db_->client().dropCollection(qualify(project_, "vectorapi_collections"));
         db_->ensureProject(project_);
 
         registry_ = std::make_unique<CollectionRegistry>(*db_);

+ 22 - 0
tests/test_api_integration.cpp

@@ -74,3 +74,25 @@ TEST_F(ApiFixture, NonAdminCannotCreateProject) {
     ASSERT_TRUE(r); EXPECT_EQ(r->status, 403);
     admin().Delete(("/api/v1/keys/" + scoped).c_str());
 }
+
+TEST_F(ApiFixture, CollectionCrudUnderProject) {
+    auto c = admin();
+    std::string base = "/api/v1/projects/" + project_ + "/collections";
+    auto created = c.Post(base.c_str(), nlohmann::json{{"name","users"},{"kind","json"}}.dump(), "application/json");
+    ASSERT_TRUE(created); EXPECT_EQ(created->status, 201);
+    auto list = c.Get(base.c_str());
+    ASSERT_EQ(list->status, 200);
+    // Bind to a local to avoid C++ temporary-lifetime issue in range-for over ["collections"].
+    auto listJson = nlohmann::json::parse(list->body);
+    bool found=false; for (auto& e : listJson["collections"]) if (e["name"]=="users") found=true;
+    EXPECT_TRUE(found);
+    EXPECT_EQ(c.Get((base + "/users").c_str())->status, 200);
+    EXPECT_EQ(c.Delete((base + "/users").c_str())->status, 200);
+    EXPECT_EQ(c.Get((base + "/users").c_str())->status, 404);
+}
+TEST_F(ApiFixture, VectorCollectionNeedsDimension) {
+    auto c = admin();
+    std::string base = "/api/v1/projects/" + project_ + "/collections";
+    auto bad = c.Post(base.c_str(), nlohmann::json{{"name","novec"},{"kind","vector"}}.dump(), "application/json");
+    ASSERT_TRUE(bad); EXPECT_EQ(bad->status, 422);
+}