This is an automated email from the ASF dual-hosted git repository.
gangwu pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/iceberg-cpp.git
The following commit(s) were added to refs/heads/main by this push:
new a3c0d3c9 feat(rest): respect server-provided endpoints (#406)
a3c0d3c9 is described below
commit a3c0d3c92f98432d15b00c36c473e15c477b6b68
Author: Feiyang Li <[email protected]>
AuthorDate: Tue Dec 16 18:01:08 2025 +0800
feat(rest): respect server-provided endpoints (#406)
---
src/iceberg/catalog/rest/CMakeLists.txt | 3 +-
src/iceberg/catalog/rest/endpoint.cc | 90 ++++++++++
src/iceberg/catalog/rest/endpoint.h | 150 ++++++++++++++++
src/iceberg/catalog/rest/json_internal.cc | 16 +-
src/iceberg/catalog/rest/meson.build | 2 +
src/iceberg/catalog/rest/rest_catalog.cc | 115 ++++++++----
src/iceberg/catalog/rest/rest_catalog.h | 6 +-
src/iceberg/catalog/rest/rest_util.cc | 10 ++
src/iceberg/catalog/rest/rest_util.h | 11 ++
src/iceberg/catalog/rest/type_fwd.h | 1 +
src/iceberg/catalog/rest/types.h | 11 +-
src/iceberg/test/CMakeLists.txt | 5 +-
src/iceberg/test/endpoint_test.cc | 261 ++++++++++++++++++++++++++++
src/iceberg/test/rest_catalog_test.cc | 38 ++++
src/iceberg/test/rest_json_internal_test.cc | 31 +++-
src/iceberg/test/rest_util_test.cc | 17 ++
16 files changed, 717 insertions(+), 50 deletions(-)
diff --git a/src/iceberg/catalog/rest/CMakeLists.txt
b/src/iceberg/catalog/rest/CMakeLists.txt
index 881b3d39..7b36298a 100644
--- a/src/iceberg/catalog/rest/CMakeLists.txt
+++ b/src/iceberg/catalog/rest/CMakeLists.txt
@@ -16,12 +16,13 @@
# under the License.
set(ICEBERG_REST_SOURCES
- rest_catalog.cc
catalog_properties.cc
+ endpoint.cc
error_handlers.cc
http_client.cc
json_internal.cc
resource_paths.cc
+ rest_catalog.cc
rest_util.cc)
set(ICEBERG_REST_STATIC_BUILD_INTERFACE_LIBS)
diff --git a/src/iceberg/catalog/rest/endpoint.cc
b/src/iceberg/catalog/rest/endpoint.cc
new file mode 100644
index 00000000..bf457c87
--- /dev/null
+++ b/src/iceberg/catalog/rest/endpoint.cc
@@ -0,0 +1,90 @@
+/*
+ * 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/endpoint.h"
+
+#include <format>
+#include <string_view>
+
+namespace iceberg::rest {
+
+constexpr std::string_view ToString(HttpMethod method) {
+ switch (method) {
+ case HttpMethod::kGet:
+ return "GET";
+ case HttpMethod::kPost:
+ return "POST";
+ case HttpMethod::kPut:
+ return "PUT";
+ case HttpMethod::kDelete:
+ return "DELETE";
+ case HttpMethod::kHead:
+ return "HEAD";
+ }
+ return "UNKNOWN";
+}
+
+Result<Endpoint> Endpoint::Make(HttpMethod method, std::string_view path) {
+ if (path.empty()) {
+ return InvalidArgument("Endpoint cannot have empty path");
+ }
+ return Endpoint(method, path);
+}
+
+Result<Endpoint> Endpoint::FromString(std::string_view str) {
+ auto space_pos = str.find(' ');
+ if (space_pos == std::string_view::npos ||
+ str.find(' ', space_pos + 1) != std::string_view::npos) {
+ return InvalidArgument(
+ "Invalid endpoint format (must consist of two elements separated by a
single "
+ "space): '{}'",
+ str);
+ }
+
+ auto method_str = str.substr(0, space_pos);
+ auto path_str = str.substr(space_pos + 1);
+
+ if (path_str.empty()) {
+ return InvalidArgument("Invalid endpoint format: path is empty");
+ }
+
+ // Parse HTTP method
+ HttpMethod method;
+ if (method_str == "GET") {
+ method = HttpMethod::kGet;
+ } else if (method_str == "POST") {
+ method = HttpMethod::kPost;
+ } else if (method_str == "PUT") {
+ method = HttpMethod::kPut;
+ } else if (method_str == "DELETE") {
+ method = HttpMethod::kDelete;
+ } else if (method_str == "HEAD") {
+ method = HttpMethod::kHead;
+ } else {
+ return InvalidArgument("Invalid HTTP method: '{}'", method_str);
+ }
+
+ return Make(method, std::string(path_str));
+}
+
+std::string Endpoint::ToString() const {
+ return std::format("{} {}", rest::ToString(method_), path_);
+}
+
+} // namespace iceberg::rest
diff --git a/src/iceberg/catalog/rest/endpoint.h
b/src/iceberg/catalog/rest/endpoint.h
new file mode 100644
index 00000000..7382955c
--- /dev/null
+++ b/src/iceberg/catalog/rest/endpoint.h
@@ -0,0 +1,150 @@
+/*
+ * 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 <string>
+#include <string_view>
+
+#include "iceberg/catalog/rest/iceberg_rest_export.h"
+#include "iceberg/result.h"
+
+/// \file iceberg/catalog/rest/endpoint.h
+/// Endpoint definitions for Iceberg REST API operations.
+
+namespace iceberg::rest {
+
+/// \brief HTTP method enumeration.
+enum class HttpMethod : uint8_t { kGet, kPost, kPut, kDelete, kHead };
+
+/// \brief Convert HttpMethod to string representation.
+constexpr std::string_view ToString(HttpMethod method);
+
+/// \brief An Endpoint is an immutable value object identifying a specific
REST API
+/// operation. It consists of:
+/// - HTTP method (GET, POST, DELETE, etc.)
+/// - Path template (e.g., "/v1/{prefix}/namespaces/{namespace}")
+class ICEBERG_REST_EXPORT Endpoint {
+ public:
+ /// \brief Make an endpoint with method and path template.
+ ///
+ /// \param method HTTP method (GET, POST, etc.)
+ /// \param path Path template with placeholders (e.g., "/v1/{prefix}/tables")
+ /// \return Endpoint instance or error if invalid
+ static Result<Endpoint> Make(HttpMethod method, std::string_view path);
+
+ /// \brief Parse endpoint from string representation. "METHOD" have to be all
+ /// upper-cased.
+ ///
+ /// \param str String in format "METHOD /path/template" (e.g., "GET
/v1/namespaces")
+ /// \return Endpoint instance or error if malformed.
+ static Result<Endpoint> FromString(std::string_view str);
+
+ /// \brief Get the HTTP method.
+ constexpr HttpMethod method() const { return method_; }
+
+ /// \brief Get the path template.
+ std::string_view path() const { return path_; }
+
+ /// \brief Serialize to "METHOD /path" format.
+ std::string ToString() const;
+
+ constexpr bool operator==(const Endpoint& other) const {
+ return method_ == other.method_ && path_ == other.path_;
+ }
+
+ // Namespace endpoints
+ static Endpoint ListNamespaces() {
+ return {HttpMethod::kGet, "/v1/{prefix}/namespaces"};
+ }
+ static Endpoint GetNamespaceProperties() {
+ return {HttpMethod::kGet, "/v1/{prefix}/namespaces/{namespace}"};
+ }
+ static Endpoint NamespaceExists() {
+ return {HttpMethod::kHead, "/v1/{prefix}/namespaces/{namespace}"};
+ }
+ static Endpoint CreateNamespace() {
+ return {HttpMethod::kPost, "/v1/{prefix}/namespaces"};
+ }
+ static Endpoint UpdateNamespace() {
+ return {HttpMethod::kPost,
"/v1/{prefix}/namespaces/{namespace}/properties"};
+ }
+ static Endpoint DropNamespace() {
+ return {HttpMethod::kDelete, "/v1/{prefix}/namespaces/{namespace}"};
+ }
+
+ // Table endpoints
+ static Endpoint ListTables() {
+ return {HttpMethod::kGet, "/v1/{prefix}/namespaces/{namespace}/tables"};
+ }
+ static Endpoint LoadTable() {
+ return {HttpMethod::kGet,
"/v1/{prefix}/namespaces/{namespace}/tables/{table}"};
+ }
+ static Endpoint TableExists() {
+ return {HttpMethod::kHead,
"/v1/{prefix}/namespaces/{namespace}/tables/{table}"};
+ }
+ static Endpoint CreateTable() {
+ return {HttpMethod::kPost, "/v1/{prefix}/namespaces/{namespace}/tables"};
+ }
+ static Endpoint UpdateTable() {
+ return {HttpMethod::kPost,
"/v1/{prefix}/namespaces/{namespace}/tables/{table}"};
+ }
+ static Endpoint DeleteTable() {
+ return {HttpMethod::kDelete,
"/v1/{prefix}/namespaces/{namespace}/tables/{table}"};
+ }
+ static Endpoint RenameTable() {
+ return {HttpMethod::kPost, "/v1/{prefix}/tables/rename"};
+ }
+ static Endpoint RegisterTable() {
+ return {HttpMethod::kPost, "/v1/{prefix}/namespaces/{namespace}/register"};
+ }
+ static Endpoint ReportMetrics() {
+ return {HttpMethod::kPost,
+ "/v1/{prefix}/namespaces/{namespace}/tables/{table}/metrics"};
+ }
+ static Endpoint TableCredentials() {
+ return {HttpMethod::kGet,
+ "/v1/{prefix}/namespaces/{namespace}/tables/{table}/credentials"};
+ }
+
+ // Transaction endpoints
+ static Endpoint CommitTransaction() {
+ return {HttpMethod::kPost, "/v1/{prefix}/transactions/commit"};
+ }
+
+ private:
+ Endpoint(HttpMethod method, std::string_view path) : method_(method),
path_(path) {}
+
+ HttpMethod method_;
+ std::string path_;
+};
+
+} // namespace iceberg::rest
+
+// Specialize std::hash for Endpoint
+namespace std {
+template <>
+struct hash<iceberg::rest::Endpoint> {
+ std::size_t operator()(const iceberg::rest::Endpoint& endpoint) const
noexcept {
+ std::size_t h1 =
std::hash<int32_t>{}(static_cast<int32_t>(endpoint.method()));
+ std::size_t h2 = std::hash<std::string_view>{}(endpoint.path());
+ return h1 ^ (h2 + 0x9e3779b9 + (h1 << 6) + (h1 >> 2));
+ }
+};
+} // namespace std
diff --git a/src/iceberg/catalog/rest/json_internal.cc
b/src/iceberg/catalog/rest/json_internal.cc
index c60b406d..66e69025 100644
--- a/src/iceberg/catalog/rest/json_internal.cc
+++ b/src/iceberg/catalog/rest/json_internal.cc
@@ -73,7 +73,9 @@ nlohmann::json ToJson(const CatalogConfig& config) {
nlohmann::json json;
json[kOverrides] = config.overrides;
json[kDefaults] = config.defaults;
- SetContainerField(json, kEndpoints, config.endpoints);
+ for (const auto& endpoint : config.endpoints) {
+ json[kEndpoints].emplace_back(endpoint.ToString());
+ }
return json;
}
@@ -85,8 +87,16 @@ Result<CatalogConfig> CatalogConfigFromJson(const
nlohmann::json& json) {
ICEBERG_ASSIGN_OR_RAISE(
config.defaults, GetJsonValueOrDefault<decltype(config.defaults)>(json,
kDefaults));
ICEBERG_ASSIGN_OR_RAISE(
- config.endpoints,
- GetJsonValueOrDefault<std::vector<std::string>>(json, kEndpoints));
+ auto endpoints, GetJsonValueOrDefault<std::vector<std::string>>(json,
kEndpoints));
+ config.endpoints.reserve(endpoints.size());
+ for (const auto& endpoint_str : endpoints) {
+ auto endpoint_result = Endpoint::FromString(endpoint_str);
+ if (!endpoint_result.has_value()) {
+ // Convert to JsonParseError in JSON deserialization context
+ return JsonParseError("{}", endpoint_result.error().message);
+ }
+ config.endpoints.emplace_back(std::move(endpoint_result.value()));
+ }
ICEBERG_RETURN_UNEXPECTED(config.Validate());
return config;
}
diff --git a/src/iceberg/catalog/rest/meson.build
b/src/iceberg/catalog/rest/meson.build
index 8378b2a8..cda90c61 100644
--- a/src/iceberg/catalog/rest/meson.build
+++ b/src/iceberg/catalog/rest/meson.build
@@ -17,6 +17,7 @@
iceberg_rest_sources = files(
'catalog_properties.cc',
+ 'endpoint.cc',
'error_handlers.cc',
'http_client.cc',
'json_internal.cc',
@@ -58,6 +59,7 @@ install_headers(
[
'catalog_properties.h',
'constant.h',
+ 'endpoint.h',
'error_handlers.h',
'http_client.h',
'iceberg_rest_export.h',
diff --git a/src/iceberg/catalog/rest/rest_catalog.cc
b/src/iceberg/catalog/rest/rest_catalog.cc
index 4a77f658..b9dfaafc 100644
--- a/src/iceberg/catalog/rest/rest_catalog.cc
+++ b/src/iceberg/catalog/rest/rest_catalog.cc
@@ -21,18 +21,21 @@
#include <memory>
#include <unordered_map>
+#include <unordered_set>
#include <utility>
#include <nlohmann/json.hpp>
#include "iceberg/catalog/rest/catalog_properties.h"
#include "iceberg/catalog/rest/constant.h"
+#include "iceberg/catalog/rest/endpoint.h"
#include "iceberg/catalog/rest/error_handlers.h"
#include "iceberg/catalog/rest/http_client.h"
#include "iceberg/catalog/rest/json_internal.h"
#include "iceberg/catalog/rest/resource_paths.h"
#include "iceberg/catalog/rest/rest_catalog.h"
#include "iceberg/catalog/rest/rest_util.h"
+#include "iceberg/catalog/rest/types.h"
#include "iceberg/json_internal.h"
#include "iceberg/partition_spec.h"
#include "iceberg/result.h"
@@ -44,20 +47,30 @@ namespace iceberg::rest {
namespace {
-// Fetch server config and merge it with client config
-Result<std::unique_ptr<RestCatalogProperties>> FetchConfig(
- const ResourcePaths& paths, const RestCatalogProperties& config) {
- ICEBERG_ASSIGN_OR_RAISE(auto config_endpoint, paths.Config());
- HttpClient client(config.ExtractHeaders());
+/// \brief Get the default set of endpoints for backwards compatibility
according to the
+/// iceberg rest spec.
+std::unordered_set<Endpoint> GetDefaultEndpoints() {
+ return {
+ Endpoint::ListNamespaces(), Endpoint::GetNamespaceProperties(),
+ Endpoint::CreateNamespace(), Endpoint::UpdateNamespace(),
+ Endpoint::DropNamespace(), Endpoint::ListTables(),
+ Endpoint::LoadTable(), Endpoint::CreateTable(),
+ Endpoint::UpdateTable(), Endpoint::DeleteTable(),
+ Endpoint::RenameTable(), Endpoint::RegisterTable(),
+ Endpoint::ReportMetrics(), Endpoint::CommitTransaction(),
+ };
+}
+
+/// \brief Fetch server config and merge it with client config
+Result<CatalogConfig> FetchServerConfig(const ResourcePaths& paths,
+ const RestCatalogProperties&
current_config) {
+ ICEBERG_ASSIGN_OR_RAISE(auto config_path, paths.Config());
+ HttpClient client(current_config.ExtractHeaders());
ICEBERG_ASSIGN_OR_RAISE(const auto response,
- client.Get(config_endpoint, /*params=*/{},
/*headers=*/{},
+ client.Get(config_path, /*params=*/{},
/*headers=*/{},
*DefaultErrorHandler::Instance()));
ICEBERG_ASSIGN_OR_RAISE(auto json, FromJsonString(response.body()));
- ICEBERG_ASSIGN_OR_RAISE(auto server_config, CatalogConfigFromJson(json));
-
- // Merge priorities: server overrides > client config > server defaults
- return RestCatalogProperties::FromMap(
- MergeConfigs(server_config.overrides, config.configs(),
server_config.defaults));
+ return CatalogConfigFromJson(json);
}
} // namespace
@@ -70,27 +83,46 @@ Result<std::unique_ptr<RestCatalog>> RestCatalog::Make(
ICEBERG_ASSIGN_OR_RAISE(
auto paths, ResourcePaths::Make(std::string(TrimTrailingSlash(uri)),
config.Get(RestCatalogProperties::kPrefix)));
- ICEBERG_ASSIGN_OR_RAISE(auto final_config, FetchConfig(*paths, config));
+ ICEBERG_ASSIGN_OR_RAISE(auto server_config, FetchServerConfig(*paths,
config));
+
+ std::unique_ptr<RestCatalogProperties> final_config =
RestCatalogProperties::FromMap(
+ MergeConfigs(server_config.overrides, config.configs(),
server_config.defaults));
+
+ std::unordered_set<Endpoint> endpoints;
+ if (!server_config.endpoints.empty()) {
+ // Endpoints are already parsed during JSON deserialization, just convert
to set
+ endpoints = std::unordered_set<Endpoint>(server_config.endpoints.begin(),
+ server_config.endpoints.end());
+ } else {
+ // If a server does not send the endpoints field, use default set of
endpoints
+ // for backwards compatibility with legacy servers
+ endpoints = GetDefaultEndpoints();
+ }
// Update resource paths based on the final config
ICEBERG_ASSIGN_OR_RAISE(auto final_uri, final_config->Uri());
ICEBERG_RETURN_UNEXPECTED(paths->SetBaseUri(std::string(TrimTrailingSlash(final_uri))));
return std::unique_ptr<RestCatalog>(
- new RestCatalog(std::move(final_config), std::move(paths)));
+ new RestCatalog(std::move(final_config), std::move(paths),
std::move(endpoints)));
}
RestCatalog::RestCatalog(std::unique_ptr<RestCatalogProperties> config,
- std::unique_ptr<ResourcePaths> paths)
+ std::unique_ptr<ResourcePaths> paths,
+ std::unordered_set<Endpoint> endpoints)
: config_(std::move(config)),
client_(std::make_unique<HttpClient>(config_->ExtractHeaders())),
paths_(std::move(paths)),
- name_(config_->Get(RestCatalogProperties::kName)) {}
+ name_(config_->Get(RestCatalogProperties::kName)),
+ supported_endpoints_(std::move(endpoints)) {}
std::string_view RestCatalog::name() const { return name_; }
Result<std::vector<Namespace>> RestCatalog::ListNamespaces(const Namespace&
ns) const {
- ICEBERG_ASSIGN_OR_RAISE(auto endpoint, paths_->Namespaces());
+ ICEBERG_RETURN_UNEXPECTED(
+ CheckEndpoint(supported_endpoints_, Endpoint::ListNamespaces()));
+
+ ICEBERG_ASSIGN_OR_RAISE(auto path, paths_->Namespaces());
std::vector<Namespace> result;
std::string next_token;
while (true) {
@@ -101,9 +133,9 @@ Result<std::vector<Namespace>>
RestCatalog::ListNamespaces(const Namespace& ns)
if (!next_token.empty()) {
params[kQueryParamPageToken] = next_token;
}
- ICEBERG_ASSIGN_OR_RAISE(const auto response,
- client_->Get(endpoint, params, /*headers=*/{},
- *NamespaceErrorHandler::Instance()));
+ ICEBERG_ASSIGN_OR_RAISE(
+ const auto response,
+ client_->Get(path, params, /*headers=*/{},
*NamespaceErrorHandler::Instance()));
ICEBERG_ASSIGN_OR_RAISE(auto json, FromJsonString(response.body()));
ICEBERG_ASSIGN_OR_RAISE(auto list_response,
ListNamespacesResponseFromJson(json));
result.insert(result.end(), list_response.namespaces.begin(),
@@ -118,11 +150,14 @@ Result<std::vector<Namespace>>
RestCatalog::ListNamespaces(const Namespace& ns)
Status RestCatalog::CreateNamespace(
const Namespace& ns, const std::unordered_map<std::string, std::string>&
properties) {
- ICEBERG_ASSIGN_OR_RAISE(auto endpoint, paths_->Namespaces());
+ ICEBERG_RETURN_UNEXPECTED(
+ CheckEndpoint(supported_endpoints_, Endpoint::CreateNamespace()));
+
+ ICEBERG_ASSIGN_OR_RAISE(auto path, paths_->Namespaces());
CreateNamespaceRequest request{.namespace_ = ns, .properties = properties};
ICEBERG_ASSIGN_OR_RAISE(auto json_request, ToJsonString(ToJson(request)));
ICEBERG_ASSIGN_OR_RAISE(const auto response,
- client_->Post(endpoint, json_request, /*headers=*/{},
+ client_->Post(path, json_request, /*headers=*/{},
*NamespaceErrorHandler::Instance()));
ICEBERG_ASSIGN_OR_RAISE(auto json, FromJsonString(response.body()));
ICEBERG_ASSIGN_OR_RAISE(auto create_response,
CreateNamespaceResponseFromJson(json));
@@ -131,9 +166,12 @@ Status RestCatalog::CreateNamespace(
Result<std::unordered_map<std::string, std::string>>
RestCatalog::GetNamespaceProperties(
const Namespace& ns) const {
- ICEBERG_ASSIGN_OR_RAISE(auto endpoint, paths_->Namespace_(ns));
+ ICEBERG_RETURN_UNEXPECTED(
+ CheckEndpoint(supported_endpoints_, Endpoint::GetNamespaceProperties()));
+
+ ICEBERG_ASSIGN_OR_RAISE(auto path, paths_->Namespace_(ns));
ICEBERG_ASSIGN_OR_RAISE(const auto response,
- client_->Get(endpoint, /*params=*/{}, /*headers=*/{},
+ client_->Get(path, /*params=*/{}, /*headers=*/{},
*NamespaceErrorHandler::Instance()));
ICEBERG_ASSIGN_OR_RAISE(auto json, FromJsonString(response.body()));
ICEBERG_ASSIGN_OR_RAISE(auto get_response,
GetNamespaceResponseFromJson(json));
@@ -141,19 +179,31 @@ Result<std::unordered_map<std::string, std::string>>
RestCatalog::GetNamespacePr
}
Status RestCatalog::DropNamespace(const Namespace& ns) {
- ICEBERG_ASSIGN_OR_RAISE(auto endpoint, paths_->Namespace_(ns));
+ ICEBERG_RETURN_UNEXPECTED(
+ CheckEndpoint(supported_endpoints_, Endpoint::DropNamespace()));
+ ICEBERG_ASSIGN_OR_RAISE(auto path, paths_->Namespace_(ns));
ICEBERG_ASSIGN_OR_RAISE(
const auto response,
- client_->Delete(endpoint, /*headers=*/{},
*DropNamespaceErrorHandler::Instance()));
+ client_->Delete(path, /*headers=*/{},
*DropNamespaceErrorHandler::Instance()));
return {};
}
Result<bool> RestCatalog::NamespaceExists(const Namespace& ns) const {
- ICEBERG_ASSIGN_OR_RAISE(auto endpoint, paths_->Namespace_(ns));
- // TODO(Feiyang Li): checks if the server supports the namespace exists
endpoint, if
- // not, triggers a fallback mechanism
+ auto check = CheckEndpoint(supported_endpoints_,
Endpoint::NamespaceExists());
+ if (!check.has_value()) {
+ // Fall back to GetNamespaceProperties
+ auto result = GetNamespaceProperties(ns);
+ if (!result.has_value() && result.error().kind ==
ErrorKind::kNoSuchNamespace) {
+ return false;
+ }
+ ICEBERG_RETURN_UNEXPECTED(result);
+ // GET succeeded, namespace exists
+ return true;
+ }
+
+ ICEBERG_ASSIGN_OR_RAISE(auto path, paths_->Namespace_(ns));
auto response_or_error =
- client_->Head(endpoint, /*headers=*/{},
*NamespaceErrorHandler::Instance());
+ client_->Head(path, /*headers=*/{}, *NamespaceErrorHandler::Instance());
if (!response_or_error.has_value()) {
const auto& error = response_or_error.error();
// catch NoSuchNamespaceException/404 and return false
@@ -168,13 +218,16 @@ Result<bool> RestCatalog::NamespaceExists(const
Namespace& ns) const {
Status RestCatalog::UpdateNamespaceProperties(
const Namespace& ns, const std::unordered_map<std::string, std::string>&
updates,
const std::unordered_set<std::string>& removals) {
- ICEBERG_ASSIGN_OR_RAISE(auto endpoint, paths_->NamespaceProperties(ns));
+ ICEBERG_RETURN_UNEXPECTED(
+ CheckEndpoint(supported_endpoints_, Endpoint::UpdateNamespace()));
+
+ ICEBERG_ASSIGN_OR_RAISE(auto path, paths_->NamespaceProperties(ns));
UpdateNamespacePropertiesRequest request{
.removals = std::vector<std::string>(removals.begin(), removals.end()),
.updates = updates};
ICEBERG_ASSIGN_OR_RAISE(auto json_request, ToJsonString(ToJson(request)));
ICEBERG_ASSIGN_OR_RAISE(const auto response,
- client_->Post(endpoint, json_request, /*headers=*/{},
+ client_->Post(path, json_request, /*headers=*/{},
*NamespaceErrorHandler::Instance()));
ICEBERG_ASSIGN_OR_RAISE(auto json, FromJsonString(response.body()));
ICEBERG_ASSIGN_OR_RAISE(auto update_response,
diff --git a/src/iceberg/catalog/rest/rest_catalog.h
b/src/iceberg/catalog/rest/rest_catalog.h
index 4e191e86..c8ddca58 100644
--- a/src/iceberg/catalog/rest/rest_catalog.h
+++ b/src/iceberg/catalog/rest/rest_catalog.h
@@ -20,9 +20,11 @@
#pragma once
#include <memory>
+#include <set>
#include <string>
#include "iceberg/catalog.h"
+#include "iceberg/catalog/rest/endpoint.h"
#include "iceberg/catalog/rest/iceberg_rest_export.h"
#include "iceberg/catalog/rest/type_fwd.h"
#include "iceberg/result.h"
@@ -98,12 +100,14 @@ class ICEBERG_REST_EXPORT RestCatalog : public Catalog {
private:
RestCatalog(std::unique_ptr<RestCatalogProperties> config,
- std::unique_ptr<ResourcePaths> paths);
+ std::unique_ptr<ResourcePaths> paths,
+ std::unordered_set<Endpoint> endpoints);
std::unique_ptr<RestCatalogProperties> config_;
std::unique_ptr<HttpClient> client_;
std::unique_ptr<ResourcePaths> paths_;
std::string name_;
+ std::unordered_set<Endpoint> supported_endpoints_;
};
} // namespace iceberg::rest
diff --git a/src/iceberg/catalog/rest/rest_util.cc
b/src/iceberg/catalog/rest/rest_util.cc
index a1a63fa1..62b9bfc3 100644
--- a/src/iceberg/catalog/rest/rest_util.cc
+++ b/src/iceberg/catalog/rest/rest_util.cc
@@ -20,9 +20,11 @@
#include "iceberg/catalog/rest/rest_util.h"
#include <format>
+#include <unordered_set>
#include <cpr/util.h>
+#include "iceberg/catalog/rest/endpoint.h"
#include "iceberg/table_identifier.h"
#include "iceberg/util/macros.h"
@@ -251,4 +253,12 @@ std::string GetStandardReasonPhrase(int32_t status_code) {
}
}
+Status CheckEndpoint(const std::unordered_set<Endpoint>& supported_endpoints,
+ const Endpoint& endpoint) {
+ if (!supported_endpoints.contains(endpoint)) {
+ return NotSupported("Server does not support endpoint: {}",
endpoint.ToString());
+ }
+ return {};
+}
+
} // namespace iceberg::rest
diff --git a/src/iceberg/catalog/rest/rest_util.h
b/src/iceberg/catalog/rest/rest_util.h
index fde67a84..5734bbc7 100644
--- a/src/iceberg/catalog/rest/rest_util.h
+++ b/src/iceberg/catalog/rest/rest_util.h
@@ -22,8 +22,11 @@
#include <string>
#include <string_view>
#include <unordered_map>
+#include <unordered_set>
+#include "iceberg/catalog/rest/endpoint.h"
#include "iceberg/catalog/rest/iceberg_rest_export.h"
+#include "iceberg/catalog/rest/type_fwd.h"
#include "iceberg/result.h"
#include "iceberg/type_fwd.h"
@@ -90,4 +93,12 @@ ICEBERG_REST_EXPORT std::unordered_map<std::string,
std::string> MergeConfigs(
/// Error").
ICEBERG_REST_EXPORT std::string GetStandardReasonPhrase(int32_t status_code);
+/// \brief Check whether the given endpoint is in the set of supported
endpoints.
+///
+/// \param supported_endpoints Set of endpoints advertised by the server
+/// \param endpoint Endpoint to validate
+/// \return Status::OK if supported, NotSupported error otherwise
+ICEBERG_REST_EXPORT Status CheckEndpoint(
+ const std::unordered_set<Endpoint>& supported_endpoints, const Endpoint&
endpoint);
+
} // namespace iceberg::rest
diff --git a/src/iceberg/catalog/rest/type_fwd.h
b/src/iceberg/catalog/rest/type_fwd.h
index f63984b3..e7fddb91 100644
--- a/src/iceberg/catalog/rest/type_fwd.h
+++ b/src/iceberg/catalog/rest/type_fwd.h
@@ -26,6 +26,7 @@ namespace iceberg::rest {
struct ErrorResponse;
+class Endpoint;
class ErrorHandler;
class HttpClient;
class ResourcePaths;
diff --git a/src/iceberg/catalog/rest/types.h b/src/iceberg/catalog/rest/types.h
index 7760e178..afcd65b9 100644
--- a/src/iceberg/catalog/rest/types.h
+++ b/src/iceberg/catalog/rest/types.h
@@ -24,6 +24,7 @@
#include <unordered_map>
#include <vector>
+#include "iceberg/catalog/rest/endpoint.h"
#include "iceberg/catalog/rest/iceberg_rest_export.h"
#include "iceberg/result.h"
#include "iceberg/table_identifier.h"
@@ -39,16 +40,10 @@ namespace iceberg::rest {
struct ICEBERG_REST_EXPORT CatalogConfig {
std::unordered_map<std::string, std::string> defaults; // required
std::unordered_map<std::string, std::string> overrides; // required
- std::vector<std::string> endpoints;
+ std::vector<Endpoint> endpoints;
/// \brief Validates the CatalogConfig.
- Status Validate() const {
- // TODO(Li Feiyang): Add an invalidEndpoint test that validates endpoint
format.
- // See:
- //
https://github.com/apache/iceberg/blob/main/core/src/test/java/org/apache/iceberg/rest/responses/TestConfigResponseParser.java#L164
- // for reference.
- return {};
- }
+ Status Validate() const { return {}; }
};
/// \brief JSON error payload returned in a response with further details on
the error.
diff --git a/src/iceberg/test/CMakeLists.txt b/src/iceberg/test/CMakeLists.txt
index a4856713..c831ce02 100644
--- a/src/iceberg/test/CMakeLists.txt
+++ b/src/iceberg/test/CMakeLists.txt
@@ -170,7 +170,10 @@ if(ICEBERG_BUILD_REST)
add_test(NAME ${test_name} COMMAND ${test_name})
endfunction()
- add_rest_iceberg_test(rest_catalog_test SOURCES rest_json_internal_test.cc
+ add_rest_iceberg_test(rest_catalog_test
+ SOURCES
+ endpoint_test.cc
+ rest_json_internal_test.cc
rest_util_test.cc)
if(ICEBERG_BUILD_REST_INTEGRATION_TESTS)
diff --git a/src/iceberg/test/endpoint_test.cc
b/src/iceberg/test/endpoint_test.cc
new file mode 100644
index 00000000..fcdc92a7
--- /dev/null
+++ b/src/iceberg/test/endpoint_test.cc
@@ -0,0 +1,261 @@
+/*
+ * 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/endpoint.h"
+
+#include <gtest/gtest.h>
+#include <nlohmann/json.hpp>
+
+#include "iceberg/test/matchers.h"
+
+namespace iceberg::rest {
+
+TEST(EndpointTest, InvalidCreate) {
+ // Empty path template should fail
+ auto result = Endpoint::Make(HttpMethod::kGet, "");
+ EXPECT_THAT(result, IsError(ErrorKind::kInvalidArgument));
+ EXPECT_THAT(result, HasErrorMessage("Endpoint cannot have empty path"));
+}
+
+TEST(EndpointTest, ValidFromString) {
+ auto result = Endpoint::FromString("GET /path");
+ EXPECT_THAT(result, IsOk());
+
+ auto endpoint = result.value();
+ EXPECT_EQ(endpoint.method(), HttpMethod::kGet);
+ EXPECT_EQ(endpoint.path(), "/path");
+}
+
+// Test all HTTP methods
+TEST(EndpointTest, AllHttpMethods) {
+ auto get = Endpoint::Make(HttpMethod::kGet, "/path");
+ ASSERT_THAT(get, IsOk());
+ EXPECT_EQ(get->ToString(), "GET /path");
+
+ auto post = Endpoint::Make(HttpMethod::kPost, "/path");
+ ASSERT_THAT(post, IsOk());
+ EXPECT_EQ(post->ToString(), "POST /path");
+
+ auto put = Endpoint::Make(HttpMethod::kPut, "/path");
+ ASSERT_THAT(put, IsOk());
+ EXPECT_EQ(put->ToString(), "PUT /path");
+
+ auto del = Endpoint::Make(HttpMethod::kDelete, "/path");
+ ASSERT_THAT(del, IsOk());
+ EXPECT_EQ(del->ToString(), "DELETE /path");
+
+ auto head = Endpoint::Make(HttpMethod::kHead, "/path");
+ ASSERT_THAT(head, IsOk());
+ EXPECT_EQ(head->ToString(), "HEAD /path");
+}
+
+// Test predefined namespace endpoints
+TEST(EndpointTest, NamespaceEndpoints) {
+ auto list_namespaces = Endpoint::ListNamespaces();
+ EXPECT_EQ(list_namespaces.method(), HttpMethod::kGet);
+ EXPECT_EQ(list_namespaces.path(), "/v1/{prefix}/namespaces");
+ EXPECT_EQ(list_namespaces.ToString(), "GET /v1/{prefix}/namespaces");
+
+ auto get_namespace = Endpoint::GetNamespaceProperties();
+ EXPECT_EQ(get_namespace.method(), HttpMethod::kGet);
+ EXPECT_EQ(get_namespace.path(), "/v1/{prefix}/namespaces/{namespace}");
+
+ auto namespace_exists = Endpoint::NamespaceExists();
+ EXPECT_EQ(namespace_exists.method(), HttpMethod::kHead);
+ EXPECT_EQ(namespace_exists.path(), "/v1/{prefix}/namespaces/{namespace}");
+
+ auto create_namespace = Endpoint::CreateNamespace();
+ EXPECT_EQ(create_namespace.method(), HttpMethod::kPost);
+ EXPECT_EQ(create_namespace.path(), "/v1/{prefix}/namespaces");
+
+ auto update_namespace = Endpoint::UpdateNamespace();
+ EXPECT_EQ(update_namespace.method(), HttpMethod::kPost);
+ EXPECT_EQ(update_namespace.path(),
"/v1/{prefix}/namespaces/{namespace}/properties");
+
+ auto drop_namespace = Endpoint::DropNamespace();
+ EXPECT_EQ(drop_namespace.method(), HttpMethod::kDelete);
+ EXPECT_EQ(drop_namespace.path(), "/v1/{prefix}/namespaces/{namespace}");
+}
+
+// Test predefined table endpoints
+TEST(EndpointTest, TableEndpoints) {
+ auto list_tables = Endpoint::ListTables();
+ EXPECT_EQ(list_tables.method(), HttpMethod::kGet);
+ EXPECT_EQ(list_tables.path(), "/v1/{prefix}/namespaces/{namespace}/tables");
+
+ auto load_table = Endpoint::LoadTable();
+ EXPECT_EQ(load_table.method(), HttpMethod::kGet);
+ EXPECT_EQ(load_table.path(),
"/v1/{prefix}/namespaces/{namespace}/tables/{table}");
+
+ auto table_exists = Endpoint::TableExists();
+ EXPECT_EQ(table_exists.method(), HttpMethod::kHead);
+ EXPECT_EQ(table_exists.path(),
"/v1/{prefix}/namespaces/{namespace}/tables/{table}");
+
+ auto create_table = Endpoint::CreateTable();
+ EXPECT_EQ(create_table.method(), HttpMethod::kPost);
+ EXPECT_EQ(create_table.path(), "/v1/{prefix}/namespaces/{namespace}/tables");
+
+ auto update_table = Endpoint::UpdateTable();
+ EXPECT_EQ(update_table.method(), HttpMethod::kPost);
+ EXPECT_EQ(update_table.path(),
"/v1/{prefix}/namespaces/{namespace}/tables/{table}");
+
+ auto delete_table = Endpoint::DeleteTable();
+ EXPECT_EQ(delete_table.method(), HttpMethod::kDelete);
+ EXPECT_EQ(delete_table.path(),
"/v1/{prefix}/namespaces/{namespace}/tables/{table}");
+
+ auto rename_table = Endpoint::RenameTable();
+ EXPECT_EQ(rename_table.method(), HttpMethod::kPost);
+ EXPECT_EQ(rename_table.path(), "/v1/{prefix}/tables/rename");
+
+ auto register_table = Endpoint::RegisterTable();
+ EXPECT_EQ(register_table.method(), HttpMethod::kPost);
+ EXPECT_EQ(register_table.path(),
"/v1/{prefix}/namespaces/{namespace}/register");
+
+ auto report_metrics = Endpoint::ReportMetrics();
+ EXPECT_EQ(report_metrics.method(), HttpMethod::kPost);
+ EXPECT_EQ(report_metrics.path(),
+ "/v1/{prefix}/namespaces/{namespace}/tables/{table}/metrics");
+
+ auto table_credentials = Endpoint::TableCredentials();
+ EXPECT_EQ(table_credentials.method(), HttpMethod::kGet);
+ EXPECT_EQ(table_credentials.path(),
+ "/v1/{prefix}/namespaces/{namespace}/tables/{table}/credentials");
+}
+
+// Test predefined transaction endpoints
+TEST(EndpointTest, TransactionEndpoints) {
+ auto commit_transaction = Endpoint::CommitTransaction();
+ EXPECT_EQ(commit_transaction.method(), HttpMethod::kPost);
+ EXPECT_EQ(commit_transaction.path(), "/v1/{prefix}/transactions/commit");
+}
+
+// Test endpoint equality
+TEST(EndpointTest, Equality) {
+ auto endpoint1 = Endpoint::Make(HttpMethod::kGet, "/path");
+ auto endpoint2 = Endpoint::Make(HttpMethod::kGet, "/path");
+ auto endpoint3 = Endpoint::Make(HttpMethod::kPost, "/path");
+ auto endpoint4 = Endpoint::Make(HttpMethod::kGet, "/other");
+
+ ASSERT_THAT(endpoint1, IsOk());
+ ASSERT_THAT(endpoint2, IsOk());
+ ASSERT_THAT(endpoint3, IsOk());
+ ASSERT_THAT(endpoint4, IsOk());
+
+ // Equality
+ EXPECT_EQ(*endpoint1, *endpoint2);
+ EXPECT_NE(*endpoint1, *endpoint3);
+ EXPECT_NE(*endpoint1, *endpoint4);
+}
+
+// Test string serialization
+TEST(EndpointTest, ToStringFormat) {
+ auto endpoint1 = Endpoint::Make(HttpMethod::kGet, "/v1/{prefix}/namespaces");
+ ASSERT_THAT(endpoint1, IsOk());
+ EXPECT_EQ(endpoint1->ToString(), "GET /v1/{prefix}/namespaces");
+
+ auto endpoint2 = Endpoint::Make(HttpMethod::kPost, "/v1/{prefix}/tables");
+ ASSERT_THAT(endpoint2, IsOk());
+ EXPECT_EQ(endpoint2->ToString(), "POST /v1/{prefix}/tables");
+
+ // Test with all HTTP methods
+ auto endpoint3 = Endpoint::Make(HttpMethod::kDelete, "/path");
+ ASSERT_THAT(endpoint3, IsOk());
+ EXPECT_EQ(endpoint3->ToString(), "DELETE /path");
+
+ auto endpoint4 = Endpoint::Make(HttpMethod::kPut, "/path");
+ ASSERT_THAT(endpoint4, IsOk());
+ EXPECT_EQ(endpoint4->ToString(), "PUT /path");
+
+ auto endpoint5 = Endpoint::Make(HttpMethod::kHead, "/path");
+ ASSERT_THAT(endpoint5, IsOk());
+ EXPECT_EQ(endpoint5->ToString(), "HEAD /path");
+}
+
+// Test string deserialization
+TEST(EndpointTest, FromStringParsing) {
+ auto result1 = Endpoint::FromString("GET /v1/{prefix}/namespaces");
+ ASSERT_THAT(result1, IsOk());
+ EXPECT_EQ(result1->method(), HttpMethod::kGet);
+ EXPECT_EQ(result1->path(), "/v1/{prefix}/namespaces");
+
+ auto result2 = Endpoint::FromString("POST
/v1/{prefix}/namespaces/{namespace}/tables");
+ ASSERT_THAT(result2, IsOk());
+ EXPECT_EQ(result2->method(), HttpMethod::kPost);
+ EXPECT_EQ(result2->path(), "/v1/{prefix}/namespaces/{namespace}/tables");
+
+ // Test all HTTP methods
+ auto result3 = Endpoint::FromString("DELETE /path");
+ ASSERT_THAT(result3, IsOk());
+ EXPECT_EQ(result3->method(), HttpMethod::kDelete);
+
+ auto result4 = Endpoint::FromString("PUT /path");
+ ASSERT_THAT(result4, IsOk());
+ EXPECT_EQ(result4->method(), HttpMethod::kPut);
+
+ auto result5 = Endpoint::FromString("HEAD /path");
+ ASSERT_THAT(result5, IsOk());
+ EXPECT_EQ(result5->method(), HttpMethod::kHead);
+}
+
+// Test string parsing with invalid inputs
+TEST(EndpointTest, FromStringInvalid) {
+ // Invalid endpoint format should fail - missing space
+ auto result1 = Endpoint::FromString("/path/without/method");
+ EXPECT_THAT(result1, IsError(ErrorKind::kInvalidArgument));
+ EXPECT_THAT(result1,
+ HasErrorMessage("Invalid endpoint format (must consist of two
elements "
+ "separated by a single space)"));
+
+ // Invalid HTTP method should fail
+ auto result2 = Endpoint::FromString("INVALID /path");
+ EXPECT_THAT(result2, IsError(ErrorKind::kInvalidArgument));
+ EXPECT_THAT(result2, HasErrorMessage("Invalid HTTP method"));
+
+ // Invalid endpoint format - extra element after path
+ auto result3 = Endpoint::FromString("GET /path INVALID");
+ EXPECT_THAT(result3, IsError(ErrorKind::kInvalidArgument));
+ EXPECT_THAT(result3,
+ HasErrorMessage("Invalid endpoint format (must consist of two
elements "
+ "separated by a single space)"));
+}
+
+// Test string round-trip
+TEST(EndpointTest, StringRoundTrip) {
+ // Create various endpoints and verify they survive string round-trip
+ std::vector<Endpoint> endpoints = {
+ Endpoint::ListNamespaces(), Endpoint::GetNamespaceProperties(),
+ Endpoint::CreateNamespace(), Endpoint::LoadTable(),
+ Endpoint::CreateTable(), Endpoint::DeleteTable(),
+ };
+
+ for (const auto& original : endpoints) {
+ // Serialize to string
+ std::string str = original.ToString();
+
+ // Deserialize from string
+ auto deserialized = Endpoint::FromString(str);
+ ASSERT_THAT(deserialized, IsOk());
+
+ // Verify they are equal
+ EXPECT_EQ(original, *deserialized);
+ EXPECT_EQ(original.ToString(), deserialized->ToString());
+ }
+}
+
+} // namespace iceberg::rest
diff --git a/src/iceberg/test/rest_catalog_test.cc
b/src/iceberg/test/rest_catalog_test.cc
index 49c527f6..725ad7ec 100644
--- a/src/iceberg/test/rest_catalog_test.cc
+++ b/src/iceberg/test/rest_catalog_test.cc
@@ -32,9 +32,13 @@
#include <gmock/gmock.h>
#include <gtest/gtest.h>
#include <netinet/in.h>
+#include <nlohmann/json.hpp>
#include <sys/socket.h>
#include "iceberg/catalog/rest/catalog_properties.h"
+#include "iceberg/catalog/rest/error_handlers.h"
+#include "iceberg/catalog/rest/http_client.h"
+#include "iceberg/catalog/rest/json_internal.h"
#include "iceberg/result.h"
#include "iceberg/table_identifier.h"
#include "iceberg/test/matchers.h"
@@ -137,6 +141,40 @@ TEST_F(RestCatalogIntegrationTest, MakeCatalogSuccess) {
EXPECT_EQ(catalog->name(), kCatalogName);
}
+/// Verifies that the server's /v1/config endpoint returns a valid response
+/// and that the endpoints field (if present) can be parsed correctly.
+TEST_F(RestCatalogIntegrationTest, FetchServerConfigDirect) {
+ // Create HTTP client and fetch config directly
+ HttpClient client({});
+ std::string config_url =
+ std::format("{}:{}/v1/config", kLocalhostUri, kRestCatalogPort);
+
+ auto response_result = client.Get(config_url, {}, {},
*DefaultErrorHandler::Instance());
+ ASSERT_THAT(response_result, IsOk());
+ auto json_result = FromJsonString(response_result->body());
+ ASSERT_THAT(json_result, IsOk());
+ auto& json = json_result.value();
+
+ EXPECT_TRUE(json.contains("defaults"));
+ EXPECT_TRUE(json.contains("overrides"));
+
+ if (json.contains("endpoints")) {
+ EXPECT_TRUE(json["endpoints"].is_array());
+
+ // Parse the config to ensure all endpoints are valid
+ auto config_result = CatalogConfigFromJson(json);
+ ASSERT_THAT(config_result, IsOk());
+ auto& config = config_result.value();
+ std::println("[INFO] Server provided {} endpoints",
config.endpoints.size());
+ EXPECT_GT(config.endpoints.size(), 0)
+ << "Server should provide at least one endpoint";
+ } else {
+ std::println(
+ "[INFO] Server did not provide endpoints field, client will use
default "
+ "endpoints");
+ }
+}
+
TEST_F(RestCatalogIntegrationTest, ListNamespaces) {
auto catalog_result = CreateCatalog();
ASSERT_THAT(catalog_result, IsOk());
diff --git a/src/iceberg/test/rest_json_internal_test.cc
b/src/iceberg/test/rest_json_internal_test.cc
index ca2671fa..f95ab09c 100644
--- a/src/iceberg/test/rest_json_internal_test.cc
+++ b/src/iceberg/test/rest_json_internal_test.cc
@@ -783,7 +783,7 @@ INSTANTIATE_TEST_SUITE_P(
CatalogConfigParam{.test_name = "BothEmpty",
.expected_json_str =
R"({"defaults":{},"overrides":{}})",
.model = {}},
- // With endpoints
+ // With valid endpoints
CatalogConfigParam{
.test_name = "WithEndpoints",
.expected_json_str =
@@ -791,13 +791,14 @@ INSTANTIATE_TEST_SUITE_P(
.model = {.defaults = {{"warehouse", "s3://bucket/warehouse"}},
.overrides = {{"clients", "5"}},
- .endpoints = {"GET /v1/config", "POST /v1/tables"}}},
+ .endpoints = {*Endpoint::Make(HttpMethod::kGet,
"/v1/config"),
+ *Endpoint::Make(HttpMethod::kPost,
"/v1/tables")}}},
// Only endpoints
CatalogConfigParam{
.test_name = "OnlyEndpoints",
.expected_json_str =
R"({"defaults":{},"overrides":{},"endpoints":["GET
/v1/config"]})",
- .model = {.endpoints = {"GET /v1/config"}}}),
+ .model = {.endpoints = {*Endpoint::Make(HttpMethod::kGet,
"/v1/config")}}}),
[](const ::testing::TestParamInfo<CatalogConfigParam>& info) {
return info.param.test_name;
});
@@ -834,7 +835,12 @@ INSTANTIATE_TEST_SUITE_P(
// Both fields null
CatalogConfigDeserializeParam{.test_name = "BothNull",
.json_str =
R"({"defaults":null,"overrides":null})",
- .expected_model = {}}),
+ .expected_model = {}},
+ // Missing endpoints field, client will uses default endpoints
+ CatalogConfigDeserializeParam{
+ .test_name = "MissingEndpoints",
+ .json_str = R"({"defaults":{},"overrides":{}})",
+ .expected_model = {.defaults = {}, .overrides = {}, .endpoints =
{}}}),
[](const ::testing::TestParamInfo<CatalogConfigDeserializeParam>& info) {
return info.param.test_name;
});
@@ -855,7 +861,22 @@ INSTANTIATE_TEST_SUITE_P(
.test_name = "WrongOverridesType",
.invalid_json_str =
R"({"defaults":{"warehouse":"s3://bucket/warehouse"},"overrides":"clients"})",
- .expected_error_message = "type must be object, but is string"}),
+ .expected_error_message = "type must be object, but is string"},
+ // Invalid endpoint format - missing space separator
+ CatalogConfigInvalidParam{
+ .test_name = "InvalidEndpointMissingSpace",
+ .invalid_json_str =
R"({"endpoints":["GET_v1/namespaces/{namespace}"]})",
+ .expected_error_message =
+ "Invalid endpoint format (must consist of two elements
separated by a "
+ "single space)"},
+ // Invalid endpoint format - extra element after path
+ CatalogConfigInvalidParam{
+ .test_name = "InvalidEndpointExtraElement",
+ .invalid_json_str =
+ R"({"endpoints":["GET v1/namespaces/{namespace} INVALID"]})",
+ .expected_error_message =
+ "Invalid endpoint format (must consist of two elements
separated by a "
+ "single space)"}),
[](const ::testing::TestParamInfo<CatalogConfigInvalidParam>& info) {
return info.param.test_name;
});
diff --git a/src/iceberg/test/rest_util_test.cc
b/src/iceberg/test/rest_util_test.cc
index b95a220b..5d2abf55 100644
--- a/src/iceberg/test/rest_util_test.cc
+++ b/src/iceberg/test/rest_util_test.cc
@@ -21,6 +21,7 @@
#include <gtest/gtest.h>
+#include "iceberg/catalog/rest/endpoint.h"
#include "iceberg/table_identifier.h"
#include "iceberg/test/matchers.h"
@@ -153,4 +154,20 @@ TEST(RestUtilTest, MergeConfigs) {
EXPECT_EQ(merged_empty["key"], "value");
}
+TEST(RestUtilTest, CheckEndpointSupported) {
+ std::unordered_set<Endpoint> supported = {
+ Endpoint::ListNamespaces(), Endpoint::LoadTable(),
Endpoint::CreateTable()};
+
+ // Supported endpoints should pass
+ EXPECT_THAT(CheckEndpoint(supported, Endpoint::ListNamespaces()), IsOk());
+ EXPECT_THAT(CheckEndpoint(supported, Endpoint::LoadTable()), IsOk());
+ EXPECT_THAT(CheckEndpoint(supported, Endpoint::CreateTable()), IsOk());
+
+ // Unsupported endpoints should fail
+ EXPECT_THAT(CheckEndpoint(supported, Endpoint::DeleteTable()),
+ IsError(ErrorKind::kNotSupported));
+ EXPECT_THAT(CheckEndpoint(supported, Endpoint::UpdateTable()),
+ IsError(ErrorKind::kNotSupported));
+}
+
} // namespace iceberg::rest