| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485 |
- #include "smartbotic/webserver/collection_service.hpp"
- #include <chrono>
- #include <functional>
- #include <iomanip>
- #include <regex>
- #include <sstream>
- #include <nlohmann/json.hpp>
- #include <spdlog/spdlog.h>
- namespace smartbotic::webserver {
- namespace {
- /// System collection for storing collection settings/metadata
- constexpr const char* kSettingsCollection = "_collection_settings";
- /// Convert timestamp to ISO 8601 string
- auto TimestampToString(const ::smartbotic::database::Timestamp& ts) -> std::string {
- if (ts.seconds() == 0 && ts.nanos() == 0) {
- return "";
- }
- std::time_t time = ts.seconds();
- std::tm tm{};
- gmtime_r(&time, &tm);
- std::ostringstream oss;
- oss << std::put_time(&tm, "%Y-%m-%dT%H:%M:%S");
- if (ts.nanos() > 0) {
- oss << "." << std::setfill('0') << std::setw(3) << (ts.nanos() / 1000000);
- }
- oss << "Z";
- return oss.str();
- }
- } // namespace
- CollectionService::CollectionService(DatabaseClient& db_client) : db_client_(db_client) {
- // Ensure settings collection exists
- grpc::ClientContext ctx;
- ::smartbotic::database::CreateCollectionRequest req;
- ::smartbotic::database::CollectionMetadata resp;
- req.set_name(kSettingsCollection);
- auto status = db_client_.GetCollectionService()->CreateCollection(&ctx, req, &resp);
- if (status.ok()) {
- spdlog::info("Created system collection {}", kSettingsCollection);
- } else if (status.error_code() == grpc::StatusCode::ALREADY_EXISTS) {
- spdlog::debug("System collection {} already exists", kSettingsCollection);
- } else {
- spdlog::warn("Failed to ensure {} collection: {}", kSettingsCollection, status.error_message());
- }
- }
- CollectionService::~CollectionService() = default;
- CollectionService::CollectionService(CollectionService&&) noexcept = default;
- auto CollectionService::operator=(CollectionService&& /*other*/) noexcept -> CollectionService& {
- // Cannot reassign reference member, so this is essentially a no-op
- return *this;
- }
- auto CollectionService::CreateCollection(const CreateCollectionRequest& request) -> CollectionResult {
- // Validate collection name
- if (!IsValidCollectionName(request.name)) {
- return {.success = false, .error = "Invalid collection name. Must be alphanumeric with underscores, 1-64 chars.", .collection = std::nullopt};
- }
- // System collection names are reserved
- if (IsSystemCollection(request.name)) {
- return {.success = false, .error = "Collection name is reserved for system use", .collection = std::nullopt};
- }
- std::string internal_name = GetInternalName(request.workspace_id, request.name);
- // Create the collection via gRPC
- grpc::ClientContext ctx;
- ::smartbotic::database::CreateCollectionRequest req;
- ::smartbotic::database::CollectionMetadata resp;
- req.set_name(internal_name);
- auto status = db_client_.GetCollectionService()->CreateCollection(&ctx, req, &resp);
- if (!status.ok()) {
- if (status.error_code() == grpc::StatusCode::ALREADY_EXISTS) {
- return {.success = false, .error = "Collection already exists", .collection = std::nullopt};
- }
- spdlog::error("Failed to create collection {}: {}", internal_name, status.error_message());
- return {.success = false, .error = "Failed to create collection: " + status.error_message(), .collection = std::nullopt};
- }
- // Store settings in metadata collection
- if (!StoreSettings(request.workspace_id, request.name, request.settings)) {
- spdlog::warn("Collection {} created but failed to store settings", internal_name);
- }
- auto info = MetadataToInfo(request.workspace_id, resp);
- info.settings = request.settings;
- spdlog::info("Created collection {} in workspace {}", request.name, request.workspace_id);
- return {.success = true, .error = "", .collection = info};
- }
- auto CollectionService::GetCollection(const std::string& workspace_id, const std::string& name) -> CollectionResult {
- std::string internal_name = GetInternalName(workspace_id, name);
- grpc::ClientContext ctx;
- ::smartbotic::database::GetCollectionMetadataRequest req;
- ::smartbotic::database::CollectionMetadata resp;
- req.set_name(internal_name);
- auto status = db_client_.GetCollectionService()->GetCollectionMetadata(&ctx, req, &resp);
- if (!status.ok()) {
- if (status.error_code() == grpc::StatusCode::NOT_FOUND) {
- return {.success = false, .error = "Collection not found", .collection = std::nullopt};
- }
- spdlog::error("Failed to get collection {}: {}", internal_name, status.error_message());
- return {.success = false, .error = "Failed to get collection: " + status.error_message(), .collection = std::nullopt};
- }
- auto info = MetadataToInfo(workspace_id, resp);
- info.settings = LoadSettings(workspace_id, name);
- return {.success = true, .error = "", .collection = info};
- }
- auto CollectionService::ListCollections(const std::string& workspace_id) -> CollectionListResult {
- grpc::ClientContext ctx;
- ::smartbotic::database::ListCollectionsRequest req;
- ::smartbotic::database::ListCollectionsResponse resp;
- req.set_page_size(1000); // Get all collections
- auto status = db_client_.GetCollectionService()->ListCollections(&ctx, req, &resp);
- if (!status.ok()) {
- spdlog::error("Failed to list collections: {}", status.error_message());
- return {.success = false, .error = "Failed to list collections: " + status.error_message(), .collections = {}, .total_count = 0};
- }
- std::string prefix = GetInternalName(workspace_id, "");
- std::vector<CollectionInfo> collections;
- for (const auto& meta : resp.collections()) {
- // Only include collections that belong to this workspace
- if (meta.name().rfind(prefix, 0) == 0) {
- auto info = MetadataToInfo(workspace_id, meta);
- info.settings = LoadSettings(workspace_id, info.name);
- collections.push_back(std::move(info));
- }
- }
- int64_t count = static_cast<int64_t>(collections.size());
- return {
- .success = true,
- .error = "",
- .collections = std::move(collections),
- .total_count = count
- };
- }
- auto CollectionService::ListSystemCollections() -> CollectionListResult {
- grpc::ClientContext ctx;
- ::smartbotic::database::ListCollectionsRequest req;
- ::smartbotic::database::ListCollectionsResponse resp;
- req.set_page_size(1000);
- auto status = db_client_.GetCollectionService()->ListCollections(&ctx, req, &resp);
- if (!status.ok()) {
- spdlog::error("Failed to list system collections: {}", status.error_message());
- return {.success = false, .error = "Failed to list collections: " + status.error_message(), .collections = {}, .total_count = 0};
- }
- std::vector<CollectionInfo> collections;
- for (const auto& meta : resp.collections()) {
- // System collections start with underscore and are not workspace-prefixed
- if (!meta.name().empty() && meta.name()[0] == '_') {
- CollectionInfo info;
- info.name = meta.name();
- info.workspace_id = ""; // Global collection
- info.document_count = meta.document_count();
- info.size_bytes = meta.size_bytes();
- info.created_at = TimestampToString(meta.created_at());
- info.updated_at = TimestampToString(meta.updated_at());
- collections.push_back(std::move(info));
- }
- }
- int64_t count = static_cast<int64_t>(collections.size());
- return {
- .success = true,
- .error = "",
- .collections = std::move(collections),
- .total_count = count
- };
- }
- auto CollectionService::UpdateCollection(const std::string& workspace_id, const std::string& name,
- const CollectionSettings& settings) -> CollectionResult {
- // First verify the collection exists
- auto get_result = GetCollection(workspace_id, name);
- if (!get_result.success) {
- return get_result;
- }
- // Update settings in metadata collection
- if (!StoreSettings(workspace_id, name, settings)) {
- return {.success = false, .error = "Failed to update collection settings", .collection = std::nullopt};
- }
- // Return updated info
- auto info = *get_result.collection;
- info.settings = settings;
- spdlog::info("Updated collection {} in workspace {}", name, workspace_id);
- return {.success = true, .error = "", .collection = info};
- }
- auto CollectionService::DropCollection(const std::string& workspace_id, const std::string& name,
- bool force) -> CollectionResult {
- std::string internal_name = GetInternalName(workspace_id, name);
- grpc::ClientContext ctx;
- ::smartbotic::database::DropCollectionRequest req;
- ::smartbotic::database::DropCollectionResponse resp;
- req.set_name(internal_name);
- req.set_force(force);
- auto status = db_client_.GetCollectionService()->DropCollection(&ctx, req, &resp);
- if (!status.ok()) {
- if (status.error_code() == grpc::StatusCode::NOT_FOUND) {
- return {.success = false, .error = "Collection not found", .collection = std::nullopt};
- }
- if (status.error_code() == grpc::StatusCode::FAILED_PRECONDITION) {
- return {.success = false, .error = "Collection is not empty. Use force=true to drop anyway.", .collection = std::nullopt};
- }
- spdlog::error("Failed to drop collection {}: {}", internal_name, status.error_message());
- return {.success = false, .error = "Failed to drop collection: " + status.error_message(), .collection = std::nullopt};
- }
- // Delete settings
- DeleteSettings(workspace_id, name);
- spdlog::info("Dropped collection {} from workspace {}", name, workspace_id);
- return {.success = true, .error = "", .collection = std::nullopt};
- }
- auto CollectionService::IsValidCollectionName(const std::string& name) -> bool {
- if (name.empty() || name.length() > 64) {
- return false;
- }
- // Must start with letter or underscore, contain only alphanumeric and underscores
- static const std::regex pattern("^[a-zA-Z_][a-zA-Z0-9_]*$");
- return std::regex_match(name, pattern);
- }
- auto CollectionService::IsSystemCollection(const std::string& name) -> bool {
- return !name.empty() && name[0] == '_';
- }
- auto CollectionService::GetInternalName(const std::string& workspace_id, const std::string& name) -> std::string {
- return std::string(kCollectionPrefix) + workspace_id + "_" + name;
- }
- auto CollectionService::GetDisplayName(const std::string& workspace_id, const std::string& internal_name) -> std::string {
- std::string prefix = GetInternalName(workspace_id, "");
- if (internal_name.rfind(prefix, 0) == 0) {
- return internal_name.substr(prefix.length());
- }
- return internal_name;
- }
- auto CollectionService::MetadataToInfo(const std::string& workspace_id,
- const ::smartbotic::database::CollectionMetadata& meta) -> CollectionInfo {
- CollectionInfo info;
- info.name = GetDisplayName(workspace_id, meta.name());
- info.workspace_id = workspace_id;
- info.document_count = meta.document_count();
- info.size_bytes = meta.size_bytes();
- info.created_at = TimestampToString(meta.created_at());
- info.updated_at = TimestampToString(meta.updated_at());
- return info;
- }
- auto CollectionService::GetCurrentTimestamp() -> std::string {
- auto now = std::chrono::system_clock::now();
- auto time_t = std::chrono::system_clock::to_time_t(now);
- auto ms = std::chrono::duration_cast<std::chrono::milliseconds>(now.time_since_epoch()) % 1000;
- std::tm tm{};
- gmtime_r(&time_t, &tm);
- std::ostringstream oss;
- oss << std::put_time(&tm, "%Y-%m-%dT%H:%M:%S");
- oss << "." << std::setfill('0') << std::setw(3) << ms.count() << "Z";
- return oss.str();
- }
- auto CollectionService::StoreSettings(const std::string& workspace_id, const std::string& name,
- const CollectionSettings& settings) -> bool {
- std::string doc_id = workspace_id + "_" + name;
- nlohmann::json json_settings;
- json_settings["workspace_id"] = workspace_id;
- json_settings["collection_name"] = name;
- json_settings["schema"] = settings.schema;
- json_settings["is_system"] = settings.is_system;
- json_settings["encrypted_fields"] = settings.encrypted_fields;
- json_settings["ttl_seconds"] = settings.ttl_seconds;
- json_settings["updated_at"] = GetCurrentTimestamp();
- // Serialize field_permissions
- nlohmann::json field_perms_json = nlohmann::json::array();
- for (const auto& fp : settings.field_permissions) {
- nlohmann::json fp_json;
- fp_json["field_name"] = fp.field_name;
- fp_json["read_groups"] = fp.read_groups;
- fp_json["write_groups"] = fp.write_groups;
- field_perms_json.push_back(fp_json);
- }
- json_settings["field_permissions"] = field_perms_json;
- // Build the proto document using a recursive lambda to handle nested structures
- std::function<void(const nlohmann::json&, ::smartbotic::database::Value*)> json_to_proto;
- json_to_proto = [&json_to_proto](const nlohmann::json& j, ::smartbotic::database::Value* val) {
- if (j.is_string()) {
- val->set_string_value(j.get<std::string>());
- } else if (j.is_boolean()) {
- val->set_bool_value(j.get<bool>());
- } else if (j.is_number_integer()) {
- val->set_int_value(j.get<int64_t>());
- } else if (j.is_number_float()) {
- val->set_double_value(j.get<double>());
- } else if (j.is_array()) {
- auto* arr = val->mutable_array_value();
- for (const auto& item : j) {
- json_to_proto(item, arr->add_values());
- }
- } else if (j.is_object()) {
- auto* map = val->mutable_map_value();
- for (const auto& [k, v] : j.items()) {
- json_to_proto(v, &(*map->mutable_fields())[k]);
- }
- } else if (j.is_null()) {
- val->set_null_value(true);
- }
- };
- ::smartbotic::database::MapValue data;
- for (const auto& [key, value] : json_settings.items()) {
- json_to_proto(value, &(*data.mutable_fields())[key]);
- }
- // Try update first, then create
- {
- grpc::ClientContext ctx;
- ::smartbotic::database::UpdateDocumentRequest req;
- ::smartbotic::database::Document resp;
- req.set_collection(kSettingsCollection);
- req.set_id(doc_id);
- req.set_merge(true);
- *req.mutable_data() = data;
- auto status = db_client_.GetDocumentService()->UpdateDocument(&ctx, req, &resp);
- if (status.ok()) {
- return true;
- }
- }
- // Create if update failed
- {
- grpc::ClientContext ctx;
- ::smartbotic::database::CreateDocumentRequest req;
- ::smartbotic::database::Document resp;
- req.set_collection(kSettingsCollection);
- req.set_id(doc_id);
- *req.mutable_data() = data;
- auto status = db_client_.GetDocumentService()->CreateDocument(&ctx, req, &resp);
- if (!status.ok()) {
- spdlog::error("Failed to store collection settings: {}", status.error_message());
- return false;
- }
- }
- return true;
- }
- auto CollectionService::LoadSettings(const std::string& workspace_id, const std::string& name) -> CollectionSettings {
- std::string doc_id = workspace_id + "_" + name;
- grpc::ClientContext ctx;
- ::smartbotic::database::GetDocumentRequest req;
- ::smartbotic::database::Document resp;
- req.set_collection(kSettingsCollection);
- req.set_id(doc_id);
- auto status = db_client_.GetDocumentService()->GetDocument(&ctx, req, &resp);
- if (!status.ok()) {
- return {}; // Return defaults if not found
- }
- CollectionSettings settings;
- const auto& fields = resp.data().fields();
- if (auto it = fields.find("schema"); it != fields.end() && it->second.has_string_value()) {
- settings.schema = it->second.string_value();
- }
- if (auto it = fields.find("is_system"); it != fields.end() && it->second.has_bool_value()) {
- settings.is_system = it->second.bool_value();
- }
- if (auto it = fields.find("ttl_seconds"); it != fields.end() && it->second.has_int_value()) {
- settings.ttl_seconds = it->second.int_value();
- }
- if (auto it = fields.find("encrypted_fields"); it != fields.end() && it->second.has_array_value()) {
- for (const auto& val : it->second.array_value().values()) {
- if (val.has_string_value()) {
- settings.encrypted_fields.push_back(val.string_value());
- }
- }
- }
- // Load field_permissions
- if (auto it = fields.find("field_permissions"); it != fields.end() && it->second.has_array_value()) {
- for (const auto& fp_val : it->second.array_value().values()) {
- if (fp_val.has_map_value()) {
- FieldPermission fp;
- const auto& fp_fields = fp_val.map_value().fields();
- if (auto fn_it = fp_fields.find("field_name"); fn_it != fp_fields.end() && fn_it->second.has_string_value()) {
- fp.field_name = fn_it->second.string_value();
- }
- if (auto rg_it = fp_fields.find("read_groups"); rg_it != fp_fields.end() && rg_it->second.has_array_value()) {
- for (const auto& rg : rg_it->second.array_value().values()) {
- if (rg.has_string_value()) {
- fp.read_groups.push_back(rg.string_value());
- }
- }
- }
- if (auto wg_it = fp_fields.find("write_groups"); wg_it != fp_fields.end() && wg_it->second.has_array_value()) {
- for (const auto& wg : wg_it->second.array_value().values()) {
- if (wg.has_string_value()) {
- fp.write_groups.push_back(wg.string_value());
- }
- }
- }
- settings.field_permissions.push_back(std::move(fp));
- }
- }
- }
- return settings;
- }
- auto CollectionService::DeleteSettings(const std::string& workspace_id, const std::string& name) -> bool {
- std::string doc_id = workspace_id + "_" + name;
- grpc::ClientContext ctx;
- ::smartbotic::database::DeleteDocumentRequest req;
- ::smartbotic::database::DeleteDocumentResponse resp;
- req.set_collection(kSettingsCollection);
- req.set_id(doc_id);
- auto status = db_client_.GetDocumentService()->DeleteDocument(&ctx, req, &resp);
- return status.ok();
- }
- } // namespace smartbotic::webserver
|