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 4a71abe  feat: scaffolding work of rest catalog client (#296)
4a71abe is described below

commit 4a71abe042ef4f62842b05dbc0bd076362ed9a94
Author: Feiyang Li <[email protected]>
AuthorDate: Thu Nov 27 11:20:31 2025 +0800

    feat: scaffolding work of rest catalog client (#296)
    
    This PR introduces the foundational scaffolding for the Iceberg REST
    Catalog client implementation in C++. It establishes the core
    infrastructure for communicating with Iceberg REST Catalog servers
    following the [Iceberg REST Catalog
    Specification](https://iceberg.apache.org/spec/#rest-catalog).
    
    ### Key Components Added
    
    #### 1. HTTP Client Infrastructure (`http_client.h/cc`)
    * Implemented `HttpClient` class wrapping the CPR library for HTTP
    operations.
    * Supports `GET`, `POST`, `POST` (form-urlencoded), `HEAD`, and `DELETE`
    methods.
    *   Thread-safe session management with mutex protection.
    * `HttpResponse` wrapper to abstract underlying HTTP library
    implementation.
    
    #### 2. Configuration Management (`catalog_properties.h/cc`)
    *   `RestCatalogProperties` class for REST catalog configuration.
    
    #### 3. Resource Path Construction (`resource_paths.h/cc`)
    *   `ResourcePaths` class for building REST API endpoint URLs.
    
    #### 4. Error Handling Framework (`error_handlers.h/cc`)
    *   Hierarchical error handler design following the REST specification.
    *   HTTP status code to ErrorKind mapping.
    *   Also, extended `result.h` with new rest related error kinds.
    
    #### 5. REST Utilities (`rest_util.h/cc`)
    *   URL encoding/decoding (RFC 3986 compliant via libcurl).
    *   Namespace encoding/decoding with ASCII Unit Separator (`0x1F`).
    * Configuration merging with proper precedence (server overrides >
    client config > server defaults).
    *   String utilities (e.g., `TrimTrailingSlash`).
    
    #### 7. RestCatalog Implementation (`rest_catalog.h/cc`)
    *   `RestCatalog` class implementing the Catalog interface.
    *   **Initialization workflow:**
        1.  Validates client configuration.
        2.  Fetches server configuration from `/v1/config`.
        3.  Merges server and client properties.
        4.  Updates resource paths based on final configuration.
    
    ### Testing
    * `rest_util_test.cc`: Comprehensive tests for URL encoding/decoding,
    namespace encoding, config merging.
    * `rest_catalog_test.cc`: These currently introduced tests are merely
    temporary integration tests that require a local REST server, such as
    the `apache/iceberg-rest-fixture` Docker image While this enables local
    testing, it is incompatible with the GitHub CI process, so they have
    been marked as `DISABLED_`. In the future, we aim to follow the example
    of iceberg-rust by designing comprehensive integration tests to verify
    the REST catalog client's behavior and integrating them into our GitHub
    CI pipeline. This work is scheduled for later completion; please refer
    to issue #333.
---
 src/iceberg/catalog/rest/CMakeLists.txt            |   9 +-
 src/iceberg/catalog/rest/catalog_properties.cc     |  51 +++++
 src/iceberg/catalog/rest/catalog_properties.h      |  71 +++++++
 .../rest/constant.h}                               |  40 ++--
 src/iceberg/catalog/rest/error_handlers.cc         | 186 ++++++++++++++++
 src/iceberg/catalog/rest/error_handlers.h          | 133 ++++++++++++
 src/iceberg/catalog/rest/http_client.cc            | 234 +++++++++++++++++++++
 src/iceberg/catalog/rest/http_client.h             | 123 +++++++++++
 src/iceberg/catalog/rest/meson.build               |  27 ++-
 src/iceberg/catalog/rest/resource_paths.cc         | 113 ++++++++++
 src/iceberg/catalog/rest/resource_paths.h          |  95 +++++++++
 src/iceberg/catalog/rest/rest_catalog.cc           | 185 ++++++++++++++--
 src/iceberg/catalog/rest/rest_catalog.h            | 123 ++++++++---
 src/iceberg/catalog/rest/rest_util.cc              | 123 +++++++++++
 src/iceberg/catalog/rest/rest_util.h               |  84 ++++++++
 .../catalog/rest/{rest_catalog.cc => type_fwd.h}   |  31 +--
 src/iceberg/catalog/rest/types.h                   |   2 -
 src/iceberg/result.h                               |  19 ++
 src/iceberg/table_identifier.h                     |   6 +
 src/iceberg/test/CMakeLists.txt                    |  11 +-
 src/iceberg/test/meson.build                       |   1 +
 src/iceberg/test/rest_catalog_test.cc              | 193 +++++++++++------
 src/iceberg/test/rest_util_test.cc                 | 156 ++++++++++++++
 src/iceberg/util/config.h                          |  13 ++
 24 files changed, 1869 insertions(+), 160 deletions(-)

diff --git a/src/iceberg/catalog/rest/CMakeLists.txt 
b/src/iceberg/catalog/rest/CMakeLists.txt
index 38d8972..881b3d3 100644
--- a/src/iceberg/catalog/rest/CMakeLists.txt
+++ b/src/iceberg/catalog/rest/CMakeLists.txt
@@ -15,7 +15,14 @@
 # specific language governing permissions and limitations
 # under the License.
 
-set(ICEBERG_REST_SOURCES rest_catalog.cc json_internal.cc)
+set(ICEBERG_REST_SOURCES
+    rest_catalog.cc
+    catalog_properties.cc
+    error_handlers.cc
+    http_client.cc
+    json_internal.cc
+    resource_paths.cc
+    rest_util.cc)
 
 set(ICEBERG_REST_STATIC_BUILD_INTERFACE_LIBS)
 set(ICEBERG_REST_SHARED_BUILD_INTERFACE_LIBS)
diff --git a/src/iceberg/catalog/rest/catalog_properties.cc 
b/src/iceberg/catalog/rest/catalog_properties.cc
new file mode 100644
index 0000000..4d95683
--- /dev/null
+++ b/src/iceberg/catalog/rest/catalog_properties.cc
@@ -0,0 +1,51 @@
+/*
+ * 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_properties.h"
+
+#include <string_view>
+
+namespace iceberg::rest {
+
+std::unique_ptr<RestCatalogProperties> 
RestCatalogProperties::default_properties() {
+  return std::unique_ptr<RestCatalogProperties>(new RestCatalogProperties());
+}
+
+std::unique_ptr<RestCatalogProperties> RestCatalogProperties::FromMap(
+    const std::unordered_map<std::string, std::string>& properties) {
+  auto rest_catalog_config =
+      std::unique_ptr<RestCatalogProperties>(new RestCatalogProperties());
+  rest_catalog_config->configs_ = properties;
+  return rest_catalog_config;
+}
+
+std::unordered_map<std::string, std::string> 
RestCatalogProperties::ExtractHeaders()
+    const {
+  return Extract(kHeaderPrefix);
+}
+
+Result<std::string_view> RestCatalogProperties::Uri() const {
+  auto it = configs_.find(kUri.key());
+  if (it == configs_.end() || it->second.empty()) {
+    return InvalidArgument("Rest catalog configuration property 'uri' is 
required.");
+  }
+  return it->second;
+}
+
+}  // namespace iceberg::rest
diff --git a/src/iceberg/catalog/rest/catalog_properties.h 
b/src/iceberg/catalog/rest/catalog_properties.h
new file mode 100644
index 0000000..d351b50
--- /dev/null
+++ b/src/iceberg/catalog/rest/catalog_properties.h
@@ -0,0 +1,71 @@
+/*
+ * 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 <memory>
+#include <string>
+#include <unordered_map>
+
+#include "iceberg/catalog/rest/iceberg_rest_export.h"
+#include "iceberg/result.h"
+#include "iceberg/util/config.h"
+
+/// \file iceberg/catalog/rest/catalog_properties.h
+/// \brief RestCatalogProperties implementation for Iceberg REST API.
+
+namespace iceberg::rest {
+
+/// \brief Configuration class for a REST Catalog.
+class ICEBERG_REST_EXPORT RestCatalogProperties
+    : public ConfigBase<RestCatalogProperties> {
+ public:
+  template <typename T>
+  using Entry = const ConfigBase<RestCatalogProperties>::Entry<T>;
+
+  /// \brief The URI of the REST catalog server.
+  inline static Entry<std::string> kUri{"uri", ""};
+  /// \brief The name of the catalog.
+  inline static Entry<std::string> kName{"name", ""};
+  /// \brief The warehouse path.
+  inline static Entry<std::string> kWarehouse{"warehouse", ""};
+  /// \brief The optional prefix for REST API paths.
+  inline static Entry<std::string> kPrefix{"prefix", ""};
+  /// \brief The prefix for HTTP headers.
+  inline static constexpr std::string_view kHeaderPrefix = "header.";
+
+  /// \brief Create a default RestCatalogProperties instance.
+  static std::unique_ptr<RestCatalogProperties> default_properties();
+
+  /// \brief Create a RestCatalogProperties instance from a map of key-value 
pairs.
+  static std::unique_ptr<RestCatalogProperties> FromMap(
+      const std::unordered_map<std::string, std::string>& properties);
+
+  /// \brief Returns HTTP headers to be added to every request.
+  std::unordered_map<std::string, std::string> ExtractHeaders() const;
+
+  /// \brief Get the URI of the REST catalog server.
+  /// \return The URI if configured, or an error if not set or empty.
+  Result<std::string_view> Uri() const;
+
+ private:
+  RestCatalogProperties() = default;
+};
+
+}  // namespace iceberg::rest
diff --git a/src/iceberg/table_identifier.h 
b/src/iceberg/catalog/rest/constant.h
similarity index 50%
copy from src/iceberg/table_identifier.h
copy to src/iceberg/catalog/rest/constant.h
index b145e75..0a6e8d0 100644
--- a/src/iceberg/table_identifier.h
+++ b/src/iceberg/catalog/rest/constant.h
@@ -19,34 +19,26 @@
 
 #pragma once
 
-/// \file iceberg/table_identifier.h
-/// A TableIdentifier is a unique identifier for a table
-
 #include <string>
-#include <vector>
 
-#include "iceberg/iceberg_export.h"
-#include "iceberg/result.h"
+#include "iceberg/version.h"
+
+/// \file iceberg/catalog/rest/constant.h
+/// Constant values for Iceberg REST API.
 
-namespace iceberg {
+namespace iceberg::rest {
 
-/// \brief A namespace in a catalog.
-struct ICEBERG_EXPORT Namespace {
-  std::vector<std::string> levels;
-};
+inline const std::string kHeaderContentType = "Content-Type";
+inline const std::string kHeaderAccept = "Accept";
+inline const std::string kHeaderXClientVersion = "X-Client-Version";
+inline const std::string kHeaderUserAgent = "User-Agent";
 
-/// \brief Identifies a table in iceberg catalog.
-struct ICEBERG_EXPORT TableIdentifier {
-  Namespace ns;
-  std::string name;
+inline const std::string kMimeTypeApplicationJson = "application/json";
+inline const std::string kMimeTypeFormUrlEncoded = 
"application/x-www-form-urlencoded";
+inline const std::string kUserAgentPrefix = "iceberg-cpp/";
+inline const std::string kUserAgent = "iceberg-cpp/" ICEBERG_VERSION_STRING;
 
-  /// \brief Validates the TableIdentifier.
-  Status Validate() const {
-    if (name.empty()) {
-      return Invalid("Invalid table identifier: missing table name");
-    }
-    return {};
-  }
-};
+inline const std::string kQueryParamParent = "parent";
+inline const std::string kQueryParamPageToken = "page_token";
 
-}  // namespace iceberg
+}  // namespace iceberg::rest
diff --git a/src/iceberg/catalog/rest/error_handlers.cc 
b/src/iceberg/catalog/rest/error_handlers.cc
new file mode 100644
index 0000000..8465c00
--- /dev/null
+++ b/src/iceberg/catalog/rest/error_handlers.cc
@@ -0,0 +1,186 @@
+/*
+ * 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/error_handlers.h"
+
+#include <string_view>
+
+#include "iceberg/catalog/rest/types.h"
+
+namespace iceberg::rest {
+
+namespace {
+
+constexpr std::string_view kIllegalArgumentException = 
"IllegalArgumentException";
+constexpr std::string_view kNoSuchNamespaceException = 
"NoSuchNamespaceException";
+constexpr std::string_view kNamespaceNotEmptyException = 
"NamespaceNotEmptyException";
+
+}  // namespace
+
+const std::shared_ptr<DefaultErrorHandler>& DefaultErrorHandler::Instance() {
+  static const std::shared_ptr<DefaultErrorHandler> instance{new 
DefaultErrorHandler()};
+  return instance;
+}
+
+Status DefaultErrorHandler::Accept(const ErrorModel& error) const {
+  switch (error.code) {
+    case 400:
+      if (error.type == kIllegalArgumentException) {
+        return InvalidArgument(error.message);
+      }
+      return BadRequest("Malformed request: {}", error.message);
+    case 401:
+      return NotAuthorized("Not authorized: {}", error.message);
+    case 403:
+      return Forbidden("Forbidden: {}", error.message);
+    case 405:
+    case 406:
+      break;
+    case 500:
+      return InternalServerError("Server error: {}: {}", error.type, 
error.message);
+    case 501:
+      return NotSupported(error.message);
+    case 503:
+      return ServiceUnavailable("Service unavailable: {}", error.message);
+  }
+
+  return RestError("Code: {}, message: {}", error.code, error.message);
+}
+
+const std::shared_ptr<NamespaceErrorHandler>& 
NamespaceErrorHandler::Instance() {
+  static const std::shared_ptr<NamespaceErrorHandler> instance{
+      new NamespaceErrorHandler()};
+  return instance;
+}
+
+Status NamespaceErrorHandler::Accept(const ErrorModel& error) const {
+  switch (error.code) {
+    case 400:
+      if (error.type == kNamespaceNotEmptyException) {
+        return NamespaceNotEmpty(error.message);
+      }
+      return BadRequest("Malformed request: {}", error.message);
+    case 404:
+      return NoSuchNamespace(error.message);
+    case 409:
+      return AlreadyExists(error.message);
+    case 422:
+      return RestError("Unable to process: {}", error.message);
+  }
+
+  return DefaultErrorHandler::Accept(error);
+}
+
+const std::shared_ptr<DropNamespaceErrorHandler>& 
DropNamespaceErrorHandler::Instance() {
+  static const std::shared_ptr<DropNamespaceErrorHandler> instance{
+      new DropNamespaceErrorHandler()};
+  return instance;
+}
+
+Status DropNamespaceErrorHandler::Accept(const ErrorModel& error) const {
+  if (error.code == 409) {
+    return NamespaceNotEmpty(error.message);
+  }
+
+  return NamespaceErrorHandler::Accept(error);
+}
+
+const std::shared_ptr<TableErrorHandler>& TableErrorHandler::Instance() {
+  static const std::shared_ptr<TableErrorHandler> instance{new 
TableErrorHandler()};
+  return instance;
+}
+
+Status TableErrorHandler::Accept(const ErrorModel& error) const {
+  switch (error.code) {
+    case 404:
+      if (error.type == kNoSuchNamespaceException) {
+        return NoSuchNamespace(error.message);
+      }
+      return NoSuchTable(error.message);
+    case 409:
+      return AlreadyExists(error.message);
+  }
+
+  return DefaultErrorHandler::Accept(error);
+}
+
+const std::shared_ptr<ViewErrorHandler>& ViewErrorHandler::Instance() {
+  static const std::shared_ptr<ViewErrorHandler> instance{new 
ViewErrorHandler()};
+  return instance;
+}
+
+Status ViewErrorHandler::Accept(const ErrorModel& error) const {
+  switch (error.code) {
+    case 404:
+      if (error.type == kNoSuchNamespaceException) {
+        return NoSuchNamespace(error.message);
+      }
+      return NoSuchView(error.message);
+    case 409:
+      return AlreadyExists(error.message);
+  }
+
+  return DefaultErrorHandler::Accept(error);
+}
+
+const std::shared_ptr<TableCommitErrorHandler>& 
TableCommitErrorHandler::Instance() {
+  static const std::shared_ptr<TableCommitErrorHandler> instance{
+      new TableCommitErrorHandler()};
+  return instance;
+}
+
+Status TableCommitErrorHandler::Accept(const ErrorModel& error) const {
+  switch (error.code) {
+    case 404:
+      return NoSuchTable(error.message);
+    case 409:
+      return CommitFailed("Commit failed: {}", error.message);
+    case 500:
+    case 502:
+    case 503:
+    case 504:
+      return CommitStateUnknown("Service failed: {}: {}", error.code, 
error.message);
+  }
+
+  return DefaultErrorHandler::Accept(error);
+}
+
+const std::shared_ptr<ViewCommitErrorHandler>& 
ViewCommitErrorHandler::Instance() {
+  static const std::shared_ptr<ViewCommitErrorHandler> instance{
+      new ViewCommitErrorHandler()};
+  return instance;
+}
+
+Status ViewCommitErrorHandler::Accept(const ErrorModel& error) const {
+  switch (error.code) {
+    case 404:
+      return NoSuchView(error.message);
+    case 409:
+      return CommitFailed("Commit failed: {}", error.message);
+    case 500:
+    case 502:
+    case 503:
+    case 504:
+      return CommitStateUnknown("Service failed: {}: {}", error.code, 
error.message);
+  }
+
+  return DefaultErrorHandler::Accept(error);
+}
+
+}  // namespace iceberg::rest
diff --git a/src/iceberg/catalog/rest/error_handlers.h 
b/src/iceberg/catalog/rest/error_handlers.h
new file mode 100644
index 0000000..072d704
--- /dev/null
+++ b/src/iceberg/catalog/rest/error_handlers.h
@@ -0,0 +1,133 @@
+/*
+ * 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 <memory>
+
+#include "iceberg/catalog/rest/iceberg_rest_export.h"
+#include "iceberg/catalog/rest/type_fwd.h"
+#include "iceberg/result.h"
+
+/// \file iceberg/catalog/rest/error_handlers.h
+/// Error handlers for different HTTP error types in Iceberg REST API.
+
+namespace iceberg::rest {
+
+/// \brief Error handler interface for processing REST API error responses. 
Maps HTTP
+/// status codes to appropriate ErrorKind values following the Iceberg REST 
specification.
+class ICEBERG_REST_EXPORT ErrorHandler {
+ public:
+  virtual ~ErrorHandler() = default;
+
+  // TODO(Li Feiyang):removing ErrorModel as the inner layer and directly using
+  // ErrorResponse
+
+  /// \brief Process an error response and return an appropriate Error.
+  ///
+  /// \param error The error model parsed from the HTTP response body
+  /// \return An Error object with appropriate ErrorKind and message
+  virtual Status Accept(const ErrorModel& error) const = 0;
+};
+
+/// \brief Default error handler for REST API responses.
+class ICEBERG_REST_EXPORT DefaultErrorHandler : public ErrorHandler {
+ public:
+  /// \brief Returns the singleton instance
+  static const std::shared_ptr<DefaultErrorHandler>& Instance();
+
+  Status Accept(const ErrorModel& error) const override;
+
+ protected:
+  constexpr DefaultErrorHandler() = default;
+};
+
+/// \brief Namespace-specific error handler for create/read/update operations.
+class ICEBERG_REST_EXPORT NamespaceErrorHandler : public DefaultErrorHandler {
+ public:
+  /// \brief Returns the singleton instance
+  static const std::shared_ptr<NamespaceErrorHandler>& Instance();
+
+  Status Accept(const ErrorModel& error) const override;
+
+ protected:
+  constexpr NamespaceErrorHandler() = default;
+};
+
+/// \brief Error handler for drop namespace operations.
+class ICEBERG_REST_EXPORT DropNamespaceErrorHandler : public 
NamespaceErrorHandler {
+ public:
+  /// \brief Returns the singleton instance
+  static const std::shared_ptr<DropNamespaceErrorHandler>& Instance();
+
+  Status Accept(const ErrorModel& error) const override;
+
+ private:
+  constexpr DropNamespaceErrorHandler() = default;
+};
+
+/// \brief Table-level error handler.
+class ICEBERG_REST_EXPORT TableErrorHandler : public DefaultErrorHandler {
+ public:
+  /// \brief Returns the singleton instance
+  static const std::shared_ptr<TableErrorHandler>& Instance();
+
+  Status Accept(const ErrorModel& error) const override;
+
+ private:
+  constexpr TableErrorHandler() = default;
+};
+
+/// \brief View-level error handler.
+class ICEBERG_REST_EXPORT ViewErrorHandler : public DefaultErrorHandler {
+ public:
+  /// \brief Returns the singleton instance
+  static const std::shared_ptr<ViewErrorHandler>& Instance();
+
+  Status Accept(const ErrorModel& error) const override;
+
+ private:
+  constexpr ViewErrorHandler() = default;
+};
+
+/// \brief Table commit operation error handler.
+class ICEBERG_REST_EXPORT TableCommitErrorHandler : public DefaultErrorHandler 
{
+ public:
+  /// \brief Returns the singleton instance
+  static const std::shared_ptr<TableCommitErrorHandler>& Instance();
+
+  Status Accept(const ErrorModel& error) const override;
+
+ private:
+  constexpr TableCommitErrorHandler() = default;
+};
+
+/// \brief View commit operation error handler.
+class ICEBERG_REST_EXPORT ViewCommitErrorHandler : public DefaultErrorHandler {
+ public:
+  /// \brief Returns the singleton instance
+  static const std::shared_ptr<ViewCommitErrorHandler>& Instance();
+
+  Status Accept(const ErrorModel& error) const override;
+
+ private:
+  constexpr ViewCommitErrorHandler() = default;
+};
+
+}  // namespace iceberg::rest
diff --git a/src/iceberg/catalog/rest/http_client.cc 
b/src/iceberg/catalog/rest/http_client.cc
new file mode 100644
index 0000000..1b026c6
--- /dev/null
+++ b/src/iceberg/catalog/rest/http_client.cc
@@ -0,0 +1,234 @@
+/*
+ * 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/http_client.h"
+
+#include <cpr/cpr.h>
+#include <nlohmann/json.hpp>
+
+#include "iceberg/catalog/rest/constant.h"
+#include "iceberg/catalog/rest/error_handlers.h"
+#include "iceberg/catalog/rest/json_internal.h"
+#include "iceberg/json_internal.h"
+#include "iceberg/result.h"
+#include "iceberg/util/macros.h"
+
+namespace iceberg::rest {
+
+class HttpResponse::Impl {
+ public:
+  explicit Impl(cpr::Response&& response) : response_(std::move(response)) {}
+  ~Impl() = default;
+
+  int32_t status_code() const { return 
static_cast<int32_t>(response_.status_code); }
+
+  std::string body() const { return response_.text; }
+
+  std::unordered_map<std::string, std::string> headers() const {
+    return {response_.header.begin(), response_.header.end()};
+  }
+
+ private:
+  cpr::Response response_;
+};
+
+HttpResponse::HttpResponse() = default;
+HttpResponse::~HttpResponse() = default;
+HttpResponse::HttpResponse(HttpResponse&&) noexcept = default;
+HttpResponse& HttpResponse::operator=(HttpResponse&&) noexcept = default;
+
+int32_t HttpResponse::status_code() const { return impl_->status_code(); }
+
+std::string HttpResponse::body() const { return impl_->body(); }
+
+std::unordered_map<std::string, std::string> HttpResponse::headers() const {
+  return impl_->headers();
+}
+
+namespace {
+
+/// \brief Merges global default headers with request-specific headers.
+///
+/// Combines the global headers derived from RestCatalogProperties with the 
headers
+/// passed in the specific request. Request-specific headers have higher 
priority
+/// and will override global defaults if the keys conflict (e.g., overriding
+/// the default "Content-Type").
+cpr::Header MergeHeaders(const std::unordered_map<std::string, std::string>& 
defaults,
+                         const std::unordered_map<std::string, std::string>& 
overrides) {
+  cpr::Header combined_headers = {defaults.begin(), defaults.end()};
+  for (const auto& [key, val] : overrides) {
+    combined_headers.insert_or_assign(key, val);
+  }
+  return combined_headers;
+}
+
+/// \brief Converts a map of string key-value pairs to cpr::Parameters.
+cpr::Parameters GetParameters(
+    const std::unordered_map<std::string, std::string>& params) {
+  cpr::Parameters cpr_params;
+  for (const auto& [key, val] : params) {
+    cpr_params.Add({key, val});
+  }
+  return cpr_params;
+}
+
+/// \brief Checks if the HTTP status code indicates a successful response.
+bool IsSuccessful(int32_t status_code) {
+  return status_code == 200      // OK
+         || status_code == 202   // Accepted
+         || status_code == 204   // No Content
+         || status_code == 304;  // Not Modified
+}
+
+/// \brief Handles failure responses by invoking the provided error handler.
+Status HandleFailureResponse(const cpr::Response& response,
+                             const ErrorHandler& error_handler) {
+  if (!IsSuccessful(response.status_code)) {
+    // TODO(gangwu): response status code is lost, wrap it with RestError.
+    ICEBERG_ASSIGN_OR_RAISE(auto json, FromJsonString(response.text));
+    ICEBERG_ASSIGN_OR_RAISE(auto error_response, ErrorResponseFromJson(json));
+    return error_handler.Accept(error_response.error);
+  }
+  return {};
+}
+
+}  // namespace
+
+void HttpClient::PrepareSession(
+    const std::string& path,
+    const std::unordered_map<std::string, std::string>& request_headers,
+    const std::unordered_map<std::string, std::string>& params) {
+  session_->SetUrl(cpr::Url{path});
+  session_->SetParameters(GetParameters(params));
+  session_->RemoveContent();
+  auto final_headers = MergeHeaders(default_headers_, request_headers);
+  session_->SetHeader(final_headers);
+}
+
+HttpClient::HttpClient(std::unordered_map<std::string, std::string> 
default_headers)
+    : default_headers_{std::move(default_headers)},
+      session_{std::make_unique<cpr::Session>()} {
+  // Set default Content-Type for all requests (including GET/HEAD/DELETE).
+  // Many systems require that content type is set regardless and will fail,
+  // even on an empty bodied request.
+  default_headers_[kHeaderContentType] = kMimeTypeApplicationJson;
+  default_headers_[kHeaderUserAgent] = kUserAgent;
+}
+
+HttpClient::~HttpClient() = default;
+
+Result<HttpResponse> HttpClient::Get(
+    const std::string& path, const std::unordered_map<std::string, 
std::string>& params,
+    const std::unordered_map<std::string, std::string>& headers,
+    const ErrorHandler& error_handler) {
+  cpr::Response response;
+  {
+    std::scoped_lock<std::mutex> lock(session_mutex_);
+    PrepareSession(path, headers, params);
+    response = session_->Get();
+  }
+
+  ICEBERG_RETURN_UNEXPECTED(HandleFailureResponse(response, error_handler));
+  HttpResponse http_response;
+  http_response.impl_ = 
std::make_unique<HttpResponse::Impl>(std::move(response));
+  return http_response;
+}
+
+Result<HttpResponse> HttpClient::Post(
+    const std::string& path, const std::string& body,
+    const std::unordered_map<std::string, std::string>& headers,
+    const ErrorHandler& error_handler) {
+  cpr::Response response;
+  {
+    std::scoped_lock<std::mutex> lock(session_mutex_);
+    PrepareSession(path, headers);
+    session_->SetBody(cpr::Body{body});
+    response = session_->Post();
+  }
+
+  ICEBERG_RETURN_UNEXPECTED(HandleFailureResponse(response, error_handler));
+  HttpResponse http_response;
+  http_response.impl_ = 
std::make_unique<HttpResponse::Impl>(std::move(response));
+  return http_response;
+}
+
+Result<HttpResponse> HttpClient::PostForm(
+    const std::string& path,
+    const std::unordered_map<std::string, std::string>& form_data,
+    const std::unordered_map<std::string, std::string>& headers,
+    const ErrorHandler& error_handler) {
+  cpr::Response response;
+
+  {
+    std::scoped_lock<std::mutex> lock(session_mutex_);
+
+    // Override default Content-Type (application/json) with form-urlencoded
+    auto form_headers = headers;
+    form_headers[kHeaderContentType] = kMimeTypeFormUrlEncoded;
+
+    PrepareSession(path, form_headers);
+    std::vector<cpr::Pair> pair_list;
+    pair_list.reserve(form_data.size());
+    for (const auto& [key, val] : form_data) {
+      pair_list.emplace_back(key, val);
+    }
+    session_->SetPayload(cpr::Payload(pair_list.begin(), pair_list.end()));
+
+    response = session_->Post();
+  }
+
+  ICEBERG_RETURN_UNEXPECTED(HandleFailureResponse(response, error_handler));
+  HttpResponse http_response;
+  http_response.impl_ = 
std::make_unique<HttpResponse::Impl>(std::move(response));
+  return http_response;
+}
+
+Result<HttpResponse> HttpClient::Head(
+    const std::string& path, const std::unordered_map<std::string, 
std::string>& headers,
+    const ErrorHandler& error_handler) {
+  cpr::Response response;
+  {
+    std::scoped_lock<std::mutex> lock(session_mutex_);
+    PrepareSession(path, headers);
+    response = session_->Head();
+  }
+
+  ICEBERG_RETURN_UNEXPECTED(HandleFailureResponse(response, error_handler));
+  HttpResponse http_response;
+  http_response.impl_ = 
std::make_unique<HttpResponse::Impl>(std::move(response));
+  return http_response;
+}
+
+Result<HttpResponse> HttpClient::Delete(
+    const std::string& path, const std::unordered_map<std::string, 
std::string>& headers,
+    const ErrorHandler& error_handler) {
+  cpr::Response response;
+  {
+    std::scoped_lock<std::mutex> lock(session_mutex_);
+    PrepareSession(path, headers);
+    response = session_->Delete();
+  }
+
+  ICEBERG_RETURN_UNEXPECTED(HandleFailureResponse(response, error_handler));
+  HttpResponse http_response;
+  http_response.impl_ = 
std::make_unique<HttpResponse::Impl>(std::move(response));
+  return http_response;
+}
+
+}  // namespace iceberg::rest
diff --git a/src/iceberg/catalog/rest/http_client.h 
b/src/iceberg/catalog/rest/http_client.h
new file mode 100644
index 0000000..56c9f29
--- /dev/null
+++ b/src/iceberg/catalog/rest/http_client.h
@@ -0,0 +1,123 @@
+/*
+ * 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 <cstdint>
+#include <memory>
+#include <mutex>
+#include <string>
+#include <unordered_map>
+
+#include "iceberg/catalog/rest/iceberg_rest_export.h"
+#include "iceberg/catalog/rest/type_fwd.h"
+#include "iceberg/result.h"
+
+/// \file iceberg/catalog/rest/http_client.h
+/// \brief Http client for Iceberg REST API.
+
+namespace cpr {
+class Session;
+}  // namespace cpr
+
+namespace iceberg::rest {
+
+/// \brief A simple wrapper for cpr::Response.
+///
+/// This class encapsulates the details of the underlying cpr library's 
response,
+/// providing a consistent interface that is independent of the specific 
network
+/// library used.
+class ICEBERG_REST_EXPORT HttpResponse {
+ public:
+  HttpResponse();
+  ~HttpResponse();
+
+  HttpResponse(const HttpResponse&) = delete;
+  HttpResponse& operator=(const HttpResponse&) = delete;
+  HttpResponse(HttpResponse&&) noexcept;
+  HttpResponse& operator=(HttpResponse&&) noexcept;
+
+  /// \brief Get the HTTP status code of the response.
+  int32_t status_code() const;
+
+  /// \brief Get the body of the response as a string.
+  std::string body() const;
+
+  /// \brief Get the headers of the response as a map.
+  std::unordered_map<std::string, std::string> headers() const;
+
+ private:
+  friend class HttpClient;
+  class Impl;
+  std::unique_ptr<Impl> impl_;
+};
+
+/// \brief HTTP client for making requests to Iceberg REST Catalog API.
+class ICEBERG_REST_EXPORT HttpClient {
+ public:
+  explicit HttpClient(std::unordered_map<std::string, std::string> 
default_headers = {});
+  ~HttpClient();
+
+  HttpClient(const HttpClient&) = delete;
+  HttpClient& operator=(const HttpClient&) = delete;
+  HttpClient(HttpClient&&) = delete;
+  HttpClient& operator=(HttpClient&&) = delete;
+
+  /// \brief Sends a GET request.
+  Result<HttpResponse> Get(const std::string& path,
+                           const std::unordered_map<std::string, std::string>& 
params,
+                           const std::unordered_map<std::string, std::string>& 
headers,
+                           const ErrorHandler& error_handler);
+
+  /// \brief Sends a POST request.
+  Result<HttpResponse> Post(const std::string& path, const std::string& body,
+                            const std::unordered_map<std::string, 
std::string>& headers,
+                            const ErrorHandler& error_handler);
+
+  /// \brief Sends a POST request with form data.
+  Result<HttpResponse> PostForm(
+      const std::string& path,
+      const std::unordered_map<std::string, std::string>& form_data,
+      const std::unordered_map<std::string, std::string>& headers,
+      const ErrorHandler& error_handler);
+
+  /// \brief Sends a HEAD request.
+  Result<HttpResponse> Head(const std::string& path,
+                            const std::unordered_map<std::string, 
std::string>& headers,
+                            const ErrorHandler& error_handler);
+
+  /// \brief Sends a DELETE request.
+  Result<HttpResponse> Delete(const std::string& path,
+                              const std::unordered_map<std::string, 
std::string>& headers,
+                              const ErrorHandler& error_handler);
+
+ private:
+  void PrepareSession(const std::string& path,
+                      const std::unordered_map<std::string, std::string>& 
request_headers,
+                      const std::unordered_map<std::string, std::string>& 
params = {});
+
+  std::unordered_map<std::string, std::string> default_headers_;
+
+  // TODO(Li Feiyang): use connection pool to support external multi-threaded 
concurrent
+  // calls
+  std::unique_ptr<cpr::Session> session_;
+  mutable std::mutex session_mutex_;
+};
+
+}  // namespace iceberg::rest
diff --git a/src/iceberg/catalog/rest/meson.build 
b/src/iceberg/catalog/rest/meson.build
index 5f1f635..89a6885 100644
--- a/src/iceberg/catalog/rest/meson.build
+++ b/src/iceberg/catalog/rest/meson.build
@@ -15,7 +15,15 @@
 # specific language governing permissions and limitations
 # under the License.
 
-iceberg_rest_sources = files('json_internal.cc', 'rest_catalog.cc')
+iceberg_rest_sources = files(
+    'catalog_properties.cc',
+    'error_handlers.cc',
+    'http_client.cc',
+    'json_internal.cc',
+    'resource_paths.cc',
+    'rest_catalog.cc',
+    'rest_util.cc',
+)
 # cpr does not export symbols, so on Windows it must
 # be used as a static lib
 cpr_needs_static = (
@@ -46,4 +54,19 @@ 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_properties.h',
+        'constant.h',
+        'error_handlers.h',
+        'http_client.h',
+        'iceberg_rest_export.h',
+        'json_internal.h',
+        'resource_paths.h',
+        'rest_catalog.h',
+        'rest_util.h',
+        'type_fwd.h',
+        'types.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 0000000..c81467c
--- /dev/null
+++ b/src/iceberg/catalog/rest/resource_paths.cc
@@ -0,0 +1,113 @@
+/*
+ * 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 <format>
+
+#include "iceberg/catalog/rest/rest_util.h"
+#include "iceberg/table_identifier.h"
+#include "iceberg/util/macros.h"
+
+namespace iceberg::rest {
+
+Result<std::unique_ptr<ResourcePaths>> ResourcePaths::Make(std::string 
base_uri,
+                                                           const std::string& 
prefix) {
+  if (base_uri.empty()) {
+    return InvalidArgument("Base URI is empty");
+  }
+  return std::unique_ptr<ResourcePaths>(new ResourcePaths(std::move(base_uri), 
prefix));
+}
+
+ResourcePaths::ResourcePaths(std::string base_uri, const std::string& prefix)
+    : base_uri_(std::move(base_uri)), prefix_(prefix.empty() ? "" : (prefix + 
"/")) {}
+
+Status ResourcePaths::SetBaseUri(const std::string& base_uri) {
+  if (base_uri.empty()) {
+    return InvalidArgument("Base URI is empty");
+  }
+  base_uri_ = base_uri;
+  return {};
+}
+
+Result<std::string> ResourcePaths::Config() const {
+  return std::format("{}/v1/{}config", base_uri_, prefix_);
+}
+
+Result<std::string> ResourcePaths::OAuth2Tokens() const {
+  return std::format("{}/v1/{}oauth/tokens", base_uri_, prefix_);
+}
+
+Result<std::string> ResourcePaths::Namespaces() const {
+  return std::format("{}/v1/{}namespaces", base_uri_, prefix_);
+}
+
+Result<std::string> ResourcePaths::Namespace_(const Namespace& ns) const {
+  ICEBERG_ASSIGN_OR_RAISE(std::string encoded_namespace, EncodeNamespace(ns));
+  return std::format("{}/v1/{}namespaces/{}", base_uri_, prefix_, 
encoded_namespace);
+}
+
+Result<std::string> ResourcePaths::NamespaceProperties(const Namespace& ns) 
const {
+  ICEBERG_ASSIGN_OR_RAISE(std::string encoded_namespace, EncodeNamespace(ns));
+  return std::format("{}/v1/{}namespaces/{}/properties", base_uri_, prefix_,
+                     encoded_namespace);
+}
+
+Result<std::string> ResourcePaths::Tables(const Namespace& ns) const {
+  ICEBERG_ASSIGN_OR_RAISE(std::string encoded_namespace, EncodeNamespace(ns));
+  return std::format("{}/v1/{}namespaces/{}/tables", base_uri_, prefix_,
+                     encoded_namespace);
+}
+
+Result<std::string> ResourcePaths::Table(const TableIdentifier& ident) const {
+  ICEBERG_ASSIGN_OR_RAISE(std::string encoded_namespace, 
EncodeNamespace(ident.ns));
+  ICEBERG_ASSIGN_OR_RAISE(std::string encoded_table_name, 
EncodeString(ident.name));
+  return std::format("{}/v1/{}namespaces/{}/tables/{}", base_uri_, prefix_,
+                     encoded_namespace, encoded_table_name);
+}
+
+Result<std::string> ResourcePaths::Register(const Namespace& ns) const {
+  ICEBERG_ASSIGN_OR_RAISE(std::string encoded_namespace, EncodeNamespace(ns));
+  return std::format("{}/v1/{}namespaces/{}/register", base_uri_, prefix_,
+                     encoded_namespace);
+}
+
+Result<std::string> ResourcePaths::Rename() const {
+  return std::format("{}/v1/{}tables/rename", base_uri_, prefix_);
+}
+
+Result<std::string> ResourcePaths::Metrics(const TableIdentifier& ident) const 
{
+  ICEBERG_ASSIGN_OR_RAISE(std::string encoded_namespace, 
EncodeNamespace(ident.ns));
+  ICEBERG_ASSIGN_OR_RAISE(std::string encoded_table_name, 
EncodeString(ident.name));
+  return std::format("{}/v1/{}namespaces/{}/tables/{}/metrics", base_uri_, 
prefix_,
+                     encoded_namespace, encoded_table_name);
+}
+
+Result<std::string> ResourcePaths::Credentials(const TableIdentifier& ident) 
const {
+  ICEBERG_ASSIGN_OR_RAISE(std::string encoded_namespace, 
EncodeNamespace(ident.ns));
+  ICEBERG_ASSIGN_OR_RAISE(std::string encoded_table_name, 
EncodeString(ident.name));
+  return std::format("{}/v1/{}namespaces/{}/tables/{}/credentials", base_uri_, 
prefix_,
+                     encoded_namespace, encoded_table_name);
+}
+
+Result<std::string> ResourcePaths::CommitTransaction() const {
+  return std::format("{}/v1/{}transactions/commit", base_uri_, prefix_);
+}
+
+}  // 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 0000000..9d0bdda
--- /dev/null
+++ b/src/iceberg/catalog/rest/resource_paths.h
@@ -0,0 +1,95 @@
+/*
+ * 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 <memory>
+#include <string>
+
+#include "iceberg/catalog/rest/iceberg_rest_export.h"
+#include "iceberg/catalog/rest/type_fwd.h"
+#include "iceberg/result.h"
+#include "iceberg/type_fwd.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 base URI and optional prefix.
+  /// \param base_uri The base URI of the REST catalog server (without 
trailing slash)
+  /// \param prefix Optional prefix for REST API paths (default: empty)
+  /// \return A unique_ptr to ResourcePaths instance
+  static Result<std::unique_ptr<ResourcePaths>> Make(std::string base_uri,
+                                                     const std::string& 
prefix);
+
+  /// \brief Set the base URI of the REST catalog server.
+  Status SetBaseUri(const std::string& base_uri);
+
+  /// \brief Get the /v1/{prefix}/config endpoint path.
+  Result<std::string> Config() const;
+
+  /// \brief Get the /v1/{prefix}/oauth/tokens endpoint path.
+  Result<std::string> OAuth2Tokens() const;
+
+  /// \brief Get the /v1/{prefix}/namespaces endpoint path.
+  Result<std::string> Namespaces() const;
+
+  /// \brief Get the /v1/{prefix}/namespaces/{namespace} endpoint path.
+  Result<std::string> Namespace_(const Namespace& ns) const;
+
+  /// \brief Get the /v1/{prefix}/namespaces/{namespace}/properties endpoint 
path.
+  Result<std::string> NamespaceProperties(const Namespace& ns) const;
+
+  /// \brief Get the /v1/{prefix}/namespaces/{namespace}/tables endpoint path.
+  Result<std::string> Tables(const Namespace& ns) const;
+
+  /// \brief Get the /v1/{prefix}/namespaces/{namespace}/tables/{table} 
endpoint path.
+  Result<std::string> Table(const TableIdentifier& ident) const;
+
+  /// \brief Get the /v1/{prefix}/namespaces/{namespace}/register endpoint 
path.
+  Result<std::string> Register(const Namespace& ns) const;
+
+  /// \brief Get the /v1/{prefix}/tables/rename endpoint path.
+  Result<std::string> Rename() const;
+
+  /// \brief Get the 
/v1/{prefix}/namespaces/{namespace}/tables/{table}/metrics endpoint
+  /// path.
+  Result<std::string> Metrics(const TableIdentifier& ident) const;
+
+  /// \brief Get the 
/v1/{prefix}/namespaces/{namespace}/tables/{table}/credentials
+  /// endpoint path.
+  Result<std::string> Credentials(const TableIdentifier& ident) const;
+
+  /// \brief Get the /v1/{prefix}/transactions/commit endpoint path.
+  Result<std::string> CommitTransaction() const;
+
+ private:
+  ResourcePaths(std::string base_uri, const std::string& prefix);
+
+  std::string base_uri_;      // required
+  const std::string prefix_;  // optional
+};
+
+}  // namespace iceberg::rest
diff --git a/src/iceberg/catalog/rest/rest_catalog.cc 
b/src/iceberg/catalog/rest/rest_catalog.cc
index cd008e9..dff52e2 100644
--- a/src/iceberg/catalog/rest/rest_catalog.cc
+++ b/src/iceberg/catalog/rest/rest_catalog.cc
@@ -19,26 +19,185 @@
 
 #include "iceberg/catalog/rest/rest_catalog.h"
 
+#include <memory>
+#include <unordered_map>
 #include <utility>
 
-#include <cpr/cpr.h>
+#include <nlohmann/json.hpp>
 
-#include "iceberg/catalog/rest/types.h"
+#include "iceberg/catalog/rest/catalog_properties.h"
+#include "iceberg/catalog/rest/constant.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/json_internal.h"
+#include "iceberg/result.h"
+#include "iceberg/table.h"
+#include "iceberg/util/macros.h"
+
+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());
+  ICEBERG_ASSIGN_OR_RAISE(const auto response,
+                          client.Get(config_endpoint, /*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));
+}
+
+}  // namespace
+
+RestCatalog::~RestCatalog() = default;
+
+Result<std::unique_ptr<RestCatalog>> RestCatalog::Make(
+    const RestCatalogProperties& config) {
+  ICEBERG_ASSIGN_OR_RAISE(auto uri, config.Uri());
+  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));
+
+  // 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)));
+}
+
+RestCatalog::RestCatalog(std::unique_ptr<RestCatalogProperties> config,
+                         std::unique_ptr<ResourcePaths> paths)
+    : config_(std::move(config)),
+      client_(std::make_unique<HttpClient>(config_->ExtractHeaders())),
+      paths_(std::move(paths)),
+      name_(config_->Get(RestCatalogProperties::kName)) {}
+
+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());
+  std::vector<Namespace> result;
+  std::string next_token;
+  while (true) {
+    std::unordered_map<std::string, std::string> params;
+    if (!ns.levels.empty()) {
+      ICEBERG_ASSIGN_OR_RAISE(params[kQueryParamParent], EncodeNamespace(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(auto json, FromJsonString(response.body()));
+    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;
+  }
+  return result;
+}
+
+Status RestCatalog::CreateNamespace(
+    [[maybe_unused]] const Namespace& ns,
+    [[maybe_unused]] const std::unordered_map<std::string, std::string>& 
properties) {
+  return NotImplemented("Not implemented");
+}
+
+Result<std::unordered_map<std::string, std::string>> 
RestCatalog::GetNamespaceProperties(
+    [[maybe_unused]] const Namespace& ns) const {
+  return NotImplemented("Not implemented");
+}
 
-namespace iceberg::catalog::rest {
+Status RestCatalog::DropNamespace([[maybe_unused]] const Namespace& ns) {
+  return NotImplemented("Not implemented");
+}
+
+Result<bool> RestCatalog::NamespaceExists([[maybe_unused]] const Namespace& 
ns) const {
+  return NotImplemented("Not implemented");
+}
+
+Status RestCatalog::UpdateNamespaceProperties(
+    [[maybe_unused]] const Namespace& ns,
+    [[maybe_unused]] const std::unordered_map<std::string, std::string>& 
updates,
+    [[maybe_unused]] const std::unordered_set<std::string>& removals) {
+  return NotImplemented("Not implemented");
+}
+
+Result<std::vector<TableIdentifier>> RestCatalog::ListTables(
+    [[maybe_unused]] const Namespace& ns) const {
+  return NotImplemented("Not implemented");
+}
+
+Result<std::unique_ptr<Table>> RestCatalog::CreateTable(
+    [[maybe_unused]] const TableIdentifier& identifier,
+    [[maybe_unused]] const Schema& schema, [[maybe_unused]] const 
PartitionSpec& spec,
+    [[maybe_unused]] const std::string& location,
+    [[maybe_unused]] const std::unordered_map<std::string, std::string>& 
properties) {
+  return NotImplemented("Not implemented");
+}
+
+Result<std::unique_ptr<Table>> RestCatalog::UpdateTable(
+    [[maybe_unused]] const TableIdentifier& identifier,
+    [[maybe_unused]] const std::vector<std::unique_ptr<TableRequirement>>& 
requirements,
+    [[maybe_unused]] const std::vector<std::unique_ptr<TableUpdate>>& updates) 
{
+  return NotImplemented("Not implemented");
+}
+
+Result<std::shared_ptr<Transaction>> RestCatalog::StageCreateTable(
+    [[maybe_unused]] const TableIdentifier& identifier,
+    [[maybe_unused]] const Schema& schema, [[maybe_unused]] const 
PartitionSpec& spec,
+    [[maybe_unused]] const std::string& location,
+    [[maybe_unused]] const std::unordered_map<std::string, std::string>& 
properties) {
+  return NotImplemented("Not implemented");
+}
+
+Status RestCatalog::DropTable([[maybe_unused]] const TableIdentifier& 
identifier,
+                              [[maybe_unused]] bool purge) {
+  return NotImplemented("Not implemented");
+}
 
-RestCatalog::RestCatalog(const std::string& base_url) : 
base_url_(std::move(base_url)) {}
+Result<bool> RestCatalog::TableExists(
+    [[maybe_unused]] const TableIdentifier& identifier) const {
+  return NotImplemented("Not implemented");
+}
+
+Status RestCatalog::RenameTable([[maybe_unused]] const TableIdentifier& from,
+                                [[maybe_unused]] const TableIdentifier& to) {
+  return NotImplemented("Not implemented");
+}
+
+Result<std::unique_ptr<Table>> RestCatalog::LoadTable(
+    [[maybe_unused]] const TableIdentifier& identifier) {
+  return NotImplemented("Not implemented");
+}
 
-cpr::Response RestCatalog::GetConfig() {
-  cpr::Url url = cpr::Url{base_url_ + "/v1/config"};
-  cpr::Response r = cpr::Get(url);
-  return r;
+Result<std::shared_ptr<Table>> RestCatalog::RegisterTable(
+    [[maybe_unused]] const TableIdentifier& identifier,
+    [[maybe_unused]] const std::string& metadata_file_location) {
+  return NotImplemented("Not implemented");
 }
 
-cpr::Response RestCatalog::ListNamespaces() {
-  cpr::Url url = cpr::Url{base_url_ + "/v1/namespaces"};
-  cpr::Response r = cpr::Get(url);
-  return r;
+std::unique_ptr<RestCatalog::TableBuilder> RestCatalog::BuildTable(
+    [[maybe_unused]] const TableIdentifier& identifier,
+    [[maybe_unused]] const Schema& schema) const {
+  return nullptr;
 }
 
-}  // namespace iceberg::catalog::rest
+}  // namespace iceberg::rest
diff --git a/src/iceberg/catalog/rest/rest_catalog.h 
b/src/iceberg/catalog/rest/rest_catalog.h
index 7b3e205..84ab2b9 100644
--- a/src/iceberg/catalog/rest/rest_catalog.h
+++ b/src/iceberg/catalog/rest/rest_catalog.h
@@ -1,41 +1,112 @@
-// 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.
+/*
+ * 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 <memory>
 #include <string>
 
-#include <cpr/cpr.h>
-
+#include "iceberg/catalog.h"
 #include "iceberg/catalog/rest/iceberg_rest_export.h"
+#include "iceberg/catalog/rest/type_fwd.h"
+#include "iceberg/result.h"
+
+/// \file iceberg/catalog/rest/rest_catalog.h
+/// RestCatalog implementation for Iceberg REST API.
 
-namespace iceberg::catalog::rest {
+namespace iceberg::rest {
 
-class ICEBERG_REST_EXPORT RestCatalog {
+/// \brief Rest catalog implementation.
+class ICEBERG_REST_EXPORT RestCatalog : public Catalog {
  public:
-  explicit RestCatalog(const std::string& base_url);
-  ~RestCatalog() = default;
+  ~RestCatalog() override;
+
+  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<std::unique_ptr<RestCatalog>> Make(const 
RestCatalogProperties& config);
+
+  std::string_view name() const override;
+
+  Result<std::vector<Namespace>> ListNamespaces(const Namespace& ns) const 
override;
+
+  Status CreateNamespace(
+      const Namespace& ns,
+      const std::unordered_map<std::string, std::string>& properties) override;
+
+  Result<std::unordered_map<std::string, std::string>> GetNamespaceProperties(
+      const Namespace& ns) const override;
+
+  Status DropNamespace(const Namespace& ns) override;
+
+  Result<bool> NamespaceExists(const Namespace& ns) const override;
 
-  cpr::Response GetConfig();
+  Status UpdateNamespaceProperties(
+      const Namespace& ns, const std::unordered_map<std::string, std::string>& 
updates,
+      const std::unordered_set<std::string>& removals) override;
 
-  cpr::Response ListNamespaces();
+  Result<std::vector<TableIdentifier>> ListTables(const Namespace& ns) const 
override;
+
+  Result<std::unique_ptr<Table>> CreateTable(
+      const TableIdentifier& identifier, const Schema& schema, const 
PartitionSpec& spec,
+      const std::string& location,
+      const std::unordered_map<std::string, std::string>& properties) override;
+
+  Result<std::unique_ptr<Table>> UpdateTable(
+      const TableIdentifier& identifier,
+      const std::vector<std::unique_ptr<TableRequirement>>& requirements,
+      const std::vector<std::unique_ptr<TableUpdate>>& updates) override;
+
+  Result<std::shared_ptr<Transaction>> StageCreateTable(
+      const TableIdentifier& identifier, const Schema& schema, const 
PartitionSpec& spec,
+      const std::string& location,
+      const std::unordered_map<std::string, std::string>& properties) override;
+
+  Result<bool> TableExists(const TableIdentifier& identifier) const override;
+
+  Status RenameTable(const TableIdentifier& from, const TableIdentifier& to) 
override;
+
+  Status DropTable(const TableIdentifier& identifier, bool purge) override;
+
+  Result<std::unique_ptr<Table>> LoadTable(const TableIdentifier& identifier) 
override;
+
+  Result<std::shared_ptr<Table>> RegisterTable(
+      const TableIdentifier& identifier,
+      const std::string& metadata_file_location) override;
+
+  std::unique_ptr<RestCatalog::TableBuilder> BuildTable(
+      const TableIdentifier& identifier, const Schema& schema) const override;
 
  private:
-  std::string base_url_;
+  RestCatalog(std::unique_ptr<RestCatalogProperties> config,
+              std::unique_ptr<ResourcePaths> paths);
+
+  std::unique_ptr<RestCatalogProperties> config_;
+  std::unique_ptr<HttpClient> client_;
+  std::unique_ptr<ResourcePaths> paths_;
+  std::string name_;
 };
 
-}  // namespace iceberg::catalog::rest
+}  // namespace iceberg::rest
diff --git a/src/iceberg/catalog/rest/rest_util.cc 
b/src/iceberg/catalog/rest/rest_util.cc
new file mode 100644
index 0000000..5a0f166
--- /dev/null
+++ b/src/iceberg/catalog/rest/rest_util.cc
@@ -0,0 +1,123 @@
+/*
+ * 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_util.h"
+
+#include <cpr/util.h>
+
+#include "iceberg/table_identifier.h"
+#include "iceberg/util/macros.h"
+
+namespace iceberg::rest {
+
+namespace {
+const std::string kNamespaceEscapeSeparator = "%1F";
+}
+
+std::string_view TrimTrailingSlash(std::string_view str) {
+  while (!str.empty() && str.back() == '/') {
+    str.remove_suffix(1);
+  }
+  return str;
+}
+
+Result<std::string> EncodeString(std::string_view str_to_encode) {
+  if (str_to_encode.empty()) {
+    return "";
+  }
+
+  // Use CPR's urlEncode which internally calls libcurl's curl_easy_escape()
+  cpr::util::SecureString encoded = cpr::util::urlEncode(str_to_encode);
+  if (encoded.empty()) {
+    return InvalidArgument("Failed to encode string '{}'", str_to_encode);
+  }
+
+  return std::string{encoded.data(), encoded.size()};
+}
+
+Result<std::string> DecodeString(std::string_view str_to_decode) {
+  if (str_to_decode.empty()) {
+    return "";
+  }
+
+  // Use CPR's urlDecode which internally calls libcurl's curl_easy_unescape()
+  cpr::util::SecureString decoded = cpr::util::urlDecode(str_to_decode);
+  if (decoded.empty()) {
+    return InvalidArgument("Failed to decode string '{}'", str_to_decode);
+  }
+
+  return std::string{decoded.data(), decoded.size()};
+}
+
+Result<std::string> EncodeNamespace(const Namespace& ns_to_encode) {
+  if (ns_to_encode.levels.empty()) {
+    return "";
+  }
+
+  ICEBERG_ASSIGN_OR_RAISE(std::string result, 
EncodeString(ns_to_encode.levels.front()));
+
+  for (size_t i = 1; i < ns_to_encode.levels.size(); ++i) {
+    ICEBERG_ASSIGN_OR_RAISE(std::string encoded_level,
+                            EncodeString(ns_to_encode.levels[i]));
+    result.append(kNamespaceEscapeSeparator);
+    result.append(std::move(encoded_level));
+  }
+
+  return result;
+}
+
+Result<Namespace> DecodeNamespace(std::string_view str_to_decode) {
+  if (str_to_decode.empty()) {
+    return Namespace{.levels = {}};
+  }
+
+  Namespace ns{};
+  std::string::size_type start = 0;
+  std::string::size_type end = str_to_decode.find(kNamespaceEscapeSeparator);
+
+  while (end != std::string::npos) {
+    ICEBERG_ASSIGN_OR_RAISE(std::string decoded_level,
+                            DecodeString(str_to_decode.substr(start, end - 
start)));
+    ns.levels.push_back(std::move(decoded_level));
+    start = end + kNamespaceEscapeSeparator.size();
+    end = str_to_decode.find(kNamespaceEscapeSeparator, start);
+  }
+
+  ICEBERG_ASSIGN_OR_RAISE(std::string decoded_level,
+                          DecodeString(str_to_decode.substr(start)));
+  ns.levels.push_back(std::move(decoded_level));
+  return ns;
+}
+
+std::unordered_map<std::string, std::string> MergeConfigs(
+    const std::unordered_map<std::string, std::string>& server_defaults,
+    const std::unordered_map<std::string, std::string>& client_configs,
+    const std::unordered_map<std::string, std::string>& server_overrides) {
+  // Merge with precedence: server_overrides > client_configs > server_defaults
+  auto merged = server_defaults;
+  for (const auto& [key, value] : client_configs) {
+    merged.insert_or_assign(key, value);
+  }
+  for (const auto& [key, value] : server_overrides) {
+    merged.insert_or_assign(key, value);
+  }
+  return merged;
+}
+
+}  // namespace iceberg::rest
diff --git a/src/iceberg/catalog/rest/rest_util.h 
b/src/iceberg/catalog/rest/rest_util.h
new file mode 100644
index 0000000..895bb2f
--- /dev/null
+++ b/src/iceberg/catalog/rest/rest_util.h
@@ -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.
+ */
+
+#pragma once
+
+#include <string>
+#include <string_view>
+#include <unordered_map>
+
+#include "iceberg/catalog/rest/iceberg_rest_export.h"
+#include "iceberg/result.h"
+#include "iceberg/type_fwd.h"
+
+namespace iceberg::rest {
+
+/// \brief Trim trailing slashes from a string.
+///
+/// \param str A string to trim.
+/// \return The trimmed string with all trailing slashes removed.
+ICEBERG_REST_EXPORT std::string_view TrimTrailingSlash(std::string_view str);
+
+/// \brief URL-encode a string (RFC 3986).
+///
+/// \details This implementation uses libcurl (via CPR), which follows RFC 
3986 strictly:
+/// - Unreserved characters: [A-Z], [a-z], [0-9], "-", "_", ".", "~"
+/// - Space is encoded as "%20" (unlike Java's URLEncoder which uses "+").
+/// - All other characters are percent-encoded (%XX).
+/// \param str_to_encode The string to encode.
+/// \return The URL-encoded string or InvalidArgument if the string is invalid.
+ICEBERG_REST_EXPORT Result<std::string> EncodeString(std::string_view 
str_to_encode);
+
+/// \brief URL-decode a string.
+///
+/// \details Decodes percent-encoded characters (e.g., "%20" -> space). Uses 
libcurl's URL
+/// decoding via the CPR library.
+/// \param str_to_decode The encoded string to decode.
+/// \return The decoded string or InvalidArgument if the string is invalid.
+ICEBERG_REST_EXPORT Result<std::string> DecodeString(std::string_view 
str_to_decode);
+
+/// \brief Encode a Namespace into a URL-safe component.
+///
+/// \details Encodes each level separately using EncodeString, then joins them 
with "%1F".
+/// \param ns_to_encode The namespace to encode.
+/// \return The percent-encoded namespace string suitable for URLs.
+ICEBERG_REST_EXPORT Result<std::string> EncodeNamespace(const Namespace& 
ns_to_encode);
+
+/// \brief Decode a URL-encoded namespace string back to a Namespace.
+///
+/// \details Splits by "%1F" (the URL-encoded form of ASCII Unit Separator), 
then decodes
+/// each level separately using DecodeString.
+/// \param str_to_decode The percent-encoded namespace string.
+/// \return The decoded Namespace.
+ICEBERG_REST_EXPORT Result<Namespace> DecodeNamespace(std::string_view 
str_to_decode);
+
+/// \brief Merge catalog configuration properties.
+///
+/// \details Merges three sets of configuration properties following the 
precedence order:
+/// server overrides > client configs > server defaults.
+/// \param server_defaults Default properties provided by the server.
+/// \param client_configs Configuration properties from the client.
+/// \param server_overrides Override properties enforced by the server.
+/// \return A merged map containing all properties with correct precedence.
+ICEBERG_REST_EXPORT std::unordered_map<std::string, std::string> MergeConfigs(
+    const std::unordered_map<std::string, std::string>& server_defaults,
+    const std::unordered_map<std::string, std::string>& client_configs,
+    const std::unordered_map<std::string, std::string>& server_overrides);
+
+}  // namespace iceberg::rest
diff --git a/src/iceberg/catalog/rest/rest_catalog.cc 
b/src/iceberg/catalog/rest/type_fwd.h
similarity index 58%
copy from src/iceberg/catalog/rest/rest_catalog.cc
copy to src/iceberg/catalog/rest/type_fwd.h
index cd008e9..082630f 100644
--- a/src/iceberg/catalog/rest/rest_catalog.cc
+++ b/src/iceberg/catalog/rest/type_fwd.h
@@ -17,28 +17,19 @@
  * under the License.
  */
 
-#include "iceberg/catalog/rest/rest_catalog.h"
+#pragma once
 
-#include <utility>
+/// \file iceberg/catalog/rest/type_fwd.h
+/// Forward declarations and enum definitions for Iceberg REST API types.
 
-#include <cpr/cpr.h>
+namespace iceberg::rest {
 
-#include "iceberg/catalog/rest/types.h"
+struct ErrorModel;
 
-namespace iceberg::catalog::rest {
+class ErrorHandler;
+class HttpClient;
+class ResourcePaths;
+class RestCatalog;
+class RestCatalogProperties;
 
-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
+}  // namespace iceberg::rest
diff --git a/src/iceberg/catalog/rest/types.h b/src/iceberg/catalog/rest/types.h
index 2e32f96..0b589bd 100644
--- a/src/iceberg/catalog/rest/types.h
+++ b/src/iceberg/catalog/rest/types.h
@@ -19,8 +19,6 @@
 
 #pragma once
 
-#include <algorithm>
-#include <format>
 #include <memory>
 #include <string>
 #include <unordered_map>
diff --git a/src/iceberg/result.h b/src/iceberg/result.h
index 743473b..ddc428a 100644
--- a/src/iceberg/result.h
+++ b/src/iceberg/result.h
@@ -30,9 +30,12 @@ namespace iceberg {
 /// \brief Error types for iceberg.
 enum class ErrorKind {
   kAlreadyExists,
+  kBadRequest,
   kCommitFailed,
   kCommitStateUnknown,
   kDecompressError,
+  kForbidden,
+  kInternalServerError,
   kInvalid,  // For general invalid errors
   kInvalidArgument,
   kInvalidArrowData,
@@ -42,12 +45,17 @@ enum class ErrorKind {
   kInvalidSchema,
   kIOError,
   kJsonParseError,
+  kNamespaceNotEmpty,
   kNoSuchNamespace,
   kNoSuchTable,
+  kNoSuchView,
   kNotAllowed,
+  kNotAuthorized,
   kNotFound,
   kNotImplemented,
   kNotSupported,
+  kRestError,
+  kServiceUnavailable,
   kUnknownError,
   kValidationFailed,
 };
@@ -77,12 +85,18 @@ using Status = Result<void>;
       -> std::unexpected<Error> {                                             \
     return std::unexpected<Error>(                                            \
         {ErrorKind::k##name, std::format(fmt, std::forward<Args>(args)...)}); \
+  }                                                                           \
+  inline auto name(const std::string& message) -> std::unexpected<Error> {    \
+    return std::unexpected<Error>({ErrorKind::k##name, message});             \
   }
 
 DEFINE_ERROR_FUNCTION(AlreadyExists)
+DEFINE_ERROR_FUNCTION(BadRequest)
 DEFINE_ERROR_FUNCTION(CommitFailed)
 DEFINE_ERROR_FUNCTION(CommitStateUnknown)
 DEFINE_ERROR_FUNCTION(DecompressError)
+DEFINE_ERROR_FUNCTION(Forbidden)
+DEFINE_ERROR_FUNCTION(InternalServerError)
 DEFINE_ERROR_FUNCTION(Invalid)
 DEFINE_ERROR_FUNCTION(InvalidArgument)
 DEFINE_ERROR_FUNCTION(InvalidArrowData)
@@ -92,12 +106,17 @@ DEFINE_ERROR_FUNCTION(InvalidManifestList)
 DEFINE_ERROR_FUNCTION(InvalidSchema)
 DEFINE_ERROR_FUNCTION(IOError)
 DEFINE_ERROR_FUNCTION(JsonParseError)
+DEFINE_ERROR_FUNCTION(NamespaceNotEmpty)
 DEFINE_ERROR_FUNCTION(NoSuchNamespace)
 DEFINE_ERROR_FUNCTION(NoSuchTable)
+DEFINE_ERROR_FUNCTION(NoSuchView)
 DEFINE_ERROR_FUNCTION(NotAllowed)
+DEFINE_ERROR_FUNCTION(NotAuthorized)
 DEFINE_ERROR_FUNCTION(NotFound)
 DEFINE_ERROR_FUNCTION(NotImplemented)
 DEFINE_ERROR_FUNCTION(NotSupported)
+DEFINE_ERROR_FUNCTION(RestError)
+DEFINE_ERROR_FUNCTION(ServiceUnavailable)
 DEFINE_ERROR_FUNCTION(UnknownError)
 DEFINE_ERROR_FUNCTION(ValidationFailed)
 
diff --git a/src/iceberg/table_identifier.h b/src/iceberg/table_identifier.h
index b145e75..bef9b81 100644
--- a/src/iceberg/table_identifier.h
+++ b/src/iceberg/table_identifier.h
@@ -33,6 +33,8 @@ namespace iceberg {
 /// \brief A namespace in a catalog.
 struct ICEBERG_EXPORT Namespace {
   std::vector<std::string> levels;
+
+  bool operator==(const Namespace& other) const { return levels == 
other.levels; }
 };
 
 /// \brief Identifies a table in iceberg catalog.
@@ -40,6 +42,10 @@ struct ICEBERG_EXPORT TableIdentifier {
   Namespace ns;
   std::string name;
 
+  bool operator==(const TableIdentifier& other) const {
+    return ns == other.ns && name == other.name;
+  }
+
   /// \brief Validates the TableIdentifier.
   Status Validate() const {
     if (name.empty()) {
diff --git a/src/iceberg/test/CMakeLists.txt b/src/iceberg/test/CMakeLists.txt
index 25a0393..c36d33d 100644
--- a/src/iceberg/test/CMakeLists.txt
+++ b/src/iceberg/test/CMakeLists.txt
@@ -176,16 +176,17 @@ if(ICEBERG_BUILD_REST)
 
     add_executable(${test_name})
     target_include_directories(${test_name} PRIVATE 
"${CMAKE_BINARY_DIR}/iceberg/test/")
-
     target_sources(${test_name} PRIVATE ${ARG_SOURCES})
-
     target_link_libraries(${test_name} PRIVATE GTest::gtest_main GTest::gmock
                                                iceberg_rest_static)
-
     add_test(NAME ${test_name} COMMAND ${test_name})
   endfunction()
 
-  add_rest_iceberg_test(rest_catalog_test SOURCES rest_catalog_test.cc
-                        rest_json_internal_test.cc)
+  add_rest_iceberg_test(rest_catalog_test
+                        SOURCES
+                        rest_catalog_test.cc
+                        rest_json_internal_test.cc
+                        rest_util_test.cc)
+
   target_include_directories(rest_catalog_test PRIVATE 
${cpp-httplib_SOURCE_DIR})
 endif()
diff --git a/src/iceberg/test/meson.build b/src/iceberg/test/meson.build
index 4e5d30a..72b09a9 100644
--- a/src/iceberg/test/meson.build
+++ b/src/iceberg/test/meson.build
@@ -93,6 +93,7 @@ if get_option('rest').enabled()
             'sources': files(
                 'rest_catalog_test.cc',
                 'rest_json_internal_test.cc',
+                'rest_util_test.cc',
             ),
             'dependencies': [iceberg_rest_dep, cpp_httplib_dep],
         },
diff --git a/src/iceberg/test/rest_catalog_test.cc 
b/src/iceberg/test/rest_catalog_test.cc
index fda9ef6..40befee 100644
--- a/src/iceberg/test/rest_catalog_test.cc
+++ b/src/iceberg/test/rest_catalog_test.cc
@@ -19,93 +19,152 @@
 
 #include "iceberg/catalog/rest/rest_catalog.h"
 
-#include <httplib.h>
-
-#include <memory>
-#include <thread>
+#include <string>
+#include <unordered_map>
 
 #include <gmock/gmock.h>
 #include <gtest/gtest.h>
-#include <nlohmann/json.hpp>
 
-namespace iceberg::catalog::rest {
+#include "iceberg/catalog/rest/catalog_properties.h"
+#include "iceberg/table_identifier.h"
+#include "iceberg/test/matchers.h"
+
+namespace iceberg::rest {
 
-class RestCatalogIntegrationTest : public ::testing::Test {
+// 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<httplib::Server>();
-    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_ = RestCatalogProperties::default_properties();
+    config_->Set(RestCatalogProperties::kUri, uri)
+        .Set(RestCatalogProperties::kName, std::string("test_catalog"))
+        .Set(RestCatalogProperties::kWarehouse, warehouse);
   }
 
-  void TearDown() override {
-    server_->stop();
-    if (server_thread_.joinable()) {
-      server_thread_.join();
-    }
-  }
+  void TearDown() override {}
 
-  std::unique_ptr<httplib::Server> server_;
-  int port_ = -1;
-  std::thread server_thread_;
+  std::unique_ptr<RestCatalogProperties> 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) {
+  auto invalid_config = RestCatalogProperties::default_properties();
+  invalid_config->Set(RestCatalogProperties::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) {
+  auto custom_config = RestCatalogProperties::default_properties();
+  custom_config
+      ->Set(RestCatalogProperties::kUri, 
config_->Get(RestCatalogProperties::kUri))
+      .Set(RestCatalogProperties::kName, 
config_->Get(RestCatalogProperties::kName))
+      .Set(RestCatalogProperties::kWarehouse,
+           config_->Get(RestCatalogProperties::kWarehouse))
+      .Set(RestCatalogProperties::Entry<std::string>{"custom_prop", ""},
+           std::string("custom_value"))
+      .Set(RestCatalogProperties::Entry<std::string>{"timeout", ""},
+           std::string("30000"));
+
+  auto catalog_result = RestCatalog::Make(*custom_config);
+  EXPECT_THAT(catalog_result, IsOk());
 }
 
-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_ListNamespaces) {
+  auto catalog_result = RestCatalog::Make(*config_);
+  ASSERT_THAT(catalog_result, IsOk());
+  auto& catalog = catalog_result.value();
 
-  std::string base_uri = "http://127.0.0.1:"; + std::to_string(port_);
-  RestCatalog catalog(base_uri);
-  cpr::Response response = catalog.GetConfig();
+  Namespace ns{.levels = {}};
+  auto result = catalog->ListNamespaces(ns);
+  EXPECT_THAT(result, IsOk());
+  EXPECT_FALSE(result->empty());
+  EXPECT_EQ(result->front().levels, 
(std::vector<std::string>{"my_namespace_test2"}));
+}
+
+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<std::string, std::string> props = {{"owner", "test"}};
+
+  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<std::string, std::string> 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<std::string, std::string> updates = {
+      {"description", "test namespace"}};
+  std::unordered_set<std::string> 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
diff --git a/src/iceberg/test/rest_util_test.cc 
b/src/iceberg/test/rest_util_test.cc
new file mode 100644
index 0000000..b95a220
--- /dev/null
+++ b/src/iceberg/test/rest_util_test.cc
@@ -0,0 +1,156 @@
+/*
+ * 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_util.h"
+
+#include <gtest/gtest.h>
+
+#include "iceberg/table_identifier.h"
+#include "iceberg/test/matchers.h"
+
+namespace iceberg::rest {
+
+TEST(RestUtilTest, TrimTrailingSlash) {
+  EXPECT_EQ(TrimTrailingSlash("https://foo";), "https://foo";);
+  EXPECT_EQ(TrimTrailingSlash("https://foo/";), "https://foo";);
+  EXPECT_EQ(TrimTrailingSlash("https://foo////";), "https://foo";);
+}
+
+TEST(RestUtilTest, RoundTripUrlEncodeDecodeNamespace) {
+  // {"dogs"}
+  EXPECT_THAT(EncodeNamespace(Namespace{.levels = {"dogs"}}),
+              HasValue(::testing::Eq("dogs")));
+  EXPECT_THAT(DecodeNamespace("dogs"),
+              HasValue(::testing::Eq(Namespace{.levels = {"dogs"}})));
+
+  // {"dogs.named.hank"}
+  EXPECT_THAT(EncodeNamespace(Namespace{.levels = {"dogs.named.hank"}}),
+              HasValue(::testing::Eq("dogs.named.hank")));
+  EXPECT_THAT(DecodeNamespace("dogs.named.hank"),
+              HasValue(::testing::Eq(Namespace{.levels = 
{"dogs.named.hank"}})));
+
+  // {"dogs/named/hank"}
+  EXPECT_THAT(EncodeNamespace(Namespace{.levels = {"dogs/named/hank"}}),
+              HasValue(::testing::Eq("dogs%2Fnamed%2Fhank")));
+  EXPECT_THAT(DecodeNamespace("dogs%2Fnamed%2Fhank"),
+              HasValue(::testing::Eq(Namespace{.levels = 
{"dogs/named/hank"}})));
+
+  // {"dogs", "named", "hank"}
+  EXPECT_THAT(EncodeNamespace(Namespace{.levels = {"dogs", "named", "hank"}}),
+              HasValue(::testing::Eq("dogs%1Fnamed%1Fhank")));
+  EXPECT_THAT(DecodeNamespace("dogs%1Fnamed%1Fhank"),
+              HasValue(::testing::Eq(Namespace{.levels = {"dogs", "named", 
"hank"}})));
+
+  // {"dogs.and.cats", "named", "hank.or.james-westfall"}
+  EXPECT_THAT(EncodeNamespace(Namespace{
+                  .levels = {"dogs.and.cats", "named", 
"hank.or.james-westfall"}}),
+              
HasValue(::testing::Eq("dogs.and.cats%1Fnamed%1Fhank.or.james-westfall")));
+  
EXPECT_THAT(DecodeNamespace("dogs.and.cats%1Fnamed%1Fhank.or.james-westfall"),
+              HasValue(::testing::Eq(Namespace{
+                  .levels = {"dogs.and.cats", "named", 
"hank.or.james-westfall"}})));
+
+  // empty namespace
+  EXPECT_THAT(EncodeNamespace(Namespace{.levels = {}}), 
HasValue(::testing::Eq("")));
+  EXPECT_THAT(DecodeNamespace(""), HasValue(::testing::Eq(Namespace{.levels = 
{}})));
+}
+
+TEST(RestUtilTest, EncodeString) {
+  // RFC 3986 unreserved characters should not be encoded
+  EXPECT_THAT(EncodeString("abc123XYZ"), HasValue(::testing::Eq("abc123XYZ")));
+  EXPECT_THAT(EncodeString("test-file_name.txt~backup"),
+              HasValue(::testing::Eq("test-file_name.txt~backup")));
+
+  // Spaces and special characters should be encoded
+  EXPECT_THAT(EncodeString("hello world"), 
HasValue(::testing::Eq("hello%20world")));
+  EXPECT_THAT(EncodeString("[email protected]"),
+              HasValue(::testing::Eq("test%40example.com")));
+  EXPECT_THAT(EncodeString("path/to/file"), 
HasValue(::testing::Eq("path%2Fto%2Ffile")));
+  EXPECT_THAT(EncodeString("key=value&foo=bar"),
+              HasValue(::testing::Eq("key%3Dvalue%26foo%3Dbar")));
+  EXPECT_THAT(EncodeString("100%"), HasValue(::testing::Eq("100%25")));
+  EXPECT_THAT(EncodeString("hello\x1Fworld"), 
HasValue(::testing::Eq("hello%1Fworld")));
+  EXPECT_THAT(EncodeString(""), HasValue(::testing::Eq("")));
+}
+
+TEST(RestUtilTest, DecodeString) {
+  // Decode percent-encoded strings
+  EXPECT_THAT(DecodeString("hello%20world"), HasValue(::testing::Eq("hello 
world")));
+  EXPECT_THAT(DecodeString("test%40example.com"),
+              HasValue(::testing::Eq("[email protected]")));
+  EXPECT_THAT(DecodeString("path%2Fto%2Ffile"), 
HasValue(::testing::Eq("path/to/file")));
+  EXPECT_THAT(DecodeString("key%3Dvalue%26foo%3Dbar"),
+              HasValue(::testing::Eq("key=value&foo=bar")));
+  EXPECT_THAT(DecodeString("100%25"), HasValue(::testing::Eq("100%")));
+
+  // ASCII Unit Separator (0x1F)
+  EXPECT_THAT(DecodeString("hello%1Fworld"), 
HasValue(::testing::Eq("hello\x1Fworld")));
+
+  // Unreserved characters remain unchanged
+  EXPECT_THAT(DecodeString("test-file_name.txt~backup"),
+              HasValue(::testing::Eq("test-file_name.txt~backup")));
+  EXPECT_THAT(DecodeString(""), HasValue(::testing::Eq("")));
+}
+
+TEST(RestUtilTest, EncodeDecodeStringRoundTrip) {
+  std::vector<std::string> test_cases = {"hello world",
+                                         "[email protected]",
+                                         "path/to/file",
+                                         "key=value&foo=bar",
+                                         "100%",
+                                         "hello\x1Fworld",
+                                         "special!@#$%^&*()chars",
+                                         "mixed-123_test.file~ok",
+                                         ""};
+
+  for (const auto& test : test_cases) {
+    ICEBERG_UNWRAP_OR_FAIL(std::string encoded, EncodeString(test));
+    ICEBERG_UNWRAP_OR_FAIL(std::string decoded, DecodeString(encoded));
+    EXPECT_EQ(decoded, test) << "Round-trip failed for: " << test;
+  }
+}
+
+TEST(RestUtilTest, MergeConfigs) {
+  std::unordered_map<std::string, std::string> server_defaults = {
+      {"default1", "value1"}, {"default2", "value2"}, {"common", 
"default_value"}};
+
+  std::unordered_map<std::string, std::string> client_configs = {
+      {"client1", "value1"}, {"common", "client_value"}, {"extra", 
"client_value"}};
+
+  std::unordered_map<std::string, std::string> server_overrides = {
+      {"override1", "value1"}, {"common", "override_value"}};
+
+  auto merged = MergeConfigs(server_defaults, client_configs, 
server_overrides);
+
+  EXPECT_EQ(merged.size(), 6);
+
+  // Check precedence: server_overrides > client_configs > server_defaults
+  EXPECT_EQ(merged["default1"], "value1");
+  EXPECT_EQ(merged["default2"], "value2");
+  EXPECT_EQ(merged["client1"], "value1");
+  EXPECT_EQ(merged["override1"], "value1");
+  EXPECT_EQ(merged["common"], "override_value");
+  EXPECT_EQ(merged["extra"], "client_value");
+
+  // Test with empty maps
+  auto merged_empty = MergeConfigs({}, {{"key", "value"}}, {});
+  EXPECT_EQ(merged_empty.size(), 1);
+  EXPECT_EQ(merged_empty["key"], "value");
+}
+
+}  // namespace iceberg::rest
diff --git a/src/iceberg/util/config.h b/src/iceberg/util/config.h
index 7a3a28b..8fb7155 100644
--- a/src/iceberg/util/config.h
+++ b/src/iceberg/util/config.h
@@ -112,6 +112,19 @@ class ConfigBase {
 
   const std::unordered_map<std::string, std::string>& configs() const { return 
configs_; }
 
+  /// \brief Extracts the prefix from the configuration.
+  /// \param prefix The prefix to extract.
+  /// \return A map of entries that match the prefix with prefix removed.
+  std::unordered_map<std::string, std::string> Extract(std::string_view 
prefix) const {
+    std::unordered_map<std::string, std::string> extracted;
+    for (const auto& [key, value] : configs_) {
+      if (key.starts_with(prefix)) {
+        extracted[key.substr(prefix.length())] = value;
+      }
+    }
+    return extracted;
+  }
+
  protected:
   std::unordered_map<std::string, std::string> configs_;
 };


Reply via email to