diff --git a/src/iceberg/catalog/rest/CMakeLists.txt b/src/iceberg/catalog/rest/CMakeLists.txt index 38d897270..e230646b1 100644 --- a/src/iceberg/catalog/rest/CMakeLists.txt +++ b/src/iceberg/catalog/rest/CMakeLists.txt @@ -15,7 +15,12 @@ # specific language governing permissions and limitations # under the License. -set(ICEBERG_REST_SOURCES rest_catalog.cc json_internal.cc) +set(ICEBERG_REST_SOURCES + catalog.cc + json_internal.cc + config.cc + http_client_internal.cc + resource_paths.cc) set(ICEBERG_REST_STATIC_BUILD_INTERFACE_LIBS) set(ICEBERG_REST_SHARED_BUILD_INTERFACE_LIBS) diff --git a/src/iceberg/catalog/rest/catalog.cc b/src/iceberg/catalog/rest/catalog.cc new file mode 100644 index 000000000..c4f0a88c6 --- /dev/null +++ b/src/iceberg/catalog/rest/catalog.cc @@ -0,0 +1,200 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +#include "iceberg/catalog/rest/catalog.h" + +#include +#include + +#include + +#include "iceberg/catalog/rest/config.h" +#include "iceberg/catalog/rest/constant.h" +#include "iceberg/catalog/rest/endpoint_util.h" +#include "iceberg/catalog/rest/http_client_interal.h" +#include "iceberg/catalog/rest/json_internal.h" +#include "iceberg/catalog/rest/types.h" +#include "iceberg/json_internal.h" +#include "iceberg/result.h" +#include "iceberg/table.h" +#include "iceberg/util/macros.h" + +namespace iceberg::rest { + +Result> RestCatalog::Make(const RestCatalogConfig& config) { + // Create ResourcePaths and validate URI + ICEBERG_ASSIGN_OR_RAISE(auto paths, ResourcePaths::Make(config)); + + ICEBERG_ASSIGN_OR_RAISE(auto tmp_client, HttpClient::Make(config)); + + const std::string endpoint = paths->V1Config(); + cpr::Parameters params; + ICEBERG_ASSIGN_OR_RAISE(const auto& response, tmp_client->Get(endpoint, params)); + switch (response.status_code) { + case cpr::status::HTTP_OK: { + ICEBERG_ASSIGN_OR_RAISE(auto json, FromJsonString(response.text)); + ICEBERG_ASSIGN_OR_RAISE(auto server_config, CatalogConfigFromJson(json)); + // Merge server config into client config, server config overrides > client config + // properties > server config defaults + auto final_props = std::move(server_config.defaults); + for (const auto& kv : config.configs()) { + final_props.insert_or_assign(kv.first, kv.second); + } + + for (const auto& kv : server_config.overrides) { + final_props.insert_or_assign(kv.first, kv.second); + } + auto final_config = RestCatalogConfig::FromMap(final_props); + ICEBERG_ASSIGN_OR_RAISE(auto client, HttpClient::Make(*final_config)); + ICEBERG_ASSIGN_OR_RAISE(auto final_paths, ResourcePaths::Make(*final_config)); + return std::unique_ptr(new RestCatalog( + std::move(final_config), std::move(client), std::move(*final_paths))); + }; + default: { + ICEBERG_ASSIGN_OR_RAISE(auto json, FromJsonString(response.text)); + ICEBERG_ASSIGN_OR_RAISE(auto list_response, ErrorResponseFromJson(json)); + return UnknownError("Error listing namespaces: {}", list_response.error.message); + } + } +} + +RestCatalog::RestCatalog(std::unique_ptr config, + std::unique_ptr client, ResourcePaths paths) + : config_(std::move(config)), client_(std::move(client)), paths_(std::move(paths)) {} + +std::string_view RestCatalog::name() const { + auto it = config_->configs().find(std::string(RestCatalogConfig::kName)); + if (it == config_->configs().end() || it->second.empty()) { + return {""}; + } + return std::string_view(it->second); +} + +Result> RestCatalog::ListNamespaces(const Namespace& ns) const { + const std::string endpoint = paths_.V1Namespaces(); + std::vector result; + std::string next_token; + while (true) { + cpr::Parameters params; + if (!ns.levels.empty()) { + params.Add({std::string(kQueryParamParent), EncodeNamespaceForUrl(ns)}); + } + if (!next_token.empty()) { + params.Add({std::string(kQueryParamPageToken), next_token}); + } + ICEBERG_ASSIGN_OR_RAISE(const auto& response, client_->Get(endpoint, params)); + switch (response.status_code) { + case cpr::status::HTTP_OK: { + ICEBERG_ASSIGN_OR_RAISE(auto json, FromJsonString(response.text)); + ICEBERG_ASSIGN_OR_RAISE(auto list_response, ListNamespacesResponseFromJson(json)); + result.insert(result.end(), list_response.namespaces.begin(), + list_response.namespaces.end()); + if (list_response.next_page_token.empty()) { + return result; + } + next_token = list_response.next_page_token; + continue; + } + case cpr::status::HTTP_NOT_FOUND: { + return NoSuchNamespace("Namespace not found"); + } + default: + ICEBERG_ASSIGN_OR_RAISE(auto json, FromJsonString(response.text)); + ICEBERG_ASSIGN_OR_RAISE(auto list_response, ErrorResponseFromJson(json)); + return UnknownError("Error listing namespaces: {}", list_response.error.message); + } + } +} + +Status RestCatalog::CreateNamespace( + const Namespace& ns, const std::unordered_map& properties) { + return NotImplemented("Not implemented"); +} + +Result> RestCatalog::GetNamespaceProperties( + const Namespace& ns) const { + return NotImplemented("Not implemented"); +} + +Status RestCatalog::DropNamespace(const Namespace& ns) { + return NotImplemented("Not implemented"); +} + +Result RestCatalog::NamespaceExists(const Namespace& ns) const { + return NotImplemented("Not implemented"); +} + +Status RestCatalog::UpdateNamespaceProperties( + const Namespace& ns, const std::unordered_map& updates, + const std::unordered_set& removals) { + return NotImplemented("Not implemented"); +} + +Result> RestCatalog::ListTables(const Namespace& ns) const { + return NotImplemented("Not implemented"); +} + +Result> RestCatalog::CreateTable( + const TableIdentifier& identifier, const Schema& schema, const PartitionSpec& spec, + const std::string& location, + const std::unordered_map& properties) { + return NotImplemented("Not implemented"); +} + +Result> RestCatalog::UpdateTable( + const TableIdentifier& identifier, + const std::vector>& requirements, + const std::vector>& updates) { + return NotImplemented("Not implemented"); +} + +Result> RestCatalog::StageCreateTable( + const TableIdentifier& identifier, const Schema& schema, const PartitionSpec& spec, + const std::string& location, + const std::unordered_map& properties) { + return NotImplemented("Not implemented"); +} + +Status RestCatalog::DropTable(const TableIdentifier& identifier, bool purge) { + return NotImplemented("Not implemented"); +} + +Result RestCatalog::TableExists(const TableIdentifier& identifier) const { + return NotImplemented("Not implemented"); +} + +Status RestCatalog::RenameTable(const TableIdentifier& from, const TableIdentifier& to) { + return NotImplemented("Not implemented"); +} + +Result> RestCatalog::LoadTable(const TableIdentifier& identifier) { + return NotImplemented("Not implemented"); +} + +Result> RestCatalog::RegisterTable( + const TableIdentifier& identifier, const std::string& metadata_file_location) { + return NotImplemented("Not implemented"); +} + +std::unique_ptr RestCatalog::BuildTable( + const TableIdentifier& identifier, const Schema& schema) const { + return nullptr; +} + +} // namespace iceberg::rest diff --git a/src/iceberg/catalog/rest/catalog.h b/src/iceberg/catalog/rest/catalog.h new file mode 100644 index 000000000..66d7ceccb --- /dev/null +++ b/src/iceberg/catalog/rest/catalog.h @@ -0,0 +1,110 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +#pragma once + +#include +#include + +#include "iceberg/catalog.h" +#include "iceberg/catalog/rest/config.h" +#include "iceberg/catalog/rest/http_client_interal.h" +#include "iceberg/catalog/rest/iceberg_rest_export.h" +#include "iceberg/catalog/rest/resource_paths.h" +#include "iceberg/result.h" + +/// \file iceberg/catalog/rest/catalog.h +/// RestCatalog implementation for Iceberg REST API. + +namespace iceberg::rest { + +class ICEBERG_REST_EXPORT RestCatalog : public Catalog { + public: + RestCatalog(const RestCatalog&) = delete; + RestCatalog& operator=(const RestCatalog&) = delete; + RestCatalog(RestCatalog&&) = delete; + RestCatalog& operator=(RestCatalog&&) = delete; + + /// \brief Create a RestCatalog instance + /// + /// \param config the configuration for the RestCatalog + /// \return a unique_ptr to RestCatalog instance + static Result> Make(const RestCatalogConfig& config); + + std::string_view name() const override; + + Result> ListNamespaces(const Namespace& ns) const override; + + Status CreateNamespace( + const Namespace& ns, + const std::unordered_map& properties) override; + + Result> GetNamespaceProperties( + const Namespace& ns) const override; + + Status DropNamespace(const Namespace& ns) override; + + Result NamespaceExists(const Namespace& ns) const override; + + Status UpdateNamespaceProperties( + const Namespace& ns, const std::unordered_map& updates, + const std::unordered_set& removals) override; + + Result> ListTables(const Namespace& ns) const override; + + Result> CreateTable( + const TableIdentifier& identifier, const Schema& schema, const PartitionSpec& spec, + const std::string& location, + const std::unordered_map& properties) override; + + Result> UpdateTable( + const TableIdentifier& identifier, + const std::vector>& requirements, + const std::vector>& updates) override; + + Result> StageCreateTable( + const TableIdentifier& identifier, const Schema& schema, const PartitionSpec& spec, + const std::string& location, + const std::unordered_map& properties) override; + + Result TableExists(const TableIdentifier& identifier) const override; + + Status RenameTable(const TableIdentifier& from, const TableIdentifier& to) override; + + Status DropTable(const TableIdentifier& identifier, bool purge) override; + + Result> LoadTable(const TableIdentifier& identifier) override; + + Result> RegisterTable( + const TableIdentifier& identifier, + const std::string& metadata_file_location) override; + + std::unique_ptr BuildTable( + const TableIdentifier& identifier, const Schema& schema) const override; + + private: + RestCatalog(std::unique_ptr config, + std::unique_ptr client, ResourcePaths paths); + + std::unique_ptr config_; + std::unique_ptr client_; + ResourcePaths paths_; +}; + +} // namespace iceberg::rest diff --git a/src/iceberg/catalog/rest/config.cc b/src/iceberg/catalog/rest/config.cc new file mode 100644 index 000000000..6220a301c --- /dev/null +++ b/src/iceberg/catalog/rest/config.cc @@ -0,0 +1,62 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +#include "iceberg/catalog/rest/config.h" + +#include "iceberg/catalog/rest/constant.h" + +namespace iceberg::rest { + +std::unique_ptr RestCatalogConfig::default_properties() { + return std::make_unique(); +} + +std::unique_ptr RestCatalogConfig::FromMap( + const std::unordered_map& properties) { + auto rest_catalog_config = std::make_unique(); + rest_catalog_config->configs_ = properties; + return rest_catalog_config; +} + +Result RestCatalogConfig::GetExtraHeaders() const { + cpr::Header headers; + + headers[std::string(kHeaderContentType)] = std::string(kMimeTypeApplicationJson); + headers[std::string(kHeaderUserAgent)] = std::string(kUserAgent); + headers[std::string(kHeaderXClientVersion)] = std::string(kClientVersion); + + constexpr std::string_view prefix = "header."; + for (const auto& [key, value] : configs_) { + if (key.starts_with(prefix)) { + std::string_view header_name = std::string_view(key).substr(prefix.length()); + + if (header_name.empty()) { + return InvalidArgument("Header name cannot be empty after '{}' prefix", prefix); + } + + if (value.empty()) { + return InvalidArgument("Header value for '{}' cannot be empty", header_name); + } + headers[std::string(header_name)] = value; + } + } + return headers; +} + +} // namespace iceberg::rest diff --git a/src/iceberg/catalog/rest/config.h b/src/iceberg/catalog/rest/config.h new file mode 100644 index 000000000..b9d015af6 --- /dev/null +++ b/src/iceberg/catalog/rest/config.h @@ -0,0 +1,69 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +#pragma once + +#include +#include +#include + +#include + +#include "iceberg/catalog/rest/iceberg_rest_export.h" +#include "iceberg/result.h" +#include "iceberg/util/config.h" + +/// \file iceberg/catalog/rest/config.h +/// \brief RestCatalogConfig implementation for Iceberg REST API. + +namespace iceberg::rest { + +/// \brief Configuration class for a REST Catalog. +class ICEBERG_REST_EXPORT RestCatalogConfig : public ConfigBase { + public: + template + using Entry = const ConfigBase::Entry; + + /// \brief The URI of the REST catalog server. + inline static std::string_view kUri{"uri"}; + + /// \brief The name of the catalog. + inline static std::string_view kName{"name"}; + + /// \brief The warehouse path. + inline static std::string_view kWarehouse{"warehouse"}; + + /// \brief Create a default RestCatalogConfig instance. + static std::unique_ptr default_properties(); + + /// \brief Create a RestCatalogConfig instance from a map of key-value pairs. + static std::unique_ptr FromMap( + const std::unordered_map& properties); + + /// \brief Generates extra HTTP headers to be added to every request from the + /// configuration. + /// + /// This includes default headers like Content-Type, User-Agent, X-Client-Version and + /// any custom headers prefixed with "header." in the properties. + /// \return A Result containing cpr::Header object, or an error if names/values are + /// invalid. + Result GetExtraHeaders() const; +}; + +} // namespace iceberg::rest diff --git a/src/iceberg/catalog/rest/constant.h b/src/iceberg/catalog/rest/constant.h new file mode 100644 index 000000000..8f1e9fbf5 --- /dev/null +++ b/src/iceberg/catalog/rest/constant.h @@ -0,0 +1,44 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +#pragma once + +#include + +#include "iceberg/version.h" + +/// \file iceberg/catalog/rest/constant.h +/// Constant values for Iceberg REST API. + +namespace iceberg::rest { + +constexpr std::string_view kHeaderContentType = "Content-Type"; +constexpr std::string_view kHeaderAccept = "Accept"; +constexpr std::string_view kHeaderXClientVersion = "X-Client-Version"; +constexpr std::string_view kHeaderUserAgent = "User-Agent"; + +constexpr std::string_view kMimeTypeApplicationJson = "application/json"; +constexpr std::string_view kClientVersion = "0.14.1"; +constexpr std::string_view kUserAgentPrefix = "iceberg-cpp/"; +constexpr std::string_view kUserAgent = "iceberg-cpp/" ICEBERG_VERSION_STRING; + +constexpr std::string_view kQueryParamParent = "parent"; +constexpr std::string_view kQueryParamPageToken = "page_token"; + +} // namespace iceberg::rest diff --git a/src/iceberg/catalog/rest/endpoint_util.h b/src/iceberg/catalog/rest/endpoint_util.h new file mode 100644 index 000000000..8aa4a69c9 --- /dev/null +++ b/src/iceberg/catalog/rest/endpoint_util.h @@ -0,0 +1,81 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +#pragma once + +#include +#include +#include +#include + +#include "iceberg/table_identifier.h" + +namespace iceberg::rest { + +/// \brief Trim a single trailing slash from a URI string_view. +/// \details If \p uri_sv ends with '/', remove that last character; otherwise the input +/// is returned unchanged. +/// \param uri_sv The URI string view to trim. +/// \return The (possibly) trimmed URI string view. +inline std::string_view TrimTrailingSlash(std::string_view uri_sv) { + if (uri_sv.ends_with('/')) { + uri_sv.remove_suffix(1); + } + return uri_sv; +} + +/// \brief Percent-encode a string as a URI component (RFC 3986). +/// \details Leaves unreserved characters [A–Z a–z 0–9 - _ . ~] as-is; all others are +/// percent-encoded using uppercase hexadecimal (e.g., space -> "%20"). +/// \param value The string to encode. +/// \return The encoded string. +inline std::string EncodeUriComponent(std::string_view value) { + std::string escaped; + escaped.reserve(value.length()); + for (const unsigned char c : value) { + if (isalnum(c) || c == '-' || c == '_' || c == '.' || c == '~') { + escaped += c; + } else { + std::format_to(std::back_inserter(escaped), "%{:02X}", c); + } + } + return escaped; +} + +/// \brief Encode a Namespace into a URL-safe component. +/// \details Joins \p ns.levels with the ASCII Unit Separator (0x1F), then percent-encodes +/// the result via EncodeUriComponent. Returns an empty string if there are no levels. +/// \param ns The namespace (sequence of path-like levels) to encode. +/// \return The percent-encoded namespace string suitable for URLs. +inline std::string EncodeNamespaceForUrl(const Namespace& ns) { + if (ns.levels.empty()) { + return ""; + } + + std::string joined_string; + joined_string.append(ns.levels.front()); + for (size_t i = 1; i < ns.levels.size(); ++i) { + joined_string.append("\x1F"); + joined_string.append(ns.levels[i]); + } + + return EncodeUriComponent(joined_string); +} + +} // namespace iceberg::rest diff --git a/src/iceberg/catalog/rest/http_client_interal.h b/src/iceberg/catalog/rest/http_client_interal.h new file mode 100644 index 000000000..ea89eda68 --- /dev/null +++ b/src/iceberg/catalog/rest/http_client_interal.h @@ -0,0 +1,93 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +#pragma once + +#include +#include +#include + +#include +#include + +#include "iceberg/catalog/rest/config.h" +#include "iceberg/catalog/rest/iceberg_rest_export.h" +#include "iceberg/result.h" + +/// \file iceberg/catalog/rest/http_client.h +/// \brief Http client for Iceberg REST API. + +namespace iceberg::rest { + +/// \brief HTTP client for making requests to Iceberg REST Catalog API. +/// +/// This class wraps the CPR library and provides a type-safe interface for making +/// HTTP requests. It handles authentication, headers, and response parsing. +class ICEBERG_REST_EXPORT HttpClient { + public: + /// \brief Factory function to create and initialize an HttpClient. + /// This is the preferred way to construct an HttpClient, as it can handle + /// potential errors during configuration parsing (e.g., invalid headers). + /// \param config The catalog configuration. + /// \return A Result containing a unique_ptr to the HttpClient, or an Error. + static Result> Make(const RestCatalogConfig& config); + + HttpClient(const HttpClient&) = delete; + HttpClient& operator=(const HttpClient&) = delete; + HttpClient(HttpClient&&) = default; + HttpClient& operator=(HttpClient&&) = default; + + /// \brief Sends a GET request. + /// \param target The target path relative to the base URL (e.g., "/v1/namespaces"). + Result Get(const std::string& target, const cpr::Parameters& params = {}, + const cpr::Header& headers = {}); + + /// \brief Sends a POST request. + /// \param target The target path relative to the base URL (e.g., "/v1/namespaces"). + Result Post(const std::string& target, const cpr::Body& body, + const cpr::Parameters& params = {}, + const cpr::Header& headers = {}); + + /// \brief Sends a HEAD request. + /// \param target The target path relative to the base URL (e.g., "/v1/namespaces"). + Result Head(const std::string& target, + const cpr::Parameters& params = {}, + const cpr::Header& headers = {}); + + /// \brief Sends a DELETE request. + /// \param target The target path relative to the base URL (e.g., "/v + Result Delete(const std::string& target, + const cpr::Parameters& params = {}, + const cpr::Header& headers = {}); + + private: + /// \brief Private constructor. Use the static Create() factory function instead. + explicit HttpClient(cpr::Header session_headers); + + /// \brief Internal helper to execute a request. + template + Result Execute(const std::string& target, const cpr::Parameters& params, + const cpr::Header& request_headers, + Func&& perform_request); + + cpr::Header default_headers_; + std::unique_ptr session_; +}; + +} // namespace iceberg::rest diff --git a/src/iceberg/catalog/rest/http_client_internal.cc b/src/iceberg/catalog/rest/http_client_internal.cc new file mode 100644 index 000000000..737e07066 --- /dev/null +++ b/src/iceberg/catalog/rest/http_client_internal.cc @@ -0,0 +1,84 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +#include + +#include "cpr/body.h" +#include "iceberg/catalog/rest/config.h" +#include "iceberg/catalog/rest/http_client_interal.h" +#include "iceberg/util/macros.h" + +namespace iceberg::rest { + +Result> HttpClient::Make(const RestCatalogConfig& config) { + ICEBERG_ASSIGN_OR_RAISE(auto session_headers, config.GetExtraHeaders()); + return std::unique_ptr(new HttpClient(std::move(session_headers))); +} + +HttpClient::HttpClient(cpr::Header session_headers) + : default_headers_(std::move(session_headers)), + session_(std::make_unique()) {} + +Result HttpClient::Get(const std::string& target, + const cpr::Parameters& params, + const cpr::Header& headers) { + return Execute(target, params, headers, + [&](cpr::Session& session) { return session.Get(); }); +} + +Result HttpClient::Post(const std::string& target, const cpr::Body& body, + const cpr::Parameters& params, + const cpr::Header& headers) { + return Execute(target, params, headers, [&](cpr::Session& session) { + session.SetBody(body); + return session.Post(); + }); +} + +Result HttpClient::Head(const std::string& target, + const cpr::Parameters& params, + const cpr::Header& headers) { + return Execute(target, params, headers, + [&](cpr::Session& session) { return session.Head(); }); +} + +Result HttpClient::Delete(const std::string& target, + const cpr::Parameters& params, + const cpr::Header& headers) { + return Execute(target, params, headers, + [&](cpr::Session& session) { return session.Delete(); }); +} + +template +Result HttpClient::Execute(const std::string& target, + const cpr::Parameters& params, + const cpr::Header& request_headers, + Func&& perform_request) { + cpr::Header combined_headers = default_headers_; + combined_headers.insert(request_headers.begin(), request_headers.end()); + + session_->SetUrl(cpr::Url{target}); + session_->SetParameters(params); + session_->SetHeader(combined_headers); + + cpr::Response response = perform_request(*session_); + return response; +} + +} // namespace iceberg::rest diff --git a/src/iceberg/catalog/rest/meson.build b/src/iceberg/catalog/rest/meson.build index 5f1f635ab..30d86b96a 100644 --- a/src/iceberg/catalog/rest/meson.build +++ b/src/iceberg/catalog/rest/meson.build @@ -15,7 +15,13 @@ # specific language governing permissions and limitations # under the License. -iceberg_rest_sources = files('json_internal.cc', 'rest_catalog.cc') +iceberg_rest_sources = files( + 'catalog.cc', + 'config.cc', + 'http_client_internal.cc', + 'json_internal.cc', + 'resource_path.cc', +) # cpr does not export symbols, so on Windows it must # be used as a static lib cpr_needs_static = ( @@ -46,4 +52,16 @@ iceberg_rest_dep = declare_dependency( meson.override_dependency('iceberg-rest', iceberg_rest_dep) pkg.generate(iceberg_rest_lib) -install_headers(['rest_catalog.h', 'types.h'], subdir: 'iceberg/catalog/rest') +install_headers( + [ + 'catalog.h', + 'config.h', + 'constant.h', + 'http_client_interal.h', + 'json_internal.h', + 'types.h', + 'util.h', + 'resource_path.h', + ], + subdir: 'iceberg/catalog/rest', +) diff --git a/src/iceberg/catalog/rest/resource_paths.cc b/src/iceberg/catalog/rest/resource_paths.cc new file mode 100644 index 000000000..24e750c9d --- /dev/null +++ b/src/iceberg/catalog/rest/resource_paths.cc @@ -0,0 +1,135 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +#include "iceberg/catalog/rest/resource_paths.h" + +#include + +#include "iceberg/catalog/rest/config.h" +#include "iceberg/catalog/rest/constant.h" +#include "iceberg/catalog/rest/endpoint_util.h" +#include "iceberg/result.h" +#include "iceberg/util/macros.h" + +namespace iceberg::rest { + +Result> ResourcePaths::Make( + const RestCatalogConfig& config) { + // Validate and extract URI + auto it = config.configs().find(std::string(RestCatalogConfig::kUri)); + if (it == config.configs().end() || it->second.empty()) { + return InvalidArgument("Rest catalog configuration property 'uri' is required."); + } + + std::string base_uri = std::string(TrimTrailingSlash(it->second)); + std::string prefix; + auto prefix_it = config.configs().find("prefix"); + if (prefix_it != config.configs().end() && !prefix_it->second.empty()) { + prefix = prefix_it->second; + } + + return std::unique_ptr( + new ResourcePaths(std::move(base_uri), std::move(prefix))); +} + +ResourcePaths::ResourcePaths(std::string base_uri, std::string prefix) + : base_uri_(std::move(base_uri)), prefix_(std::move(prefix)) {} + +std::string ResourcePaths::BuildPath(std::string_view path) const { + if (prefix_.empty()) { + return std::format("{}/v1/{}", base_uri_, path); + } + return std::format("{}/v1/{}/{}", base_uri_, prefix_, path); +} + +std::string ResourcePaths::V1Config() const { + return std::format("{}/v1/config", base_uri_); +} + +std::string ResourcePaths::V1OAuth2Tokens() const { + return std::format("{}/v1/oauth/tokens", base_uri_); +} + +std::string ResourcePaths::V1Namespaces() const { return BuildPath("namespaces"); } + +std::string ResourcePaths::V1Namespace(const Namespace& ns) const { + return BuildPath(std::format("namespaces/{}", EncodeNamespaceForUrl(ns))); +} + +std::string ResourcePaths::V1NamespaceProperties(const Namespace& ns) const { + return BuildPath(std::format("namespaces/{}/properties", EncodeNamespaceForUrl(ns))); +} + +std::string ResourcePaths::V1Tables(const Namespace& ns) const { + return BuildPath(std::format("namespaces/{}/tables", EncodeNamespaceForUrl(ns))); +} + +std::string ResourcePaths::V1Table(const TableIdentifier& table) const { + return BuildPath(std::format("namespaces/{}/tables/{}", EncodeNamespaceForUrl(table.ns), + table.name)); +} + +std::string ResourcePaths::V1RegisterTable(const Namespace& ns) const { + return BuildPath(std::format("namespaces/{}/register", EncodeNamespaceForUrl(ns))); +} + +std::string ResourcePaths::V1RenameTable() const { return BuildPath("tables/rename"); } + +std::string ResourcePaths::V1TableMetrics(const TableIdentifier& table) const { + return BuildPath(std::format("namespaces/{}/tables/{}/metrics", + EncodeNamespaceForUrl(table.ns), table.name)); +} + +std::string ResourcePaths::V1TableCredentials(const TableIdentifier& table) const { + return BuildPath(std::format("namespaces/{}/tables/{}/credentials", + EncodeNamespaceForUrl(table.ns), table.name)); +} + +std::string ResourcePaths::V1TableScanPlan(const TableIdentifier& table) const { + return BuildPath(std::format("namespaces/{}/tables/{}/plan", + EncodeNamespaceForUrl(table.ns), table.name)); +} + +std::string ResourcePaths::V1TableScanPlanResult(const TableIdentifier& table, + const std::string& plan_id) const { + return BuildPath(std::format("namespaces/{}/tables/{}/plan/{}", + EncodeNamespaceForUrl(table.ns), table.name, plan_id)); +} + +std::string ResourcePaths::V1TableTasks(const TableIdentifier& table) const { + return BuildPath(std::format("namespaces/{}/tables/{}/tasks", + EncodeNamespaceForUrl(table.ns), table.name)); +} + +std::string ResourcePaths::V1TransactionCommit() const { + return BuildPath("transactions/commit"); +} + +std::string ResourcePaths::V1Views(const Namespace& ns) const { + return BuildPath(std::format("namespaces/{}/views", EncodeNamespaceForUrl(ns))); +} + +std::string ResourcePaths::V1View(const TableIdentifier& view) const { + return BuildPath( + std::format("namespaces/{}/views/{}", EncodeNamespaceForUrl(view.ns), view.name)); +} + +std::string ResourcePaths::V1RenameView() const { return BuildPath("views/rename"); } + +} // namespace iceberg::rest diff --git a/src/iceberg/catalog/rest/resource_paths.h b/src/iceberg/catalog/rest/resource_paths.h new file mode 100644 index 000000000..19c0ec64b --- /dev/null +++ b/src/iceberg/catalog/rest/resource_paths.h @@ -0,0 +1,132 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +#pragma once + +#include +#include + +#include "iceberg/catalog/rest/config.h" +#include "iceberg/catalog/rest/iceberg_rest_export.h" +#include "iceberg/result.h" +#include "iceberg/table_identifier.h" + +/// \file iceberg/catalog/rest/resource_paths.h +/// \brief Resource path construction for Iceberg REST API endpoints. + +namespace iceberg::rest { + +/// \brief Resource path builder for Iceberg REST catalog endpoints. +/// +/// This class constructs REST API endpoint URLs for various catalog operations. +class ICEBERG_REST_EXPORT ResourcePaths { + public: + /// \brief Construct a ResourcePaths with REST catalog configuration. + /// \param config The REST catalog configuration containing the base URI + static Result> Make(const RestCatalogConfig& config); + + /// \brief Get the /v1/{prefix}/config endpoint path. + /// \return The endpoint URL + std::string V1Config() const; + + /// \brief Get the /v1/{prefix}/oauth/tokens endpoint path. + /// \return The endpoint URL + std::string V1OAuth2Tokens() const; + + /// \brief Get the /v1/{prefix}/namespaces endpoint path. + /// \return The endpoint URL + std::string V1Namespaces() const; + + /// \brief Get the /v1/{prefix}/namespaces/{namespace} endpoint path. + /// \return The endpoint URL + std::string V1Namespace(const Namespace& ns) const; + + /// \brief Get the /v1/{prefix}/namespaces/{namespace}/properties endpoint path. + /// \return The endpoint URL + std::string V1NamespaceProperties(const Namespace& ns) const; + + /// \brief Get the /v1/{prefix}/namespaces/{namespace}/tables endpoint path. + /// \return The endpoint URL + std::string V1Tables(const Namespace& ns) const; + + /// \brief Get the /v1/{prefix}/namespaces/{namespace}/tables/{table} endpoint path. + /// \return The endpoint URL + std::string V1Table(const TableIdentifier& table) const; + + /// \brief Get the /v1/{prefix}/namespaces/{namespace}/register endpoint path. + /// \return The endpoint URL + std::string V1RegisterTable(const Namespace& ns) const; + + /// \brief Get the /v1/{prefix}/tables/rename endpoint path. + /// \return The endpoint URL + std::string V1RenameTable() const; + + /// \brief Get the /v1/{prefix}/namespaces/{namespace}/tables/{table}/metrics endpoint + /// path. + /// \return The endpoint URL + std::string V1TableMetrics(const TableIdentifier& table) const; + + /// \brief Get the /v1/{prefix}/namespaces/{namespace}/tables/{table}/credentials + /// endpoint path. + /// \return The endpoint URL + std::string V1TableCredentials(const TableIdentifier& table) const; + + /// \brief Get the /v1/{prefix}/namespaces/{namespace}/tables/{table}/plan endpoint + /// path. + /// \return The endpoint URL + std::string V1TableScanPlan(const TableIdentifier& table) const; + + /// \brief Get the /v1/{prefix}/namespaces/{namespace}/tables/{table}/plan/{planId} + /// endpoint path. + /// \return The endpoint URL + std::string V1TableScanPlanResult(const TableIdentifier& table, + const std::string& plan_id) const; + + /// \brief Get the /v1/{prefix}/namespaces/{namespace}/tables/{table}/tasks endpoint + /// path. + /// \return The endpoint URL + std::string V1TableTasks(const TableIdentifier& table) const; + + /// \brief Get the /v1/{prefix}/transactions/commit endpoint path. + /// \return The endpoint URL + std::string V1TransactionCommit() const; + + /// \brief Get the /v1/{prefix}/namespaces/{namespace}/views endpoint path. + /// \return The endpoint URL + std::string V1Views(const Namespace& ns) const; + + /// \brief Get the /v1/{prefix}/namespaces/{namespace}/views/{view} endpoint path. + /// \return The endpoint URL + std::string V1View(const TableIdentifier& view) const; + + /// \brief Get the /v1/{prefix}/views/rename endpoint path. + /// \return The endpoint URL + std::string V1RenameView() const; + + private: + explicit ResourcePaths(std::string base_uri, std::string prefix); + + // Helper to build path with optional prefix: {base_uri_}/{prefix_?}/{path} + std::string BuildPath(std::string_view path) const; + + std::string base_uri_; // URI with /v1, e.g., "http://localhost:8181/v1" + std::string prefix_; // Optional prefix from config +}; + +} // namespace iceberg::rest diff --git a/src/iceberg/catalog/rest/rest_catalog.cc b/src/iceberg/catalog/rest/rest_catalog.cc deleted file mode 100644 index cd008e9b2..000000000 --- a/src/iceberg/catalog/rest/rest_catalog.cc +++ /dev/null @@ -1,44 +0,0 @@ -/* - * Licensed to the Apache Software Foundation (ASF) under one - * or more contributor license agreements. See the NOTICE file - * distributed with this work for additional information - * regarding copyright ownership. The ASF licenses this file - * to you under the Apache License, Version 2.0 (the - * "License"); you may not use this file except in compliance - * with the License. You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, - * software distributed under the License is distributed on an - * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY - * KIND, either express or implied. See the License for the - * specific language governing permissions and limitations - * under the License. - */ - -#include "iceberg/catalog/rest/rest_catalog.h" - -#include - -#include - -#include "iceberg/catalog/rest/types.h" - -namespace iceberg::catalog::rest { - -RestCatalog::RestCatalog(const std::string& base_url) : base_url_(std::move(base_url)) {} - -cpr::Response RestCatalog::GetConfig() { - cpr::Url url = cpr::Url{base_url_ + "/v1/config"}; - cpr::Response r = cpr::Get(url); - return r; -} - -cpr::Response RestCatalog::ListNamespaces() { - cpr::Url url = cpr::Url{base_url_ + "/v1/namespaces"}; - cpr::Response r = cpr::Get(url); - return r; -} - -} // namespace iceberg::catalog::rest diff --git a/src/iceberg/catalog/rest/rest_catalog.h b/src/iceberg/catalog/rest/rest_catalog.h deleted file mode 100644 index 7b3e205c1..000000000 --- a/src/iceberg/catalog/rest/rest_catalog.h +++ /dev/null @@ -1,41 +0,0 @@ -// Licensed to the Apache Software Foundation (ASF) under one -// or more contributor license agreements. See the NOTICE file -// distributed with this work for additional information -// regarding copyright ownership. The ASF licenses this file -// to you under the Apache License, Version 2.0 (the -// "License"); you may not use this file except in compliance -// with the License. You may obtain a copy of the License at -// -// http://www.apache.org/licenses/LICENSE-2.0 -// -// Unless required by applicable law or agreed to in writing, -// software distributed under the License is distributed on an -// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY -// KIND, either express or implied. See the License for the -// specific language governing permissions and limitations -// under the License. - -#pragma once - -#include - -#include - -#include "iceberg/catalog/rest/iceberg_rest_export.h" - -namespace iceberg::catalog::rest { - -class ICEBERG_REST_EXPORT RestCatalog { - public: - explicit RestCatalog(const std::string& base_url); - ~RestCatalog() = default; - - cpr::Response GetConfig(); - - cpr::Response ListNamespaces(); - - private: - std::string base_url_; -}; - -} // namespace iceberg::catalog::rest diff --git a/src/iceberg/test/rest_catalog_test.cc b/src/iceberg/test/rest_catalog_test.cc index fda9ef6de..7b30a36f7 100644 --- a/src/iceberg/test/rest_catalog_test.cc +++ b/src/iceberg/test/rest_catalog_test.cc @@ -17,95 +17,157 @@ * under the License. */ -#include "iceberg/catalog/rest/rest_catalog.h" - -#include - -#include -#include +#include +#include #include #include -#include -namespace iceberg::catalog::rest { +#include "iceberg/catalog/rest/catalog.h" +#include "iceberg/catalog/rest/config.h" +#include "iceberg/table_identifier.h" +#include "iceberg/test/matchers.h" -class RestCatalogIntegrationTest : public ::testing::Test { +namespace iceberg::rest { + +// Test fixture for REST catalog tests, This assumes you have a local REST catalog service +// running Default configuration: http://localhost:8181 +class RestCatalogTest : public ::testing::Test { protected: void SetUp() override { - server_ = std::make_unique(); - port_ = server_->bind_to_any_port("127.0.0.1"); - - server_thread_ = std::thread([this]() { server_->listen_after_bind(); }); + // Default configuration for local testing + // You can override this with environment variables if needed + const char* uri_env = std::getenv("ICEBERG_REST_URI"); + const char* warehouse_env = std::getenv("ICEBERG_REST_WAREHOUSE"); + + std::string uri = uri_env ? uri_env : "http://localhost:8181"; + std::string warehouse = warehouse_env ? warehouse_env : "default"; + + config_ + .Set(RestCatalogConfig::Entry{std::string(RestCatalogConfig::kUri), + ""}, + uri) + .Set(RestCatalogConfig::Entry{std::string(RestCatalogConfig::kName), + ""}, + std::string("test_catalog")) + .Set( + RestCatalogConfig::Entry{ + std::string(RestCatalogConfig::kWarehouse), ""}, + warehouse); } - void TearDown() override { - server_->stop(); - if (server_thread_.joinable()) { - server_thread_.join(); - } - } + void TearDown() override {} - std::unique_ptr server_; - int port_ = -1; - std::thread server_thread_; + RestCatalogConfig config_; }; -TEST_F(RestCatalogIntegrationTest, DISABLED_GetConfigSuccessfully) { - server_->Get("/v1/config", [](const httplib::Request&, httplib::Response& res) { - res.status = 200; - res.set_content(R"({"warehouse": "s3://test-bucket"})", "application/json"); - }); +TEST_F(RestCatalogTest, DISABLED_MakeCatalogSuccess) { + auto catalog_result = RestCatalog::Make(config_); + EXPECT_THAT(catalog_result, IsOk()); - std::string base_uri = "http://127.0.0.1:" + std::to_string(port_); - RestCatalog catalog(base_uri); - cpr::Response response = catalog.GetConfig(); + if (catalog_result.has_value()) { + auto& catalog = catalog_result.value(); + EXPECT_EQ(catalog->name(), "test_catalog"); + } +} - ASSERT_EQ(response.error.code, cpr::ErrorCode::OK); - ASSERT_EQ(response.status_code, 200); +TEST_F(RestCatalogTest, DISABLED_MakeCatalogEmptyUri) { + RestCatalogConfig invalid_config = config_; + invalid_config.Set( + RestCatalogConfig::Entry{std::string(RestCatalogConfig::kUri), ""}, + std::string("")); - auto json_body = nlohmann::json::parse(response.text); - EXPECT_EQ(json_body["warehouse"], "s3://test-bucket"); + auto catalog_result = RestCatalog::Make(invalid_config); + EXPECT_THAT(catalog_result, IsError(ErrorKind::kInvalidArgument)); + EXPECT_THAT(catalog_result, HasErrorMessage("uri")); } -TEST_F(RestCatalogIntegrationTest, DISABLED_ListNamespacesReturnsMultipleResults) { - server_->Get("/v1/namespaces", [](const httplib::Request&, httplib::Response& res) { - res.status = 200; - res.set_content(R"({ - "namespaces": [ - ["accounting", "db"], - ["production", "db"] - ] - })", - "application/json"); - }); - - std::string base_uri = "http://127.0.0.1:" + std::to_string(port_); - RestCatalog catalog(base_uri); - cpr::Response response = catalog.ListNamespaces(); - - ASSERT_EQ(response.error.code, cpr::ErrorCode::OK); - ASSERT_EQ(response.status_code, 200); - - auto json_body = nlohmann::json::parse(response.text); - ASSERT_TRUE(json_body.contains("namespaces")); - EXPECT_EQ(json_body["namespaces"].size(), 2); - EXPECT_THAT(json_body["namespaces"][0][0], "accounting"); +TEST_F(RestCatalogTest, DISABLED_MakeCatalogWithCustomProperties) { + RestCatalogConfig custom_config = config_; + custom_config + .Set(RestCatalogConfig::Entry{"custom_prop", ""}, + std::string("custom_value")) + .Set(RestCatalogConfig::Entry{"timeout", ""}, std::string("30000")); + + auto catalog_result = RestCatalog::Make(custom_config); + EXPECT_THAT(catalog_result, IsOk()); +} + +TEST_F(RestCatalogTest, DISABLED_ListNamespaces) { + auto catalog_result = RestCatalog::Make(config_); + ASSERT_THAT(catalog_result, IsOk()); + auto& catalog = catalog_result.value(); + + Namespace ns{.levels = {}}; + auto result = catalog->ListNamespaces(ns); + EXPECT_THAT(result, IsOk()); + EXPECT_FALSE(result->empty()); + EXPECT_EQ(result->front().levels, (std::vector{"my_namespace_test2"})); } -TEST_F(RestCatalogIntegrationTest, DISABLED_HandlesServerError) { - server_->Get("/v1/config", [](const httplib::Request&, httplib::Response& res) { - res.status = 500; - res.set_content("Internal Server Error", "text/plain"); - }); +TEST_F(RestCatalogTest, DISABLED_CreateNamespaceNotImplemented) { + auto catalog_result = RestCatalog::Make(config_); + ASSERT_THAT(catalog_result, IsOk()); + auto catalog = std::move(catalog_result.value()); + + Namespace ns{.levels = {"test_namespace"}}; + std::unordered_map props = {{"owner", "test"}}; - std::string base_uri = "http://127.0.0.1:" + std::to_string(port_); - RestCatalog catalog(base_uri); - cpr::Response response = catalog.GetConfig(); + auto result = catalog->CreateNamespace(ns, props); + EXPECT_THAT(result, IsError(ErrorKind::kNotImplemented)); +} - ASSERT_EQ(response.error.code, cpr::ErrorCode::OK); - ASSERT_EQ(response.status_code, 500); - ASSERT_EQ(response.text, "Internal Server Error"); +TEST_F(RestCatalogTest, DISABLED_IntegrationTestFullNamespaceWorkflow) { + auto catalog_result = RestCatalog::Make(config_); + ASSERT_THAT(catalog_result, IsOk()); + auto catalog = std::move(catalog_result.value()); + + // 1. List initial namespaces + Namespace root{.levels = {}}; + auto list_result1 = catalog->ListNamespaces(root); + ASSERT_THAT(list_result1, IsOk()); + size_t initial_count = list_result1->size(); + + // 2. Create a new namespace + Namespace test_ns{.levels = {"integration_test_ns"}}; + std::unordered_map props = { + {"owner", "test"}, {"created_by", "rest_catalog_test"}}; + auto create_result = catalog->CreateNamespace(test_ns, props); + EXPECT_THAT(create_result, IsOk()); + + // 3. Verify namespace exists + auto exists_result = catalog->NamespaceExists(test_ns); + EXPECT_THAT(exists_result, HasValue(::testing::Eq(true))); + + // 4. List namespaces again (should have one more) + auto list_result2 = catalog->ListNamespaces(root); + ASSERT_THAT(list_result2, IsOk()); + EXPECT_EQ(list_result2->size(), initial_count + 1); + + // 5. Get namespace properties + auto props_result = catalog->GetNamespaceProperties(test_ns); + ASSERT_THAT(props_result, IsOk()); + EXPECT_EQ((*props_result)["owner"], "test"); + + // 6. Update properties + std::unordered_map updates = { + {"description", "test namespace"}}; + std::unordered_set removals = {}; + auto update_result = catalog->UpdateNamespaceProperties(test_ns, updates, removals); + EXPECT_THAT(update_result, IsOk()); + + // 7. Verify updated properties + auto props_result2 = catalog->GetNamespaceProperties(test_ns); + ASSERT_THAT(props_result2, IsOk()); + EXPECT_EQ((*props_result2)["description"], "test namespace"); + + // 8. Drop the namespace (cleanup) + auto drop_result = catalog->DropNamespace(test_ns); + EXPECT_THAT(drop_result, IsOk()); + + // 9. Verify namespace no longer exists + auto exists_result2 = catalog->NamespaceExists(test_ns); + EXPECT_THAT(exists_result2, HasValue(::testing::Eq(false))); } -} // namespace iceberg::catalog::rest +} // namespace iceberg::rest