From 64fafeb905e741492a160d111a1e5e18b0ed0e3c Mon Sep 17 00:00:00 2001 From: Calvin Min Date: Tue, 6 Jan 2026 11:34:33 -0500 Subject: [PATCH 1/7] Workspace API Support --- CMakeLists.txt | 3 + examples/CMakeLists.txt | 5 + examples/workspace_example.cpp | 258 ++++++++++++ include/databricks/workspace/workspace.h | 194 +++++++++ .../databricks/workspace/workspace_types.h | 106 +++++ src/workspace/workspace.cpp | 396 ++++++++++++++++++ 6 files changed, 962 insertions(+) create mode 100644 examples/workspace_example.cpp create mode 100644 include/databricks/workspace/workspace.h create mode 100644 include/databricks/workspace/workspace_types.h create mode 100644 src/workspace/workspace.cpp diff --git a/CMakeLists.txt b/CMakeLists.txt index 90ccbef..ca718d2 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -124,6 +124,7 @@ set(SOURCES src/unity_catalog/unity_catalog_types.cpp src/unity_catalog/unity_catalog.cpp src/secrets/secrets.cpp + src/workspace/workspace.cpp src/internal/pool_manager.cpp src/internal/logger.cpp src/internal/http_client.cpp @@ -142,6 +143,8 @@ set(HEADERS include/databricks/unity_catalog/unity_catalog_types.h include/databricks/secrets/secrets.h include/databricks/secrets/secrets_types.h + include/databricks/workspace/workspace.h + include/databricks/workspace/workspace_types.h ) # Internal headers (not installed) diff --git a/examples/CMakeLists.txt b/examples/CMakeLists.txt index 6b76e43..4c882ef 100644 --- a/examples/CMakeLists.txt +++ b/examples/CMakeLists.txt @@ -20,6 +20,10 @@ target_link_libraries(unity_catalog_example PRIVATE databricks_sdk) add_executable(secrets_example secrets_example.cpp) target_link_libraries(secrets_example PRIVATE databricks_sdk) +# ========== Workspace API Examples ========== +add_executable(workspace_example workspace_example.cpp) +target_link_libraries(workspace_example PRIVATE databricks_sdk) + # Set RPATH for all examples to find ODBC libraries set_target_properties( simple_query @@ -27,6 +31,7 @@ set_target_properties( compute_example unity_catalog_example secrets_example + workspace_example PROPERTIES BUILD_RPATH "${CMAKE_BINARY_DIR};/opt/homebrew/lib;/usr/local/lib" INSTALL_RPATH "/opt/homebrew/lib;/usr/local/lib" diff --git a/examples/workspace_example.cpp b/examples/workspace_example.cpp new file mode 100644 index 0000000..7ef5acb --- /dev/null +++ b/examples/workspace_example.cpp @@ -0,0 +1,258 @@ +// Copyright (c) 2026 Calvin Min +// SPDX-License-Identifier: MIT +/** + * @file workspace_example.cpp + * @brief Example demonstrating the Databricks Workspace API + * + * This example shows how to: + * 1. List workspace objects in a directory + * 2. Create a new directory + * 3. Get object status/metadata + * 4. Import a simple Python notebook + * 5. Export the notebook + * 6. Delete objects and clean up + */ + +#include "databricks/core/config.h" +#include "databricks/workspace/workspace.h" + +#include +#include +#include +#include +#include + +// Helper function to encode string to base64 +std::string base64_encode(const std::string& input) { + static const char* base64_chars = + "ABCDEFGHIJKLMNOPQRSTUVWXYZ" + "abcdefghijklmnopqrstuvwxyz" + "0123456789+/"; + + std::string encoded; + int val = 0; + int valb = -6; + + for (unsigned char c : input) { + val = (val << 8) + c; + valb += 8; + while (valb >= 0) { + encoded.push_back(base64_chars[(val >> valb) & 0x3F]); + valb -= 6; + } + } + + if (valb > -6) { + encoded.push_back(base64_chars[((val << 8) >> (valb + 8)) & 0x3F]); + } + + while (encoded.size() % 4) { + encoded.push_back('='); + } + + return encoded; +} + +// Helper function to print ObjectType +std::string object_type_to_string(databricks::ObjectType type) { + switch (type) { + case databricks::ObjectType::NOTEBOOK: return "NOTEBOOK"; + case databricks::ObjectType::DIRECTORY: return "DIRECTORY"; + case databricks::ObjectType::LIBRARY: return "LIBRARY"; + case databricks::ObjectType::FILE: return "FILE"; + case databricks::ObjectType::REPO: return "REPO"; + default: return "UNKNOWN"; + } +} + +// Helper function to print Language +std::string language_to_string(databricks::Language lang) { + switch (lang) { + case databricks::Language::PYTHON: return "PYTHON"; + case databricks::Language::SCALA: return "SCALA"; + case databricks::Language::SQL: return "SQL"; + case databricks::Language::R: return "R"; + default: return "UNKNOWN"; + } +} + +int main() { + try { + // Load configuration from environment + databricks::AuthConfig auth = databricks::AuthConfig::from_environment(); + + std::cout << "Connecting to: " << auth.host << std::endl; + std::cout << "======================================\n" << std::endl; + + // Create Workspace API client + databricks::Workspace workspace(auth); + + // Use the user's home directory - replace with your Databricks username + // Example: /Users/yourname@company.com + std::string base_path = "/Users"; + + std::cout << "NOTE: This example uses path: " << base_path << std::endl; + std::cout << "You may need to change this to your user directory.\n" << std::endl; + + // =================================================================== + // Example 1: List workspace objects in the /Users directory + // =================================================================== + std::cout << "1. Listing workspace objects in '" << base_path << "':" << std::endl; + std::cout << "---------------------------------------------------" << std::endl; + + auto objects = workspace.list(base_path, std::optional{}); + std::cout << "Found " << objects.size() << " objects:\n" << std::endl; + + for (size_t i = 0; i < std::min(objects.size(), size_t(5)); ++i) { + const auto& obj = objects[i]; + std::cout << " Path: " << obj.path << std::endl; + std::cout << " Type: " << object_type_to_string(obj.object_type) << std::endl; + std::cout << " Object ID: " << obj.object_id << std::endl; + if (obj.object_type == databricks::ObjectType::NOTEBOOK) { + std::cout << " Language: " << language_to_string(obj.language) << std::endl; + } + std::cout << std::endl; + } + + if (objects.size() > 5) { + std::cout << " ... and " << (objects.size() - 5) << " more objects\n" << std::endl; + } + + // Find a user directory to work with + std::string user_path; + for (const auto& obj : objects) { + if (obj.object_type == databricks::ObjectType::DIRECTORY) { + user_path = obj.path; + break; + } + } + + if (user_path.empty()) { + std::cout << "\nNo user directories found. Exiting example." << std::endl; + return 0; + } + + std::cout << "Using directory: " << user_path << std::endl; + + // =================================================================== + // Example 2: Create a new directory + // =================================================================== + std::cout << "\n2. Creating a new directory:" << std::endl; + std::cout << "----------------------------" << std::endl; + + std::string example_dir = user_path + "/sdk_example"; + std::cout << "Creating directory: " << example_dir << std::endl; + + workspace.mkdirs(example_dir); + std::cout << "Directory created successfully!\n" << std::endl; + + // =================================================================== + // Example 3: Get object status/metadata + // =================================================================== + std::cout << "\n3. Getting status of the created directory:" << std::endl; + std::cout << "-------------------------------------------" << std::endl; + + auto dir_info = workspace.get_status(example_dir); + std::cout << " Path: " << dir_info.path << std::endl; + std::cout << " Type: " << object_type_to_string(dir_info.object_type) << std::endl; + std::cout << " Object ID: " << dir_info.object_id << std::endl; + std::cout << std::endl; + + // =================================================================== + // Example 4: Import a simple Python notebook + // =================================================================== + std::cout << "\n4. Importing a Python notebook:" << std::endl; + std::cout << "--------------------------------" << std::endl; + + // Create a simple Python notebook content + std::string notebook_content = + "# Databricks notebook source\n" + "# MAGIC %md\n" + "# MAGIC # Example Notebook\n" + "# MAGIC This notebook was created using the Databricks C++ SDK\n" + "\n" + "# COMMAND ----------\n" + "\n" + "print(\"Hello from Databricks C++ SDK!\")\n" + "\n" + "# COMMAND ----------\n" + "\n" + "# Sample data processing\n" + "data = [1, 2, 3, 4, 5]\n" + "squared = [x**2 for x in data]\n" + "print(f\"Original: {data}\")\n" + "print(f\"Squared: {squared}\")\n"; + + // Base64 encode the content + std::string encoded_content = base64_encode(notebook_content); + + std::string notebook_path = example_dir + "/example_notebook"; + std::cout << "Importing notebook to: " << notebook_path << std::endl; + + workspace.import_file( + notebook_path, + encoded_content, + databricks::ImportFormat::SOURCE, + std::optional(databricks::Language::PYTHON), + true // overwrite if exists + ); + + std::cout << "Notebook imported successfully!\n" << std::endl; + + // =================================================================== + // Example 5: Export the notebook + // =================================================================== + std::cout << "\n5. Exporting the notebook:" << std::endl; + std::cout << "--------------------------" << std::endl; + + auto export_response = workspace.export_file( + notebook_path, + databricks::ExportFormat::SOURCE + ); + + std::cout << " Notebook exported successfully!" << std::endl; + std::cout << " File type: " << export_response.file_type << std::endl; + std::cout << " Content size: " << export_response.content.size() << " bytes (base64)" << std::endl; + std::cout << std::endl; + + // =================================================================== + // Example 6: List objects in our example directory + // =================================================================== + std::cout << "\n6. Listing objects in '" << example_dir << "':" << std::endl; + std::cout << "--------------------------------------------" << std::endl; + + auto example_objects = workspace.list(example_dir, std::nullopt); + std::cout << "Found " << example_objects.size() << " objects:\n" << std::endl; + + for (const auto& obj : example_objects) { + std::cout << " Path: " << obj.path << std::endl; + std::cout << " Type: " << object_type_to_string(obj.object_type) << std::endl; + if (obj.object_type == databricks::ObjectType::NOTEBOOK) { + std::cout << " Language: " << language_to_string(obj.language) << std::endl; + } + std::cout << std::endl; + } + + // =================================================================== + // Example 7: Cleanup - Delete objects + // =================================================================== + std::cout << "\n7. Cleaning up (deleting notebook and directory):" << std::endl; + std::cout << "--------------------------------------------------" << std::endl; + + std::cout << "Deleting notebook: " << notebook_path << std::endl; + workspace.delete_object(notebook_path, false); + std::cout << "Notebook deleted successfully!" << std::endl; + + std::cout << "Deleting directory: " << example_dir << std::endl; + workspace.delete_object(example_dir, true); // recursive delete + std::cout << "Directory deleted successfully!\n" << std::endl; + + std::cout << "\n======================================" << std::endl; + std::cout << "Workspace API example completed successfully!" << std::endl; + } catch (const std::exception& e) { + std::cerr << "Error: " << e.what() << std::endl; + return 1; + } + + return 0; +} diff --git a/include/databricks/workspace/workspace.h b/include/databricks/workspace/workspace.h new file mode 100644 index 0000000..e41e234 --- /dev/null +++ b/include/databricks/workspace/workspace.h @@ -0,0 +1,194 @@ +// Copyright (c) 2025 Calvin Min +// SPDX-License-Identifier: MIT +#pragma once + +#include "databricks/core/config.h" +#include "databricks/workspace/workspace_types.h" + +#include +#include +#include +#include + +namespace databricks { +namespace internal { +class IHttpClient; +} // namespace internal + +/** + * @brief Client for interacting with the Databricks Workspace API + * + * The Workspace API allows you to manage workspace objects such as notebooks, directories, + * and files in your Databricks workspace. You can list, import, export, delete, and create + * directories programmatically. This implementation uses Workspace API 2.0. + * + * Example usage: + * @code + * databricks::AuthConfig auth = databricks::AuthConfig::from_environment(); + * databricks::Workspace workspace(auth); + * + * // List contents of a directory + * auto objects = workspace.list("/Users/user@example.com"); + * for (const auto& obj : objects) { + * std::cout << obj.path << " (" << obj.object_type << ")" << std::endl; + * } + * + * // Create a directory + * workspace.mkdirs("/Users/user@example.com/my_project"); + * + * // Export a notebook + * auto exported = workspace.export_file("/Users/user@example.com/notebook", + * databricks::ExportFormat::SOURCE); + * + * // Get object status + * auto info = workspace.get_status("/Users/user@example.com/notebook"); + * + * // Delete an object + * workspace.delete_object("/Users/user@example.com/old_notebook", false); + * @endcode + */ +class Workspace { +public: + /** + * @brief Construct a Workspace API client + * @param auth Authentication configuration with host and token + * @param api_version Workspace API version to use (default: "2.0") + */ + explicit Workspace(const AuthConfig& auth, const std::string& api_version = "2.0"); + + /** + * @brief Construct a Workspace API client with dependency injection (for testing) + * @param http_client Injected HTTP client (use MockHttpClient for unit tests) + * @note This constructor is primarily for testing with mock HTTP clients + */ + explicit Workspace(std::shared_ptr http_client); + + /** + * @brief Destructor + */ + ~Workspace(); + + Workspace(const Workspace&) = delete; + Workspace& operator=(const Workspace&) = delete; + + // Directory operations + + /** + * @brief List the contents of a directory or get information about a single object + * + * @param path The absolute workspace path of the notebook or directory + * @param notebooks_modified_after Optional filter to only return notebooks modified after + * this Unix timestamp (in milliseconds) + * @return Vector of ObjectInfo representing the contents (empty if path is a file) + * @throws std::runtime_error if the path does not exist (RESOURCE_DOES_NOT_EXIST) + * + * @note If the path points to a notebook or file (not a directory), this returns + * a vector with a single ObjectInfo element describing that object. + */ + std::vector list(const std::string& path, + const std::optional& notebooks_modified_after = std::nullopt); + + /** + * @brief Create the specified directory and necessary parent directories if they don't exist + * + * @param path The absolute workspace path of the directory to create + * @throws std::runtime_error if there is an object (not a directory) at any prefix + * of the input path (RESOURCE_ALREADY_EXISTS) + * + * @note This operation is idempotent - if the directory already exists, it succeeds. + * If this operation fails, some parent directories may have been created. + */ + void mkdirs(const std::string& path); + + /** + * @brief Get the status/metadata of a workspace object or directory + * + * @param path The absolute workspace path of the notebook or directory + * @return ObjectInfo containing metadata about the object + * @throws std::runtime_error if the path does not exist (RESOURCE_DOES_NOT_EXIST) + */ + ObjectInfo get_status(const std::string& path); + + // Export operations + + /** + * @brief Export a notebook or directory from the workspace + * + * @param path The absolute workspace path of the object or directory to export + * @param format The format for the exported content (default: SOURCE) + * @return ExportResponse containing the base64-encoded exported content + * @throws std::runtime_error if the path does not exist (RESOURCE_DOES_NOT_EXIST) + * @throws std::runtime_error if the exported data exceeds size limit (MAX_NOTEBOOK_SIZE_EXCEEDED) + * + * @note Exporting a directory is only supported for DBC, SOURCE, and AUTO formats. + * The content field in the response is base64-encoded. + */ + ExportResponse export_file(const std::string& path, ExportFormat format = ExportFormat::SOURCE); + + // Import operations + + /** + * @brief Import a notebook or file into the workspace + * + * @param path The absolute workspace path where the object should be imported + * @param content Base64-encoded content to import + * @param format The format of the content being imported (default: AUTO) + * @param language The language of the notebook (required for SOURCE format with single files) + * @param overwrite Whether to overwrite the object if it already exists (default: false) + * @throws std::runtime_error if path already exists and overwrite is false + * (RESOURCE_ALREADY_EXISTS) + * + * @note To import a directory, use DBC or SOURCE format with language unset. + * To import a single file as SOURCE, you must set the language field. + */ + void import_file(const std::string& path, const std::string& content, ImportFormat format = ImportFormat::AUTO, + const std::optional& language = std::nullopt, bool overwrite = false); + + /** + * @brief Import a notebook or file into the workspace using an ImportRequest struct + * + * @param request ImportRequest containing all import parameters + * @throws std::runtime_error if path already exists and overwrite is false + * (RESOURCE_ALREADY_EXISTS) + * + * @note This is a convenience overload that accepts an ImportRequest struct + */ + void import_file(const ImportRequest& request); + + // Delete operations + + /** + * @brief Delete a workspace object or directory + * + * @param path The absolute workspace path of the notebook or directory to delete + * @param recursive Whether to recursively delete all objects in a directory (default: false) + * @throws std::runtime_error if the path does not exist (RESOURCE_DOES_NOT_EXIST) + * @throws std::runtime_error if path is a non-empty directory and recursive is false + * (DIRECTORY_NOT_EMPTY) + * + * @note Deleted objects do not go to the Trash folder and cannot be recovered. + * Use with caution! + */ + void delete_object(const std::string& path, bool recursive = false); + +private: + class Impl; + std::unique_ptr pimpl_; + + // Helper methods for enum conversions + std::string object_type_to_string(ObjectType type) const; + std::string export_format_to_string(ExportFormat format) const; + std::string import_format_to_string(ImportFormat format) const; + std::string language_to_string(Language language) const; + + static ObjectType string_to_object_type(const std::string& str); + static ExportFormat string_to_export_format(const std::string& str); + static ImportFormat string_to_import_format(const std::string& str); + static Language string_to_language(const std::string& str); + + // Helper methods for parsing API responses + static std::vector parse_list_response(const std::string& json_str); + static ObjectInfo parse_object_info(const std::string& json_str); +}; + +} // namespace databricks diff --git a/include/databricks/workspace/workspace_types.h b/include/databricks/workspace/workspace_types.h new file mode 100644 index 0000000..0e16d58 --- /dev/null +++ b/include/databricks/workspace/workspace_types.h @@ -0,0 +1,106 @@ +// Copyright (c) 2026 Calvin Min +// SPDX-License-Identifier: MIT +#pragma once + +#include +#include +#include + +namespace databricks { + +/** + * @brief Enumeration of workspace object types + */ +enum class ObjectType { + NOTEBOOK, ///< Jupyter/Databricks notebook + DIRECTORY, ///< Workspace directory/folder + LIBRARY, ///< Library (JAR, Python egg, etc.) + FILE, ///< Generic file + REPO, ///< Git repository + UNKNOWN ///< Unknown or unrecognized object type +}; + +/** + * @brief Enumeration of export formats for workspace objects + */ +enum class ExportFormat { + SOURCE, ///< Export as source code (default) + HTML, ///< Export as HTML file + JUPYTER, ///< Export as Jupyter/IPython Notebook (.ipynb) + DBC, ///< Export as Databricks archive format + R_MARKDOWN, ///< Export as R Markdown format + AUTO ///< Automatically detect format based on object type +}; + +/** + * @brief Enumeration of import formats for workspace objects + */ +enum class ImportFormat { + SOURCE, ///< Import as source code + HTML, ///< Import as HTML file + JUPYTER, ///< Import as Jupyter/IPython Notebook (.ipynb) + DBC, ///< Import as Databricks archive format + R_MARKDOWN, ///< Import as R Markdown format + AUTO ///< Automatically detect format (default) +}; + +/** + * @brief Enumeration of notebook programming languages + */ +enum class Language { + SCALA, ///< Scala programming language + PYTHON, ///< Python programming language + SQL, ///< SQL query language + R, ///< R programming language + UNKNOWN ///< Unknown or unrecognized language +}; + +/** + * @brief Represents metadata for a workspace object (file, notebook, or directory) + */ +struct ObjectInfo { + std::string path; ///< Absolute workspace path (e.g., "/Users/user@example.com/notebook") + ObjectType object_type = ObjectType::UNKNOWN; ///< Type of the object + uint64_t object_id = 0; ///< Unique numerical identifier for the object + Language language = Language::UNKNOWN; ///< Language (only set for NOTEBOOK type) + uint64_t size = 0; ///< Size in bytes (for files) + uint64_t created_at = 0; ///< Creation timestamp (Unix milliseconds) + uint64_t modified_at = 0; ///< Last modification timestamp (Unix milliseconds) + + /** + * @brief Parse an ObjectInfo from JSON string + * @param json_str JSON representation of a workspace object + * @return Parsed ObjectInfo object + * @throws std::runtime_error if parsing fails + */ + static ObjectInfo from_json(const std::string& json_str); +}; + +/** + * @brief Response from the export endpoint containing exported content + */ +struct ExportResponse { + std::string content; ///< Base64-encoded content of the exported object + std::string file_type; ///< File type indicator (e.g., "py", "scala", "sql") + + /** + * @brief Parse an ExportResponse from JSON string + * @param json_str JSON representation of an export response + * @return Parsed ExportResponse object + * @throws std::runtime_error if parsing fails + */ + static ExportResponse from_json(const std::string& json_str); +}; + +/** + * @brief Request parameters for importing a workspace object + */ +struct ImportRequest { + std::string path; ///< Absolute workspace path for the imported object + std::string content; ///< Base64-encoded content to import + ImportFormat format = ImportFormat::AUTO; ///< Format of the content being imported + Language language = Language::UNKNOWN; ///< Language (required for SOURCE format, single file) + bool overwrite = false; ///< Whether to overwrite existing object +}; + +} // namespace databricks diff --git a/src/workspace/workspace.cpp b/src/workspace/workspace.cpp new file mode 100644 index 0000000..cc2a3e9 --- /dev/null +++ b/src/workspace/workspace.cpp @@ -0,0 +1,396 @@ +// Copyright (c) 2026 Calvin Min +// SPDX-License-Identifier: MIT +#include "databricks/workspace/workspace.h" + +#include "../internal/http_client.h" +#include "../internal/http_client_interface.h" +#include "../internal/logger.h" + +#include +#include +#include + +#include + +using json = nlohmann::json; + +namespace databricks { + +// ==================== PIMPL IMPLEMENTATION ==================== + +class Workspace::Impl { +public: + // Constructor for production use (creates real HttpClient) + explicit Impl(const AuthConfig& auth, const std::string& api_version = "2.0") + : http_client_(std::make_shared(auth, api_version)) {} + + // Constructor for testing (accepts injected client) + explicit Impl(std::shared_ptr client) + : http_client_(std::move(client)) {} + + std::shared_ptr http_client_; +}; + +// ==================== CONSTRUCTORS & DESTRUCTOR ==================== + +Workspace::Workspace(const AuthConfig& auth, const std::string& api_version) + : pimpl_(std::make_unique(auth, api_version)) {} + +Workspace::Workspace(std::shared_ptr http_client) + : pimpl_(std::make_unique(std::move(http_client))) {} + +Workspace::~Workspace() = default; + +// ==================== PUBLIC API METHODS ==================== + +std::vector Workspace::list(const std::string& path, + const std::optional& notebooks_modified_after) { + internal::get_logger()->info("Listing workspace objects at path: " + path); + + // Build query parameters + std::string query_params = "?path=" + path; + if (notebooks_modified_after.has_value()) { + query_params += "¬ebooks_modified_after=" + std::to_string(notebooks_modified_after.value()); + } + + auto response = pimpl_->http_client_->get("/workspace/list" + query_params); + pimpl_->http_client_->check_response(response, "list"); + + internal::get_logger()->debug("Successfully retrieved workspace list"); + return parse_list_response(response.body); +} + +void Workspace::mkdirs(const std::string& path) { + internal::get_logger()->info("Creating workspace directory: " + path); + + // Build JSON body + json body_json; + body_json["path"] = path; + std::string body = body_json.dump(); + + internal::get_logger()->debug("Mkdirs request body: " + body); + + auto response = pimpl_->http_client_->post("/workspace/mkdirs", body); + pimpl_->http_client_->check_response(response, "mkdirs"); + + internal::get_logger()->info("Successfully created workspace directory: " + path); +} + +ObjectInfo Workspace::get_status(const std::string& path) { + internal::get_logger()->info("Getting status for workspace object: " + path); + + // Build query parameters + std::string query_params = "?path=" + path; + + auto response = pimpl_->http_client_->get("/workspace/get-status" + query_params); + pimpl_->http_client_->check_response(response, "getStatus"); + + internal::get_logger()->debug("Successfully retrieved object status"); + return parse_object_info(response.body); +} + +ExportResponse Workspace::export_file(const std::string& path, ExportFormat format) { + internal::get_logger()->info("Exporting workspace object: " + path); + + // Build query parameters + std::string query_params = "?path=" + path; + query_params += "&format=" + export_format_to_string(format); + + internal::get_logger()->debug("Export request for path=" + path + ", format=" + export_format_to_string(format)); + + auto response = pimpl_->http_client_->get("/workspace/export" + query_params); + pimpl_->http_client_->check_response(response, "export"); + + internal::get_logger()->info("Successfully exported workspace object: " + path); + return ExportResponse::from_json(response.body); +} + +void Workspace::import_file(const std::string& path, + const std::string& content, + ImportFormat format, + const std::optional& language, + bool overwrite) { + internal::get_logger()->info("Importing workspace object to path: " + path); + + // Build JSON body + json body_json; + body_json["path"] = path; + body_json["content"] = content; + body_json["format"] = import_format_to_string(format); + body_json["overwrite"] = overwrite; + + // Add language if specified (required for SOURCE format with single files) + if (language.has_value()) { + body_json["language"] = language_to_string(language.value()); + } + + std::string body = body_json.dump(); + internal::get_logger()->debug("Import request for path=" + path + ", format=" + import_format_to_string(format)); + + auto response = pimpl_->http_client_->post("/workspace/import", body); + pimpl_->http_client_->check_response(response, "import"); + + internal::get_logger()->info("Successfully imported workspace object: " + path); +} + +void Workspace::import_file(const ImportRequest& request) { + std::optional lang = std::nullopt; + if (request.language != Language::UNKNOWN) { + lang = request.language; + } + + import_file(request.path, request.content, request.format, lang, request.overwrite); +} + +void Workspace::delete_object(const std::string& path, bool recursive) { + internal::get_logger()->info("Deleting workspace object: " + path + " (recursive=" + (recursive ? "true" : "false") + ")"); + + // Build JSON body + json body_json; + body_json["path"] = path; + body_json["recursive"] = recursive; + std::string body = body_json.dump(); + + internal::get_logger()->debug("Delete request body: " + body); + + auto response = pimpl_->http_client_->post("/workspace/delete", body); + pimpl_->http_client_->check_response(response, "delete"); + + internal::get_logger()->info("Successfully deleted workspace object: " + path); +} + +// ==================== ENUM CONVERSION HELPERS ==================== + +std::string Workspace::object_type_to_string(ObjectType type) const { + static const std::unordered_map type_map = { + {ObjectType::NOTEBOOK, "NOTEBOOK"}, + {ObjectType::DIRECTORY, "DIRECTORY"}, + {ObjectType::LIBRARY, "LIBRARY"}, + {ObjectType::FILE, "FILE"}, + {ObjectType::REPO, "REPO"} + }; + + auto it = type_map.find(type); + if (it != type_map.end()) { + return it->second; + } + throw std::invalid_argument("Unknown ObjectType"); +} + +ObjectType Workspace::string_to_object_type(const std::string& str) { + static const std::unordered_map type_map = { + {"NOTEBOOK", ObjectType::NOTEBOOK}, + {"DIRECTORY", ObjectType::DIRECTORY}, + {"LIBRARY", ObjectType::LIBRARY}, + {"FILE", ObjectType::FILE}, + {"REPO", ObjectType::REPO} + }; + + auto it = type_map.find(str); + if (it != type_map.end()) { + return it->second; + } + return ObjectType::UNKNOWN; +} + +std::string Workspace::export_format_to_string(ExportFormat format) const { + static const std::unordered_map format_map = { + {ExportFormat::SOURCE, "SOURCE"}, + {ExportFormat::HTML, "HTML"}, + {ExportFormat::JUPYTER, "JUPYTER"}, + {ExportFormat::DBC, "DBC"}, + {ExportFormat::R_MARKDOWN, "R_MARKDOWN"}, + {ExportFormat::AUTO, "AUTO"} + }; + + auto it = format_map.find(format); + if (it != format_map.end()) { + return it->second; + } + throw std::invalid_argument("Unknown ExportFormat"); +} + +ExportFormat Workspace::string_to_export_format(const std::string& str) { + static const std::unordered_map format_map = { + {"SOURCE", ExportFormat::SOURCE}, + {"HTML", ExportFormat::HTML}, + {"JUPYTER", ExportFormat::JUPYTER}, + {"DBC", ExportFormat::DBC}, + {"R_MARKDOWN", ExportFormat::R_MARKDOWN}, + {"AUTO", ExportFormat::AUTO} + }; + + auto it = format_map.find(str); + if (it != format_map.end()) { + return it->second; + } + return ExportFormat::SOURCE; // Default +} + +std::string Workspace::import_format_to_string(ImportFormat format) const { + static const std::unordered_map format_map = { + {ImportFormat::SOURCE, "SOURCE"}, + {ImportFormat::HTML, "HTML"}, + {ImportFormat::JUPYTER, "JUPYTER"}, + {ImportFormat::DBC, "DBC"}, + {ImportFormat::R_MARKDOWN, "R_MARKDOWN"}, + {ImportFormat::AUTO, "AUTO"} + }; + + auto it = format_map.find(format); + if (it != format_map.end()) { + return it->second; + } + throw std::invalid_argument("Unknown ImportFormat"); +} + +ImportFormat Workspace::string_to_import_format(const std::string& str) { + static const std::unordered_map format_map = { + {"SOURCE", ImportFormat::SOURCE}, + {"HTML", ImportFormat::HTML}, + {"JUPYTER", ImportFormat::JUPYTER}, + {"DBC", ImportFormat::DBC}, + {"R_MARKDOWN", ImportFormat::R_MARKDOWN}, + {"AUTO", ImportFormat::AUTO} + }; + + auto it = format_map.find(str); + if (it != format_map.end()) { + return it->second; + } + return ImportFormat::AUTO; // Default +} + +std::string Workspace::language_to_string(Language language) const { + static const std::unordered_map lang_map = { + {Language::SCALA, "SCALA"}, + {Language::PYTHON, "PYTHON"}, + {Language::SQL, "SQL"}, + {Language::R, "R"} + }; + + auto it = lang_map.find(language); + if (it != lang_map.end()) { + return it->second; + } + throw std::invalid_argument("Unknown Language"); +} + +Language Workspace::string_to_language(const std::string& str) { + static const std::unordered_map lang_map = { + {"SCALA", Language::SCALA}, + {"PYTHON", Language::PYTHON}, + {"SQL", Language::SQL}, + {"R", Language::R} + }; + + auto it = lang_map.find(str); + if (it != lang_map.end()) { + return it->second; + } + return Language::UNKNOWN; +} + +// ==================== FROM_JSON IMPLEMENTATIONS ==================== + +ObjectInfo ObjectInfo::from_json(const std::string& json_str) { + try { + auto j = json::parse(json_str); + ObjectInfo info; + + info.path = j.value("path", ""); + info.object_id = j.value("object_id", uint64_t(0)); + + // Parse object_type enum + std::string type_str = j.value("object_type", ""); + if (type_str == "NOTEBOOK") { + info.object_type = ObjectType::NOTEBOOK; + } else if (type_str == "DIRECTORY") { + info.object_type = ObjectType::DIRECTORY; + } else if (type_str == "LIBRARY") { + info.object_type = ObjectType::LIBRARY; + } else if (type_str == "FILE") { + info.object_type = ObjectType::FILE; + } else if (type_str == "REPO") { + info.object_type = ObjectType::REPO; + } else { + info.object_type = ObjectType::UNKNOWN; + } + + // Parse language enum (only present for NOTEBOOK type) + if (j.contains("language")) { + std::string lang_str = j.value("language", ""); + if (lang_str == "SCALA") { + info.language = Language::SCALA; + } else if (lang_str == "PYTHON") { + info.language = Language::PYTHON; + } else if (lang_str == "SQL") { + info.language = Language::SQL; + } else if (lang_str == "R") { + info.language = Language::R; + } else { + info.language = Language::UNKNOWN; + } + } + + // Optional fields + info.size = j.value("size", uint64_t(0)); + info.created_at = j.value("created_at", uint64_t(0)); + info.modified_at = j.value("modified_at", uint64_t(0)); + + return info; + } catch (const json::exception& e) { + throw std::runtime_error("Failed to parse ObjectInfo JSON: " + std::string(e.what())); + } +} + +ExportResponse ExportResponse::from_json(const std::string& json_str) { + try { + auto j = json::parse(json_str); + ExportResponse response; + + response.content = j.value("content", ""); + response.file_type = j.value("file_type", ""); + + return response; + } catch (const json::exception& e) { + throw std::runtime_error("Failed to parse ExportResponse JSON: " + std::string(e.what())); + } +} + +// ==================== PRIVATE HELPER METHODS ==================== + +std::vector Workspace::parse_list_response(const std::string& json_str) { + std::vector objects; + + try { + auto j = json::parse(json_str); + + if (!j.contains("objects") || !j["objects"].is_array()) { + internal::get_logger()->warn("No objects array found in list response"); + return objects; + } + + for (const auto& obj_json : j["objects"]) { + objects.push_back(ObjectInfo::from_json(obj_json.dump())); + } + + internal::get_logger()->info("Parsed " + std::to_string(objects.size()) + " workspace objects"); + } catch (const json::exception& e) { + internal::get_logger()->error("Failed to parse list response: " + std::string(e.what())); + throw std::runtime_error("Failed to parse list response: " + std::string(e.what())); + } + + return objects; +} + +ObjectInfo Workspace::parse_object_info(const std::string& json_str) { + try { + return ObjectInfo::from_json(json_str); + } catch (const std::runtime_error& e) { + internal::get_logger()->error("Failed to parse object info: " + std::string(e.what())); + throw; + } +} + +} // namespace databricks From 4a475261643c42119da935a40af1d8c2d93c2204 Mon Sep 17 00:00:00 2001 From: Calvin Min Date: Tue, 6 Jan 2026 16:00:30 -0500 Subject: [PATCH 2/7] unit tests: list workspace objects --- tests/unit/workspace/test_workspace.cpp | 129 ++++++++++++++++++++++++ 1 file changed, 129 insertions(+) create mode 100644 tests/unit/workspace/test_workspace.cpp diff --git a/tests/unit/workspace/test_workspace.cpp b/tests/unit/workspace/test_workspace.cpp new file mode 100644 index 0000000..5d60f73 --- /dev/null +++ b/tests/unit/workspace/test_workspace.cpp @@ -0,0 +1,129 @@ +// Copyright (c) 2026 Calvin Min +// SPDX-License-Identifier: MIT +#include "../../mocks/mock_http_client.h" + +#include +#include +#include + +using databricks::test::MockHttpClient; +using ::testing::_; +using ::testing::HasSubstr; +using ::testing::NiceMock; +using ::testing::Return; +using ::testing::Throw; + +// ============================================================================ +// Test Fixtures +// ============================================================================ + +// Constructor Tests +class WorkspaceTest : public ::testing::Test { +protected: + databricks::AuthConfig auth; + + void SetUp() override { + auth.host = "https://test.databricks.com"; + auth.set_token("test_token"); + auth.timeout_seconds = 30; + } +}; + +// Workspace API Mocks +class WorkspaceApiTest : public ::testing::Test { +protected: + void SetUp() override { mock_client_ = std::make_shared>(); } + + std::shared_ptr> mock_client_; +}; + +// ============================================================================ +// Constructor Tests +// ============================================================================ +TEST_F(WorkspaceTest, ConstructorCreatesValidClient) { + ASSERT_NO_THROW({databricks::Workspace workspace(auth);}); +} + +TEST_F(WorkspaceTest, ConstructorCreatesValidClientWithManualApiVersion) { + const std::string& api_version = "1.0"; + ASSERT_NO_THROW({ + databricks::Workspace workspace(auth, api_version); + }); +} + +TEST_F(WorkspaceTest, MultipleValidWorkspaceClient) { + databricks::AuthConfig mockAuth; + mockAuth.host = "https://workspace1.databricks.com"; + mockAuth.set_token("token1"); + + ASSERT_NO_THROW({ + databricks::Workspace workspace1(auth); + databricks::Workspace workspace2(mockAuth); + }); +} + +// ============================================================================ +// Workspace API Test +// ============================================================================ + +// Test: List Workspace Objects +TEST_F(WorkspaceApiTest, SuccessfulListWorkspaceObjects) { + std::string mock_list_response = R"({ + "objects": [ + { + "path": "/test/path/notebook1", + "object_type": "NOTEBOOK", + "object_id": 12345, + "language": "PYTHON", + "size": 1024, + "created_at": 1609459200000, + "modified_at": 1609545600000 + }, + { + "path": "/test/path/directory1", + "object_type": "DIRECTORY", + "object_id": 67890 + } + ] + })"; + + EXPECT_CALL(*mock_client_, get("/workspace/list?path=/test/path")) + .WillOnce(Return(MockHttpClient::success_response(mock_list_response))); + + databricks::Workspace workspace(mock_client_); + const std::string& mock_path = "/test/path"; + auto response = workspace.list(mock_path); + + // Verify we got 2 objects back + ASSERT_EQ(response.size(), 2); + + // Verify first object (notebook) + EXPECT_EQ(response[0].path, "/test/path/notebook1"); + EXPECT_EQ(response[0].object_type, databricks::ObjectType::NOTEBOOK); + EXPECT_EQ(response[0].object_id, 12345); + EXPECT_EQ(response[0].language, databricks::Language::PYTHON); + EXPECT_EQ(response[0].size, 1024); + EXPECT_EQ(response[0].created_at, 1609459200000); + EXPECT_EQ(response[0].modified_at, 1609545600000); + + // Verify second object (directory) + EXPECT_EQ(response[1].path, "/test/path/directory1"); + EXPECT_EQ(response[1].object_type, databricks::ObjectType::DIRECTORY); + EXPECT_EQ(response[1].object_id, 67890); +} + +TEST_F(WorkspaceApiTest, SuccessfulListEmptyWorkspaceObjects) { + const std::string& mock_empty_list_response = R"({ + "objects": [] + })"; + + EXPECT_CALL(*mock_client_, get("/workspace/list?path=/test/path")) + .WillOnce(Return(MockHttpClient::success_response(mock_empty_list_response))); + + databricks::Workspace workspace(mock_client_); + const std::string& mock_path = "/test/path"; + auto response = workspace.list(mock_path); + + // Verify we got 0 objects back + ASSERT_EQ(response.size(), 0); +} \ No newline at end of file From 3cac6b426e80ee127c5e9f844f213dc73afafeec Mon Sep 17 00:00:00 2001 From: Calvin Min Date: Tue, 6 Jan 2026 16:57:56 -0500 Subject: [PATCH 3/7] additional unit tests w/ exception handling in core workspace code --- src/workspace/workspace.cpp | 30 +++++++++++ tests/unit/workspace/test_workspace.cpp | 69 +++++++++++++++++++++++-- 2 files changed, 96 insertions(+), 3 deletions(-) diff --git a/src/workspace/workspace.cpp b/src/workspace/workspace.cpp index cc2a3e9..a44ac9e 100644 --- a/src/workspace/workspace.cpp +++ b/src/workspace/workspace.cpp @@ -47,6 +47,11 @@ std::vector Workspace::list(const std::string& path, const std::optional& notebooks_modified_after) { internal::get_logger()->info("Listing workspace objects at path: " + path); + // Throw exception if path is empty + if (path.length() == 0) { + throw std::invalid_argument("Path cannot be empty"); + } + // Build query parameters std::string query_params = "?path=" + path; if (notebooks_modified_after.has_value()) { @@ -63,6 +68,11 @@ std::vector Workspace::list(const std::string& path, void Workspace::mkdirs(const std::string& path) { internal::get_logger()->info("Creating workspace directory: " + path); + // Throw exception if path is empty + if (path.length() == 0) { + throw std::invalid_argument("Path cannot be empty"); + } + // Build JSON body json body_json; body_json["path"] = path; @@ -79,6 +89,11 @@ void Workspace::mkdirs(const std::string& path) { ObjectInfo Workspace::get_status(const std::string& path) { internal::get_logger()->info("Getting status for workspace object: " + path); + // Throw exception if path is empty + if (path.length() == 0) { + throw std::invalid_argument("Path cannot be empty"); + } + // Build query parameters std::string query_params = "?path=" + path; @@ -92,6 +107,11 @@ ObjectInfo Workspace::get_status(const std::string& path) { ExportResponse Workspace::export_file(const std::string& path, ExportFormat format) { internal::get_logger()->info("Exporting workspace object: " + path); + // Throw exception if path is empty + if (path.length() == 0) { + throw std::invalid_argument("Path cannot be empty"); + } + // Build query parameters std::string query_params = "?path=" + path; query_params += "&format=" + export_format_to_string(format); @@ -112,6 +132,11 @@ void Workspace::import_file(const std::string& path, bool overwrite) { internal::get_logger()->info("Importing workspace object to path: " + path); + // Throw exception for invalid arguments + if (path.length() == 0 || content.length() == 0 ) { + throw std::invalid_argument("Path or Content Input cannot be empty"); + } + // Build JSON body json body_json; body_json["path"] = path; @@ -145,6 +170,11 @@ void Workspace::import_file(const ImportRequest& request) { void Workspace::delete_object(const std::string& path, bool recursive) { internal::get_logger()->info("Deleting workspace object: " + path + " (recursive=" + (recursive ? "true" : "false") + ")"); + // Throw exception if path is empty + if (path.length() == 0) { + throw std::invalid_argument("Path cannot be empty"); + } + // Build JSON body json body_json; body_json["path"] = path; diff --git a/tests/unit/workspace/test_workspace.cpp b/tests/unit/workspace/test_workspace.cpp index 5d60f73..a79dda0 100644 --- a/tests/unit/workspace/test_workspace.cpp +++ b/tests/unit/workspace/test_workspace.cpp @@ -9,6 +9,7 @@ using databricks::test::MockHttpClient; using ::testing::_; using ::testing::HasSubstr; +using ::testing::Eq; using ::testing::NiceMock; using ::testing::Return; using ::testing::Throw; @@ -67,7 +68,7 @@ TEST_F(WorkspaceTest, MultipleValidWorkspaceClient) { // ============================================================================ // Test: List Workspace Objects -TEST_F(WorkspaceApiTest, SuccessfulListWorkspaceObjects) { +TEST_F(WorkspaceApiTest, ListWorkspaceObjectsSuccess) { std::string mock_list_response = R"({ "objects": [ { @@ -112,7 +113,8 @@ TEST_F(WorkspaceApiTest, SuccessfulListWorkspaceObjects) { EXPECT_EQ(response[1].object_id, 67890); } -TEST_F(WorkspaceApiTest, SuccessfulListEmptyWorkspaceObjects) { +// Test: List Empty Workspace Objects +TEST_F(WorkspaceApiTest, ListEmptyWorkspaceObjectsSuccess) { const std::string& mock_empty_list_response = R"({ "objects": [] })"; @@ -126,4 +128,65 @@ TEST_F(WorkspaceApiTest, SuccessfulListEmptyWorkspaceObjects) { // Verify we got 0 objects back ASSERT_EQ(response.size(), 0); -} \ No newline at end of file +} + +// Test: Fail to list Workspace Objects with an empty path +TEST_F(WorkspaceApiTest, ListWorkspaceObjectThrowsInvalidArgument) { + databricks::Workspace workspace(mock_client_); + const std::string& empty_path = ""; + + EXPECT_THROW(workspace.list(empty_path), std::invalid_argument); +} + +// Test: Create a Mock Directory +TEST_F(WorkspaceApiTest, CreateDirectorySuccess) { + EXPECT_CALL(*mock_client_, post("/workspace/mkdirs", Eq(R"({"path":"/test/path"})"))) + .WillOnce(Return(MockHttpClient::success_response(""))); + + databricks::Workspace workspace(mock_client_); + const std::string& mock_path = "/test/path"; + workspace.mkdirs(mock_path); +} + +// Test: Fail to create an empty Mock Directory +TEST_F(WorkspaceApiTest, CreateEmptyDirectoryThrowsInvalidArgument) { + databricks::Workspace workspace(mock_client_); + const std::string& empty_path = ""; + + EXPECT_THROW(workspace.mkdirs(empty_path), std::invalid_argument); +} + +// Test: Get Status for a Workspace object +TEST_F(WorkspaceApiTest, GetStatusSuccess) { + std::string mock_status_response = R"({ + "path": "/test/notebook", + "object_type": "NOTEBOOK", + "object_id": 12345, + "language": "PYTHON", + "size": 2048, + "created_at": 1609459200000, + "modified_at": 1609545600000 + })"; + + EXPECT_CALL(*mock_client_, get("/workspace/get-status?path=/test/notebook")) + .WillOnce(Return(MockHttpClient::success_response(mock_status_response))); + + databricks::Workspace workspace(mock_client_); + const std::string& mock_path = "/test/notebook"; + auto response = workspace.get_status(mock_path); + + // Verify object status + EXPECT_EQ(response.path, "/test/notebook"); + EXPECT_EQ(response.object_type, databricks::ObjectType::NOTEBOOK); + EXPECT_EQ(response.object_id, 12345); + EXPECT_EQ(response.language, databricks::Language::PYTHON); + EXPECT_EQ(response.size, 2048); +} + +// Test: Fail to get status for an empty path Workspace object +TEST_F(WorkspaceApiTest, GetStatusThrowsInvalidArgument) { + databricks::Workspace workspace(mock_client_); + const std::string& empty_path = ""; + + EXPECT_THROW(workspace.get_status(empty_path), std::invalid_argument); +} From 1bd1cdb670559317e7cbef488c3c57168bf3ddfa Mon Sep 17 00:00:00 2001 From: Calvin Min Date: Tue, 6 Jan 2026 17:18:09 -0500 Subject: [PATCH 4/7] url encoded query parameters --- CMakeLists.txt | 2 ++ src/compute/compute.cpp | 6 +++++- src/internal/url_utils.cpp | 32 +++++++++++++++++++++++++++++ src/internal/url_utils.h | 31 ++++++++++++++++++++++++++++ src/jobs/jobs.cpp | 5 +++-- src/secrets/secrets.cpp | 5 +++-- src/unity_catalog/unity_catalog.cpp | 14 +++++++------ src/workspace/workspace.cpp | 13 ++++++------ 8 files changed, 91 insertions(+), 17 deletions(-) create mode 100644 src/internal/url_utils.cpp create mode 100644 src/internal/url_utils.h diff --git a/CMakeLists.txt b/CMakeLists.txt index ca718d2..afab7b0 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -128,6 +128,7 @@ set(SOURCES src/internal/pool_manager.cpp src/internal/logger.cpp src/internal/http_client.cpp + src/internal/url_utils.cpp ) set(HEADERS @@ -152,6 +153,7 @@ set(INTERNAL_HEADERS src/internal/pool_manager.h src/internal/logger.h src/internal/http_client.h + src/internal/url_utils.h ) # Create library target diff --git a/src/compute/compute.cpp b/src/compute/compute.cpp index 199d930..65b03e9 100644 --- a/src/compute/compute.cpp +++ b/src/compute/compute.cpp @@ -5,6 +5,7 @@ #include "../internal/http_client.h" #include "../internal/http_client_interface.h" #include "../internal/logger.h" +#include "../internal/url_utils.h" #include @@ -73,8 +74,11 @@ bool Compute::create_compute(const Cluster& cluster_config) { Cluster Compute::get_compute(const std::string& cluster_id) { internal::get_logger()->info("Getting compute cluster details for cluster_id=" + cluster_id); + // URL-encode the cluster_id to prevent injection + std::string encoded_id = internal::url_encode(cluster_id); + // Make API request with cluster_id as query parameter - auto response = pimpl_->http_client_->get("/clusters/get?cluster_id=" + cluster_id); + auto response = pimpl_->http_client_->get("/clusters/get?cluster_id=" + encoded_id); pimpl_->http_client_->check_response(response, "getCompute"); internal::get_logger()->debug("Compute cluster details response: " + response.body); diff --git a/src/internal/url_utils.cpp b/src/internal/url_utils.cpp new file mode 100644 index 0000000..cc64493 --- /dev/null +++ b/src/internal/url_utils.cpp @@ -0,0 +1,32 @@ +// Copyright (c) 2025 Calvin Min +// SPDX-License-Identifier: MIT +#include "url_utils.h" + +#include + +#include + +namespace databricks { +namespace internal { + +std::string url_encode(const std::string& str) { + CURL* curl = curl_easy_init(); + if (!curl) { + throw std::runtime_error("Failed to initialize CURL for URL encoding"); + } + + char* encoded = curl_easy_escape(curl, str.c_str(), static_cast(str.length())); + if (!encoded) { + curl_easy_cleanup(curl); + throw std::runtime_error("Failed to URL encode string"); + } + + std::string result(encoded); + curl_free(encoded); + curl_easy_cleanup(curl); + + return result; +} + +} // namespace internal +} // namespace databricks diff --git a/src/internal/url_utils.h b/src/internal/url_utils.h new file mode 100644 index 0000000..0b3f065 --- /dev/null +++ b/src/internal/url_utils.h @@ -0,0 +1,31 @@ +// Copyright (c) 2025 Calvin Min +// SPDX-License-Identifier: MIT +#pragma once + +#include + +namespace databricks { +namespace internal { + +/** + * @brief URL-encode a string to safely include in query parameters + * + * Encodes special characters in a string to make it safe for use in URLs. + * Uses libcurl's curl_easy_escape function for RFC 3986 compliant encoding. + * + * @param str String to encode + * @return std::string URL-encoded string + * + * @throws std::runtime_error if CURL initialization or encoding fails + * + * @example + * std::string encoded = url_encode("hello world"); + * // Returns: "hello%20world" + * + * std::string safe = url_encode("cluster&id=123"); + * // Returns: "cluster%26id%3D123" + */ +std::string url_encode(const std::string& str); + +} // namespace internal +} // namespace databricks diff --git a/src/jobs/jobs.cpp b/src/jobs/jobs.cpp index 4381ac7..a07d214 100644 --- a/src/jobs/jobs.cpp +++ b/src/jobs/jobs.cpp @@ -5,6 +5,7 @@ #include "../internal/http_client.h" #include "../internal/http_client_interface.h" #include "../internal/logger.h" +#include "../internal/url_utils.h" #include #include @@ -32,7 +33,7 @@ class Jobs::Impl { // ============================================================================ namespace { -// Build query string from parameters +// Build query string from parameters with URL encoding std::string build_query_string(const std::map& params) { if (params.empty()) { return ""; @@ -45,7 +46,7 @@ std::string build_query_string(const std::map& params) if (!first) { oss << "&"; } - oss << key << "=" << value; + oss << key << "=" << internal::url_encode(value); first = false; } return oss.str(); diff --git a/src/secrets/secrets.cpp b/src/secrets/secrets.cpp index 34f2414..a152b86 100644 --- a/src/secrets/secrets.cpp +++ b/src/secrets/secrets.cpp @@ -5,6 +5,7 @@ #include "../internal/http_client.h" #include "../internal/http_client_interface.h" #include "../internal/logger.h" +#include "../internal/url_utils.h" #include @@ -144,8 +145,8 @@ void Secrets::delete_secret(const std::string& scope, const std::string& key) { std::vector Secrets::list_secrets(const std::string& scope) { internal::get_logger()->info("Listing secrets in scope: " + scope); - // Make GET request with scope as query parameter - auto response = pimpl_->http_client_->get("/secrets/list?scope=" + scope); + // Make GET request with scope as query parameter (URL-encoded) + auto response = pimpl_->http_client_->get("/secrets/list?scope=" + internal::url_encode(scope)); pimpl_->http_client_->check_response(response, "listSecrets"); internal::get_logger()->debug("Successfully retrieved secrets list"); diff --git a/src/unity_catalog/unity_catalog.cpp b/src/unity_catalog/unity_catalog.cpp index adc02cf..516c3a8 100644 --- a/src/unity_catalog/unity_catalog.cpp +++ b/src/unity_catalog/unity_catalog.cpp @@ -5,6 +5,7 @@ #include "../internal/http_client.h" #include "../internal/http_client_interface.h" #include "../internal/logger.h" +#include "../internal/url_utils.h" #include @@ -51,7 +52,7 @@ std::vector UnityCatalog::list_catalogs() { CatalogInfo UnityCatalog::get_catalog(const std::string& catalog_name) { internal::get_logger()->info("Getting catalog details for catalog=" + catalog_name); - auto response = pimpl_->http_client_->get("/unity-catalog/catalogs/" + catalog_name); + auto response = pimpl_->http_client_->get("/unity-catalog/catalogs/" + internal::url_encode(catalog_name)); pimpl_->http_client_->check_response(response, "getCatalog"); internal::get_logger()->debug("Catalog details response: " + response.body); @@ -109,7 +110,7 @@ bool UnityCatalog::delete_catalog(const std::string& catalog_name, bool force) { std::vector UnityCatalog::list_schemas(const std::string& catalog_name) { internal::get_logger()->info("Listing schemas in catalog: " + catalog_name); - auto response = pimpl_->http_client_->get("/unity-catalog/schemas?catalog_name=" + catalog_name); + auto response = pimpl_->http_client_->get("/unity-catalog/schemas?catalog_name=" + internal::url_encode(catalog_name)); pimpl_->http_client_->check_response(response, "listSchemas"); internal::get_logger()->debug("Schemas list response: " + response.body); @@ -119,7 +120,7 @@ std::vector UnityCatalog::list_schemas(const std::string& catalog_na SchemaInfo UnityCatalog::get_schema(const std::string& full_name) { internal::get_logger()->info("Getting schema details for: " + full_name); - auto response = pimpl_->http_client_->get("/unity-catalog/schemas/" + full_name); + auto response = pimpl_->http_client_->get("/unity-catalog/schemas/" + internal::url_encode(full_name)); pimpl_->http_client_->check_response(response, "getSchema"); internal::get_logger()->debug("Schema details response: " + response.body); @@ -169,8 +170,9 @@ bool UnityCatalog::delete_schema(const std::string& full_name) { std::vector UnityCatalog::list_tables(const std::string& catalog_name, const std::string& schema_name) { internal::get_logger()->info("Listing tables in " + catalog_name + "." + schema_name); - // Create Endpoint with Catalog and Schema name - std::string endpoint = "/unity-catalog/tables?catalog_name=" + catalog_name + "&schema_name=" + schema_name; + // Create Endpoint with Catalog and Schema name (URL-encoded) + std::string endpoint = "/unity-catalog/tables?catalog_name=" + internal::url_encode(catalog_name) + + "&schema_name=" + internal::url_encode(schema_name); auto response = pimpl_->http_client_->get(endpoint); pimpl_->http_client_->check_response(response, "listTables"); @@ -181,7 +183,7 @@ std::vector UnityCatalog::list_tables(const std::string& catalog_name TableInfo UnityCatalog::get_table(const std::string& full_name) { internal::get_logger()->info("Getting table details for: " + full_name); - auto response = pimpl_->http_client_->get("/unity-catalog/tables/" + full_name); + auto response = pimpl_->http_client_->get("/unity-catalog/tables/" + internal::url_encode(full_name)); pimpl_->http_client_->check_response(response, "getTable"); internal::get_logger()->debug("Table details response: " + response.body); diff --git a/src/workspace/workspace.cpp b/src/workspace/workspace.cpp index a44ac9e..6235b61 100644 --- a/src/workspace/workspace.cpp +++ b/src/workspace/workspace.cpp @@ -5,6 +5,7 @@ #include "../internal/http_client.h" #include "../internal/http_client_interface.h" #include "../internal/logger.h" +#include "../internal/url_utils.h" #include #include @@ -52,8 +53,8 @@ std::vector Workspace::list(const std::string& path, throw std::invalid_argument("Path cannot be empty"); } - // Build query parameters - std::string query_params = "?path=" + path; + // Build query parameters with URL encoding + std::string query_params = "?path=" + internal::url_encode(path); if (notebooks_modified_after.has_value()) { query_params += "¬ebooks_modified_after=" + std::to_string(notebooks_modified_after.value()); } @@ -94,8 +95,8 @@ ObjectInfo Workspace::get_status(const std::string& path) { throw std::invalid_argument("Path cannot be empty"); } - // Build query parameters - std::string query_params = "?path=" + path; + // Build query parameters with URL encoding + std::string query_params = "?path=" + internal::url_encode(path); auto response = pimpl_->http_client_->get("/workspace/get-status" + query_params); pimpl_->http_client_->check_response(response, "getStatus"); @@ -112,8 +113,8 @@ ExportResponse Workspace::export_file(const std::string& path, ExportFormat form throw std::invalid_argument("Path cannot be empty"); } - // Build query parameters - std::string query_params = "?path=" + path; + // Build query parameters with URL encoding + std::string query_params = "?path=" + internal::url_encode(path); query_params += "&format=" + export_format_to_string(format); internal::get_logger()->debug("Export request for path=" + path + ", format=" + export_format_to_string(format)); From a2ac4a51ff2d20082b8ead72f390a3a851377cc8 Mon Sep 17 00:00:00 2001 From: Calvin Min Date: Tue, 6 Jan 2026 17:23:29 -0500 Subject: [PATCH 5/7] unit test changes: url encoded values --- tests/unit/workspace/test_workspace.cpp | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/tests/unit/workspace/test_workspace.cpp b/tests/unit/workspace/test_workspace.cpp index a79dda0..db97201 100644 --- a/tests/unit/workspace/test_workspace.cpp +++ b/tests/unit/workspace/test_workspace.cpp @@ -88,7 +88,7 @@ TEST_F(WorkspaceApiTest, ListWorkspaceObjectsSuccess) { ] })"; - EXPECT_CALL(*mock_client_, get("/workspace/list?path=/test/path")) + EXPECT_CALL(*mock_client_, get("/workspace/list?path=%2Ftest%2Fpath")) .WillOnce(Return(MockHttpClient::success_response(mock_list_response))); databricks::Workspace workspace(mock_client_); @@ -119,7 +119,7 @@ TEST_F(WorkspaceApiTest, ListEmptyWorkspaceObjectsSuccess) { "objects": [] })"; - EXPECT_CALL(*mock_client_, get("/workspace/list?path=/test/path")) + EXPECT_CALL(*mock_client_, get("/workspace/list?path=%2Ftest%2Fpath")) .WillOnce(Return(MockHttpClient::success_response(mock_empty_list_response))); databricks::Workspace workspace(mock_client_); @@ -168,7 +168,7 @@ TEST_F(WorkspaceApiTest, GetStatusSuccess) { "modified_at": 1609545600000 })"; - EXPECT_CALL(*mock_client_, get("/workspace/get-status?path=/test/notebook")) + EXPECT_CALL(*mock_client_, get("/workspace/get-status?path=%2Ftest%2Fnotebook")) .WillOnce(Return(MockHttpClient::success_response(mock_status_response))); databricks::Workspace workspace(mock_client_); From 873ee335db96c852696469983ca923edb5ff2ab5 Mon Sep 17 00:00:00 2001 From: Calvin Min Date: Fri, 9 Jan 2026 14:29:59 -0500 Subject: [PATCH 6/7] make format changes --- examples/workspace_example.cpp | 91 ++++++++++--------- include/databricks/internal/secure_string.h | 5 +- .../databricks/workspace/workspace_types.h | 26 +++--- src/unity_catalog/unity_catalog.cpp | 5 +- src/workspace/workspace.cpp | 90 ++++++------------ tests/unit/workspace/test_workspace.cpp | 10 +- 6 files changed, 99 insertions(+), 128 deletions(-) diff --git a/examples/workspace_example.cpp b/examples/workspace_example.cpp index 7ef5acb..00901c4 100644 --- a/examples/workspace_example.cpp +++ b/examples/workspace_example.cpp @@ -17,17 +17,16 @@ #include "databricks/workspace/workspace.h" #include +#include #include #include #include -#include // Helper function to encode string to base64 std::string base64_encode(const std::string& input) { - static const char* base64_chars = - "ABCDEFGHIJKLMNOPQRSTUVWXYZ" - "abcdefghijklmnopqrstuvwxyz" - "0123456789+/"; + static const char* base64_chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZ" + "abcdefghijklmnopqrstuvwxyz" + "0123456789+/"; std::string encoded; int val = 0; @@ -56,23 +55,34 @@ std::string base64_encode(const std::string& input) { // Helper function to print ObjectType std::string object_type_to_string(databricks::ObjectType type) { switch (type) { - case databricks::ObjectType::NOTEBOOK: return "NOTEBOOK"; - case databricks::ObjectType::DIRECTORY: return "DIRECTORY"; - case databricks::ObjectType::LIBRARY: return "LIBRARY"; - case databricks::ObjectType::FILE: return "FILE"; - case databricks::ObjectType::REPO: return "REPO"; - default: return "UNKNOWN"; + case databricks::ObjectType::NOTEBOOK: + return "NOTEBOOK"; + case databricks::ObjectType::DIRECTORY: + return "DIRECTORY"; + case databricks::ObjectType::LIBRARY: + return "LIBRARY"; + case databricks::ObjectType::FILE: + return "FILE"; + case databricks::ObjectType::REPO: + return "REPO"; + default: + return "UNKNOWN"; } } // Helper function to print Language std::string language_to_string(databricks::Language lang) { switch (lang) { - case databricks::Language::PYTHON: return "PYTHON"; - case databricks::Language::SCALA: return "SCALA"; - case databricks::Language::SQL: return "SQL"; - case databricks::Language::R: return "R"; - default: return "UNKNOWN"; + case databricks::Language::PYTHON: + return "PYTHON"; + case databricks::Language::SCALA: + return "SCALA"; + case databricks::Language::SQL: + return "SQL"; + case databricks::Language::R: + return "R"; + default: + return "UNKNOWN"; } } @@ -165,23 +175,22 @@ int main() { std::cout << "--------------------------------" << std::endl; // Create a simple Python notebook content - std::string notebook_content = - "# Databricks notebook source\n" - "# MAGIC %md\n" - "# MAGIC # Example Notebook\n" - "# MAGIC This notebook was created using the Databricks C++ SDK\n" - "\n" - "# COMMAND ----------\n" - "\n" - "print(\"Hello from Databricks C++ SDK!\")\n" - "\n" - "# COMMAND ----------\n" - "\n" - "# Sample data processing\n" - "data = [1, 2, 3, 4, 5]\n" - "squared = [x**2 for x in data]\n" - "print(f\"Original: {data}\")\n" - "print(f\"Squared: {squared}\")\n"; + std::string notebook_content = "# Databricks notebook source\n" + "# MAGIC %md\n" + "# MAGIC # Example Notebook\n" + "# MAGIC This notebook was created using the Databricks C++ SDK\n" + "\n" + "# COMMAND ----------\n" + "\n" + "print(\"Hello from Databricks C++ SDK!\")\n" + "\n" + "# COMMAND ----------\n" + "\n" + "# Sample data processing\n" + "data = [1, 2, 3, 4, 5]\n" + "squared = [x**2 for x in data]\n" + "print(f\"Original: {data}\")\n" + "print(f\"Squared: {squared}\")\n"; // Base64 encode the content std::string encoded_content = base64_encode(notebook_content); @@ -189,12 +198,9 @@ int main() { std::string notebook_path = example_dir + "/example_notebook"; std::cout << "Importing notebook to: " << notebook_path << std::endl; - workspace.import_file( - notebook_path, - encoded_content, - databricks::ImportFormat::SOURCE, - std::optional(databricks::Language::PYTHON), - true // overwrite if exists + workspace.import_file(notebook_path, encoded_content, databricks::ImportFormat::SOURCE, + std::optional(databricks::Language::PYTHON), + true // overwrite if exists ); std::cout << "Notebook imported successfully!\n" << std::endl; @@ -205,10 +211,7 @@ int main() { std::cout << "\n5. Exporting the notebook:" << std::endl; std::cout << "--------------------------" << std::endl; - auto export_response = workspace.export_file( - notebook_path, - databricks::ExportFormat::SOURCE - ); + auto export_response = workspace.export_file(notebook_path, databricks::ExportFormat::SOURCE); std::cout << " Notebook exported successfully!" << std::endl; std::cout << " File type: " << export_response.file_type << std::endl; @@ -244,7 +247,7 @@ int main() { std::cout << "Notebook deleted successfully!" << std::endl; std::cout << "Deleting directory: " << example_dir << std::endl; - workspace.delete_object(example_dir, true); // recursive delete + workspace.delete_object(example_dir, true); // recursive delete std::cout << "Directory deleted successfully!\n" << std::endl; std::cout << "\n======================================" << std::endl; diff --git a/include/databricks/internal/secure_string.h b/include/databricks/internal/secure_string.h index d9d2f8b..3f0fc46 100644 --- a/include/databricks/internal/secure_string.h +++ b/include/databricks/internal/secure_string.h @@ -6,6 +6,7 @@ #include #include #include + #include namespace databricks { @@ -89,9 +90,7 @@ template class SecureAllocator { /** * @brief Unlock memory pages */ - static void unlock_memory(void* ptr, size_type size) noexcept { - munlock(ptr, size); - } + static void unlock_memory(void* ptr, size_type size) noexcept { munlock(ptr, size); } /** * @brief Securely zero memory using volatile writes diff --git a/include/databricks/workspace/workspace_types.h b/include/databricks/workspace/workspace_types.h index 0e16d58..98b2dae 100644 --- a/include/databricks/workspace/workspace_types.h +++ b/include/databricks/workspace/workspace_types.h @@ -59,13 +59,13 @@ enum class Language { * @brief Represents metadata for a workspace object (file, notebook, or directory) */ struct ObjectInfo { - std::string path; ///< Absolute workspace path (e.g., "/Users/user@example.com/notebook") + std::string path; ///< Absolute workspace path (e.g., "/Users/user@example.com/notebook") ObjectType object_type = ObjectType::UNKNOWN; ///< Type of the object - uint64_t object_id = 0; ///< Unique numerical identifier for the object - Language language = Language::UNKNOWN; ///< Language (only set for NOTEBOOK type) - uint64_t size = 0; ///< Size in bytes (for files) - uint64_t created_at = 0; ///< Creation timestamp (Unix milliseconds) - uint64_t modified_at = 0; ///< Last modification timestamp (Unix milliseconds) + uint64_t object_id = 0; ///< Unique numerical identifier for the object + Language language = Language::UNKNOWN; ///< Language (only set for NOTEBOOK type) + uint64_t size = 0; ///< Size in bytes (for files) + uint64_t created_at = 0; ///< Creation timestamp (Unix milliseconds) + uint64_t modified_at = 0; ///< Last modification timestamp (Unix milliseconds) /** * @brief Parse an ObjectInfo from JSON string @@ -80,8 +80,8 @@ struct ObjectInfo { * @brief Response from the export endpoint containing exported content */ struct ExportResponse { - std::string content; ///< Base64-encoded content of the exported object - std::string file_type; ///< File type indicator (e.g., "py", "scala", "sql") + std::string content; ///< Base64-encoded content of the exported object + std::string file_type; ///< File type indicator (e.g., "py", "scala", "sql") /** * @brief Parse an ExportResponse from JSON string @@ -96,11 +96,11 @@ struct ExportResponse { * @brief Request parameters for importing a workspace object */ struct ImportRequest { - std::string path; ///< Absolute workspace path for the imported object - std::string content; ///< Base64-encoded content to import - ImportFormat format = ImportFormat::AUTO; ///< Format of the content being imported - Language language = Language::UNKNOWN; ///< Language (required for SOURCE format, single file) - bool overwrite = false; ///< Whether to overwrite existing object + std::string path; ///< Absolute workspace path for the imported object + std::string content; ///< Base64-encoded content to import + ImportFormat format = ImportFormat::AUTO; ///< Format of the content being imported + Language language = Language::UNKNOWN; ///< Language (required for SOURCE format, single file) + bool overwrite = false; ///< Whether to overwrite existing object }; } // namespace databricks diff --git a/src/unity_catalog/unity_catalog.cpp b/src/unity_catalog/unity_catalog.cpp index 516c3a8..27ed0c2 100644 --- a/src/unity_catalog/unity_catalog.cpp +++ b/src/unity_catalog/unity_catalog.cpp @@ -110,7 +110,8 @@ bool UnityCatalog::delete_catalog(const std::string& catalog_name, bool force) { std::vector UnityCatalog::list_schemas(const std::string& catalog_name) { internal::get_logger()->info("Listing schemas in catalog: " + catalog_name); - auto response = pimpl_->http_client_->get("/unity-catalog/schemas?catalog_name=" + internal::url_encode(catalog_name)); + auto response = + pimpl_->http_client_->get("/unity-catalog/schemas?catalog_name=" + internal::url_encode(catalog_name)); pimpl_->http_client_->check_response(response, "listSchemas"); internal::get_logger()->debug("Schemas list response: " + response.body); @@ -172,7 +173,7 @@ std::vector UnityCatalog::list_tables(const std::string& catalog_name // Create Endpoint with Catalog and Schema name (URL-encoded) std::string endpoint = "/unity-catalog/tables?catalog_name=" + internal::url_encode(catalog_name) + - "&schema_name=" + internal::url_encode(schema_name); + "&schema_name=" + internal::url_encode(schema_name); auto response = pimpl_->http_client_->get(endpoint); pimpl_->http_client_->check_response(response, "listTables"); diff --git a/src/workspace/workspace.cpp b/src/workspace/workspace.cpp index 6235b61..8a6c273 100644 --- a/src/workspace/workspace.cpp +++ b/src/workspace/workspace.cpp @@ -45,7 +45,7 @@ Workspace::~Workspace() = default; // ==================== PUBLIC API METHODS ==================== std::vector Workspace::list(const std::string& path, - const std::optional& notebooks_modified_after) { + const std::optional& notebooks_modified_after) { internal::get_logger()->info("Listing workspace objects at path: " + path); // Throw exception if path is empty @@ -126,15 +126,12 @@ ExportResponse Workspace::export_file(const std::string& path, ExportFormat form return ExportResponse::from_json(response.body); } -void Workspace::import_file(const std::string& path, - const std::string& content, - ImportFormat format, - const std::optional& language, - bool overwrite) { +void Workspace::import_file(const std::string& path, const std::string& content, ImportFormat format, + const std::optional& language, bool overwrite) { internal::get_logger()->info("Importing workspace object to path: " + path); // Throw exception for invalid arguments - if (path.length() == 0 || content.length() == 0 ) { + if (path.length() == 0 || content.length() == 0) { throw std::invalid_argument("Path or Content Input cannot be empty"); } @@ -169,7 +166,8 @@ void Workspace::import_file(const ImportRequest& request) { } void Workspace::delete_object(const std::string& path, bool recursive) { - internal::get_logger()->info("Deleting workspace object: " + path + " (recursive=" + (recursive ? "true" : "false") + ")"); + internal::get_logger()->info("Deleting workspace object: " + path + + " (recursive=" + (recursive ? "true" : "false") + ")"); // Throw exception if path is empty if (path.length() == 0) { @@ -193,13 +191,11 @@ void Workspace::delete_object(const std::string& path, bool recursive) { // ==================== ENUM CONVERSION HELPERS ==================== std::string Workspace::object_type_to_string(ObjectType type) const { - static const std::unordered_map type_map = { - {ObjectType::NOTEBOOK, "NOTEBOOK"}, - {ObjectType::DIRECTORY, "DIRECTORY"}, - {ObjectType::LIBRARY, "LIBRARY"}, - {ObjectType::FILE, "FILE"}, - {ObjectType::REPO, "REPO"} - }; + static const std::unordered_map type_map = {{ObjectType::NOTEBOOK, "NOTEBOOK"}, + {ObjectType::DIRECTORY, "DIRECTORY"}, + {ObjectType::LIBRARY, "LIBRARY"}, + {ObjectType::FILE, "FILE"}, + {ObjectType::REPO, "REPO"}}; auto it = type_map.find(type); if (it != type_map.end()) { @@ -209,13 +205,11 @@ std::string Workspace::object_type_to_string(ObjectType type) const { } ObjectType Workspace::string_to_object_type(const std::string& str) { - static const std::unordered_map type_map = { - {"NOTEBOOK", ObjectType::NOTEBOOK}, - {"DIRECTORY", ObjectType::DIRECTORY}, - {"LIBRARY", ObjectType::LIBRARY}, - {"FILE", ObjectType::FILE}, - {"REPO", ObjectType::REPO} - }; + static const std::unordered_map type_map = {{"NOTEBOOK", ObjectType::NOTEBOOK}, + {"DIRECTORY", ObjectType::DIRECTORY}, + {"LIBRARY", ObjectType::LIBRARY}, + {"FILE", ObjectType::FILE}, + {"REPO", ObjectType::REPO}}; auto it = type_map.find(str); if (it != type_map.end()) { @@ -226,13 +220,9 @@ ObjectType Workspace::string_to_object_type(const std::string& str) { std::string Workspace::export_format_to_string(ExportFormat format) const { static const std::unordered_map format_map = { - {ExportFormat::SOURCE, "SOURCE"}, - {ExportFormat::HTML, "HTML"}, - {ExportFormat::JUPYTER, "JUPYTER"}, - {ExportFormat::DBC, "DBC"}, - {ExportFormat::R_MARKDOWN, "R_MARKDOWN"}, - {ExportFormat::AUTO, "AUTO"} - }; + {ExportFormat::SOURCE, "SOURCE"}, {ExportFormat::HTML, "HTML"}, + {ExportFormat::JUPYTER, "JUPYTER"}, {ExportFormat::DBC, "DBC"}, + {ExportFormat::R_MARKDOWN, "R_MARKDOWN"}, {ExportFormat::AUTO, "AUTO"}}; auto it = format_map.find(format); if (it != format_map.end()) { @@ -243,13 +233,9 @@ std::string Workspace::export_format_to_string(ExportFormat format) const { ExportFormat Workspace::string_to_export_format(const std::string& str) { static const std::unordered_map format_map = { - {"SOURCE", ExportFormat::SOURCE}, - {"HTML", ExportFormat::HTML}, - {"JUPYTER", ExportFormat::JUPYTER}, - {"DBC", ExportFormat::DBC}, - {"R_MARKDOWN", ExportFormat::R_MARKDOWN}, - {"AUTO", ExportFormat::AUTO} - }; + {"SOURCE", ExportFormat::SOURCE}, {"HTML", ExportFormat::HTML}, + {"JUPYTER", ExportFormat::JUPYTER}, {"DBC", ExportFormat::DBC}, + {"R_MARKDOWN", ExportFormat::R_MARKDOWN}, {"AUTO", ExportFormat::AUTO}}; auto it = format_map.find(str); if (it != format_map.end()) { @@ -260,13 +246,9 @@ ExportFormat Workspace::string_to_export_format(const std::string& str) { std::string Workspace::import_format_to_string(ImportFormat format) const { static const std::unordered_map format_map = { - {ImportFormat::SOURCE, "SOURCE"}, - {ImportFormat::HTML, "HTML"}, - {ImportFormat::JUPYTER, "JUPYTER"}, - {ImportFormat::DBC, "DBC"}, - {ImportFormat::R_MARKDOWN, "R_MARKDOWN"}, - {ImportFormat::AUTO, "AUTO"} - }; + {ImportFormat::SOURCE, "SOURCE"}, {ImportFormat::HTML, "HTML"}, + {ImportFormat::JUPYTER, "JUPYTER"}, {ImportFormat::DBC, "DBC"}, + {ImportFormat::R_MARKDOWN, "R_MARKDOWN"}, {ImportFormat::AUTO, "AUTO"}}; auto it = format_map.find(format); if (it != format_map.end()) { @@ -277,13 +259,9 @@ std::string Workspace::import_format_to_string(ImportFormat format) const { ImportFormat Workspace::string_to_import_format(const std::string& str) { static const std::unordered_map format_map = { - {"SOURCE", ImportFormat::SOURCE}, - {"HTML", ImportFormat::HTML}, - {"JUPYTER", ImportFormat::JUPYTER}, - {"DBC", ImportFormat::DBC}, - {"R_MARKDOWN", ImportFormat::R_MARKDOWN}, - {"AUTO", ImportFormat::AUTO} - }; + {"SOURCE", ImportFormat::SOURCE}, {"HTML", ImportFormat::HTML}, + {"JUPYTER", ImportFormat::JUPYTER}, {"DBC", ImportFormat::DBC}, + {"R_MARKDOWN", ImportFormat::R_MARKDOWN}, {"AUTO", ImportFormat::AUTO}}; auto it = format_map.find(str); if (it != format_map.end()) { @@ -294,11 +272,7 @@ ImportFormat Workspace::string_to_import_format(const std::string& str) { std::string Workspace::language_to_string(Language language) const { static const std::unordered_map lang_map = { - {Language::SCALA, "SCALA"}, - {Language::PYTHON, "PYTHON"}, - {Language::SQL, "SQL"}, - {Language::R, "R"} - }; + {Language::SCALA, "SCALA"}, {Language::PYTHON, "PYTHON"}, {Language::SQL, "SQL"}, {Language::R, "R"}}; auto it = lang_map.find(language); if (it != lang_map.end()) { @@ -309,11 +283,7 @@ std::string Workspace::language_to_string(Language language) const { Language Workspace::string_to_language(const std::string& str) { static const std::unordered_map lang_map = { - {"SCALA", Language::SCALA}, - {"PYTHON", Language::PYTHON}, - {"SQL", Language::SQL}, - {"R", Language::R} - }; + {"SCALA", Language::SCALA}, {"PYTHON", Language::PYTHON}, {"SQL", Language::SQL}, {"R", Language::R}}; auto it = lang_map.find(str); if (it != lang_map.end()) { diff --git a/tests/unit/workspace/test_workspace.cpp b/tests/unit/workspace/test_workspace.cpp index db97201..41eef75 100644 --- a/tests/unit/workspace/test_workspace.cpp +++ b/tests/unit/workspace/test_workspace.cpp @@ -8,8 +8,8 @@ using databricks::test::MockHttpClient; using ::testing::_; -using ::testing::HasSubstr; using ::testing::Eq; +using ::testing::HasSubstr; using ::testing::NiceMock; using ::testing::Return; using ::testing::Throw; @@ -42,14 +42,12 @@ class WorkspaceApiTest : public ::testing::Test { // Constructor Tests // ============================================================================ TEST_F(WorkspaceTest, ConstructorCreatesValidClient) { - ASSERT_NO_THROW({databricks::Workspace workspace(auth);}); + ASSERT_NO_THROW({ databricks::Workspace workspace(auth); }); } TEST_F(WorkspaceTest, ConstructorCreatesValidClientWithManualApiVersion) { const std::string& api_version = "1.0"; - ASSERT_NO_THROW({ - databricks::Workspace workspace(auth, api_version); - }); + ASSERT_NO_THROW({ databricks::Workspace workspace(auth, api_version); }); } TEST_F(WorkspaceTest, MultipleValidWorkspaceClient) { @@ -121,7 +119,7 @@ TEST_F(WorkspaceApiTest, ListEmptyWorkspaceObjectsSuccess) { EXPECT_CALL(*mock_client_, get("/workspace/list?path=%2Ftest%2Fpath")) .WillOnce(Return(MockHttpClient::success_response(mock_empty_list_response))); - + databricks::Workspace workspace(mock_client_); const std::string& mock_path = "/test/path"; auto response = workspace.list(mock_path); From 79af68e34c8dfe5ff05469e83f000aa53773460c Mon Sep 17 00:00:00 2001 From: Calvin Min Date: Wed, 14 Jan 2026 12:13:40 -0500 Subject: [PATCH 7/7] finish out workspace_test file --- tests/unit/workspace/test_workspace.cpp | 265 +++++++++++++++++++++++- 1 file changed, 264 insertions(+), 1 deletion(-) diff --git a/tests/unit/workspace/test_workspace.cpp b/tests/unit/workspace/test_workspace.cpp index 41eef75..834e11c 100644 --- a/tests/unit/workspace/test_workspace.cpp +++ b/tests/unit/workspace/test_workspace.cpp @@ -62,9 +62,13 @@ TEST_F(WorkspaceTest, MultipleValidWorkspaceClient) { } // ============================================================================ -// Workspace API Test +// Workspace API Tests // ============================================================================ +// ---------------------------------------------------------------------------- +// List Tests +// ---------------------------------------------------------------------------- + // Test: List Workspace Objects TEST_F(WorkspaceApiTest, ListWorkspaceObjectsSuccess) { std::string mock_list_response = R"({ @@ -136,6 +140,10 @@ TEST_F(WorkspaceApiTest, ListWorkspaceObjectThrowsInvalidArgument) { EXPECT_THROW(workspace.list(empty_path), std::invalid_argument); } +// ---------------------------------------------------------------------------- +// Mkdirs Tests +// ---------------------------------------------------------------------------- + // Test: Create a Mock Directory TEST_F(WorkspaceApiTest, CreateDirectorySuccess) { EXPECT_CALL(*mock_client_, post("/workspace/mkdirs", Eq(R"({"path":"/test/path"})"))) @@ -154,6 +162,10 @@ TEST_F(WorkspaceApiTest, CreateEmptyDirectoryThrowsInvalidArgument) { EXPECT_THROW(workspace.mkdirs(empty_path), std::invalid_argument); } +// ---------------------------------------------------------------------------- +// Get Status Tests +// ---------------------------------------------------------------------------- + // Test: Get Status for a Workspace object TEST_F(WorkspaceApiTest, GetStatusSuccess) { std::string mock_status_response = R"({ @@ -188,3 +200,254 @@ TEST_F(WorkspaceApiTest, GetStatusThrowsInvalidArgument) { EXPECT_THROW(workspace.get_status(empty_path), std::invalid_argument); } + +// ============================================================================ +// Export File Tests +// ============================================================================ + +// Test: Export file successfully with SOURCE format +TEST_F(WorkspaceApiTest, ExportFileSuccessWithSourceFormat) { + std::string mock_export_response = R"({ + "content": "cHJpbnQoImhlbGxvIHdvcmxkIik=", + "file_type": "py" + })"; + + EXPECT_CALL(*mock_client_, get("/workspace/export?path=%2Ftest%2Fnotebook&format=SOURCE")) + .WillOnce(Return(MockHttpClient::success_response(mock_export_response))); + + databricks::Workspace workspace(mock_client_); + const std::string& mock_path = "/test/notebook"; + auto response = workspace.export_file(mock_path, databricks::ExportFormat::SOURCE); + + // Verify the export response + EXPECT_EQ(response.content, "cHJpbnQoImhlbGxvIHdvcmxkIik="); + EXPECT_EQ(response.file_type, "py"); +} + +// Test: Export file successfully with JUPYTER format +TEST_F(WorkspaceApiTest, ExportFileSuccessWithJupyterFormat) { + std::string mock_export_response = R"({ + "content": "eyJ2ZXJzaW9uIjogMX0=", + "file_type": "ipynb" + })"; + + EXPECT_CALL(*mock_client_, get("/workspace/export?path=%2Ftest%2Fnotebook&format=JUPYTER")) + .WillOnce(Return(MockHttpClient::success_response(mock_export_response))); + + databricks::Workspace workspace(mock_client_); + const std::string& mock_path = "/test/notebook"; + auto response = workspace.export_file(mock_path, databricks::ExportFormat::JUPYTER); + + // Verify the export response + EXPECT_EQ(response.content, "eyJ2ZXJzaW9uIjogMX0="); + EXPECT_EQ(response.file_type, "ipynb"); +} + +// Test: Export file successfully with HTML format +TEST_F(WorkspaceApiTest, ExportFileSuccessWithHtmlFormat) { + std::string mock_export_response = R"({ + "content": "PGh0bWw+PC9odG1sPg==", + "file_type": "html" + })"; + + EXPECT_CALL(*mock_client_, get("/workspace/export?path=%2Ftest%2Fnotebook&format=HTML")) + .WillOnce(Return(MockHttpClient::success_response(mock_export_response))); + + databricks::Workspace workspace(mock_client_); + const std::string& mock_path = "/test/notebook"; + auto response = workspace.export_file(mock_path, databricks::ExportFormat::HTML); + + // Verify the export response + EXPECT_EQ(response.content, "PGh0bWw+PC9odG1sPg=="); + EXPECT_EQ(response.file_type, "html"); +} + +// Test: Export file with DBC format for directory export +TEST_F(WorkspaceApiTest, ExportFileSuccessWithDbcFormat) { + std::string mock_export_response = R"({ + "content": "UEsDBBQAAAAIAA==", + "file_type": "dbc" + })"; + + EXPECT_CALL(*mock_client_, get("/workspace/export?path=%2Ftest%2Fdirectory&format=DBC")) + .WillOnce(Return(MockHttpClient::success_response(mock_export_response))); + + databricks::Workspace workspace(mock_client_); + const std::string& mock_path = "/test/directory"; + auto response = workspace.export_file(mock_path, databricks::ExportFormat::DBC); + + // Verify the export response + EXPECT_EQ(response.content, "UEsDBBQAAAAIAA=="); + EXPECT_EQ(response.file_type, "dbc"); +} + +// Test: Fail to export file with empty path +TEST_F(WorkspaceApiTest, ExportFileThrowsInvalidArgument) { + databricks::Workspace workspace(mock_client_); + const std::string& empty_path = ""; + + EXPECT_THROW(workspace.export_file(empty_path, databricks::ExportFormat::SOURCE), std::invalid_argument); +} + +// ============================================================================ +// Import File Tests +// ============================================================================ + +// Test: Import file successfully with SOURCE format and Python language +TEST_F(WorkspaceApiTest, ImportFileSuccessWithSourceFormat) { + std::string expected_body = + R"({"content":"cHJpbnQoImhlbGxvIHdvcmxkIik=","format":"SOURCE","language":"PYTHON","overwrite":false,"path":"/test/notebook"})"; + + EXPECT_CALL(*mock_client_, post("/workspace/import", Eq(expected_body))) + .WillOnce(Return(MockHttpClient::success_response(""))); + + databricks::Workspace workspace(mock_client_); + const std::string& mock_path = "/test/notebook"; + const std::string& mock_content = "cHJpbnQoImhlbGxvIHdvcmxkIik="; + workspace.import_file(mock_path, mock_content, databricks::ImportFormat::SOURCE, databricks::Language::PYTHON, + false); +} + +// Test: Import file successfully with JUPYTER format (no language required) +TEST_F(WorkspaceApiTest, ImportFileSuccessWithJupyterFormat) { + std::string expected_body = + R"({"content":"eyJ2ZXJzaW9uIjogMX0=","format":"JUPYTER","overwrite":false,"path":"/test/notebook"})"; + + EXPECT_CALL(*mock_client_, post("/workspace/import", Eq(expected_body))) + .WillOnce(Return(MockHttpClient::success_response(""))); + + databricks::Workspace workspace(mock_client_); + const std::string& mock_path = "/test/notebook"; + const std::string& mock_content = "eyJ2ZXJzaW9uIjogMX0="; + workspace.import_file(mock_path, mock_content, databricks::ImportFormat::JUPYTER); +} + +// Test: Import file with overwrite enabled +TEST_F(WorkspaceApiTest, ImportFileSuccessWithOverwrite) { + std::string expected_body = + R"({"content":"cHJpbnQoImhlbGxvIHdvcmxkIik=","format":"SOURCE","language":"SCALA","overwrite":true,"path":"/test/notebook"})"; + + EXPECT_CALL(*mock_client_, post("/workspace/import", Eq(expected_body))) + .WillOnce(Return(MockHttpClient::success_response(""))); + + databricks::Workspace workspace(mock_client_); + const std::string& mock_path = "/test/notebook"; + const std::string& mock_content = "cHJpbnQoImhlbGxvIHdvcmxkIik="; + workspace.import_file(mock_path, mock_content, databricks::ImportFormat::SOURCE, databricks::Language::SCALA, true); +} + +// Test: Import file with AUTO format +TEST_F(WorkspaceApiTest, ImportFileSuccessWithAutoFormat) { + std::string expected_body = + R"({"content":"cHJpbnQoImhlbGxvIHdvcmxkIik=","format":"AUTO","overwrite":false,"path":"/test/notebook"})"; + + EXPECT_CALL(*mock_client_, post("/workspace/import", Eq(expected_body))) + .WillOnce(Return(MockHttpClient::success_response(""))); + + databricks::Workspace workspace(mock_client_); + const std::string& mock_path = "/test/notebook"; + const std::string& mock_content = "cHJpbnQoImhlbGxvIHdvcmxkIik="; + workspace.import_file(mock_path, mock_content, databricks::ImportFormat::AUTO); +} + +// Test: Import file with DBC format for directory import +TEST_F(WorkspaceApiTest, ImportFileSuccessWithDbcFormat) { + std::string expected_body = + R"({"content":"UEsDBBQAAAAIAA==","format":"DBC","overwrite":false,"path":"/test/directory"})"; + + EXPECT_CALL(*mock_client_, post("/workspace/import", Eq(expected_body))) + .WillOnce(Return(MockHttpClient::success_response(""))); + + databricks::Workspace workspace(mock_client_); + const std::string& mock_path = "/test/directory"; + const std::string& mock_content = "UEsDBBQAAAAIAA=="; + workspace.import_file(mock_path, mock_content, databricks::ImportFormat::DBC); +} + +// Test: Import file using ImportRequest struct +TEST_F(WorkspaceApiTest, ImportFileSuccessWithImportRequest) { + std::string expected_body = + R"({"content":"cHJpbnQoImhlbGxvIHdvcmxkIik=","format":"SOURCE","language":"PYTHON","overwrite":true,"path":"/test/notebook"})"; + + EXPECT_CALL(*mock_client_, post("/workspace/import", Eq(expected_body))) + .WillOnce(Return(MockHttpClient::success_response(""))); + + databricks::Workspace workspace(mock_client_); + + databricks::ImportRequest request; + request.path = "/test/notebook"; + request.content = "cHJpbnQoImhlbGxvIHdvcmxkIik="; + request.format = databricks::ImportFormat::SOURCE; + request.language = databricks::Language::PYTHON; + request.overwrite = true; + + workspace.import_file(request); +} + +// Test: Fail to import file with empty path +TEST_F(WorkspaceApiTest, ImportFileThrowsInvalidArgumentForEmptyPath) { + databricks::Workspace workspace(mock_client_); + const std::string& empty_path = ""; + const std::string& mock_content = "cHJpbnQoImhlbGxvIHdvcmxkIik="; + + EXPECT_THROW(workspace.import_file(empty_path, mock_content, databricks::ImportFormat::SOURCE), + std::invalid_argument); +} + +// Test: Fail to import file with empty content +TEST_F(WorkspaceApiTest, ImportFileThrowsInvalidArgumentForEmptyContent) { + databricks::Workspace workspace(mock_client_); + const std::string& mock_path = "/test/notebook"; + const std::string& empty_content = ""; + + EXPECT_THROW(workspace.import_file(mock_path, empty_content, databricks::ImportFormat::SOURCE), + std::invalid_argument); +} + +// ============================================================================ +// Delete Object Tests +// ============================================================================ + +// Test: Delete file successfully without recursion +TEST_F(WorkspaceApiTest, DeleteObjectSuccessNonRecursive) { + std::string expected_body = R"({"path":"/test/notebook","recursive":false})"; + + EXPECT_CALL(*mock_client_, post("/workspace/delete", Eq(expected_body))) + .WillOnce(Return(MockHttpClient::success_response(""))); + + databricks::Workspace workspace(mock_client_); + const std::string& mock_path = "/test/notebook"; + workspace.delete_object(mock_path, false); +} + +// Test: Delete directory successfully with recursion +TEST_F(WorkspaceApiTest, DeleteObjectSuccessRecursive) { + std::string expected_body = R"({"path":"/test/directory","recursive":true})"; + + EXPECT_CALL(*mock_client_, post("/workspace/delete", Eq(expected_body))) + .WillOnce(Return(MockHttpClient::success_response(""))); + + databricks::Workspace workspace(mock_client_); + const std::string& mock_path = "/test/directory"; + workspace.delete_object(mock_path, true); +} + +// Test: Delete object with default recursive parameter (false) +TEST_F(WorkspaceApiTest, DeleteObjectSuccessDefaultRecursive) { + std::string expected_body = R"({"path":"/test/notebook","recursive":false})"; + + EXPECT_CALL(*mock_client_, post("/workspace/delete", Eq(expected_body))) + .WillOnce(Return(MockHttpClient::success_response(""))); + + databricks::Workspace workspace(mock_client_); + const std::string& mock_path = "/test/notebook"; + workspace.delete_object(mock_path); // Default recursive=false +} + +// Test: Fail to delete object with empty path +TEST_F(WorkspaceApiTest, DeleteObjectThrowsInvalidArgument) { + databricks::Workspace workspace(mock_client_); + const std::string& empty_path = ""; + + EXPECT_THROW(workspace.delete_object(empty_path, false), std::invalid_argument); +} \ No newline at end of file