| /* |
| * 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 <string> |
| |
| #include <gtest/gtest.h> |
| |
| #include "iceberg/catalog/rest/catalog_properties.h" |
| #include "iceberg/catalog/rest/endpoint.h" |
| #include "iceberg/catalog/rest/resource_paths.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, RoundTripNamespaceWithCustomSeparator) { |
| Namespace ns{.levels = {"dogs.and.cats", "named", "hank.or.james-westfall"}}; |
| |
| EXPECT_THAT(EncodeNamespace(ns, "%2E"), |
| HasValue(::testing::Eq("dogs.and.cats%2Enamed%2Ehank.or.james-westfall"))); |
| EXPECT_THAT(DecodeNamespace("dogs.and.cats%2Enamed%2Ehank.or.james-westfall", "%2E"), |
| HasValue(::testing::Eq(ns))); |
| } |
| |
| TEST(RestUtilTest, NamespaceRejectsEmptySeparator) { |
| auto expect_empty_separator_error = [](const auto& result) { |
| EXPECT_THAT(result, IsError(ErrorKind::kInvalidArgument)); |
| EXPECT_THAT(result, HasErrorMessage("REST namespace separator cannot be empty")); |
| }; |
| |
| expect_empty_separator_error(EncodeNamespace(Namespace{.levels = {"dogs"}}, "")); |
| expect_empty_separator_error(EncodeNamespace(Namespace{.levels = {}}, "")); |
| expect_empty_separator_error(DecodeNamespace("dogs", "")); |
| expect_empty_separator_error(DecodeNamespace("", "")); |
| } |
| |
| TEST(RestUtilTest, ResourcePathsRejectsEmptyNamespaceSeparator) { |
| auto result = ResourcePaths::Make("https://catalog.example.com", "", ""); |
| |
| EXPECT_THAT(result, IsError(ErrorKind::kInvalidArgument)); |
| EXPECT_THAT(result, HasErrorMessage("REST namespace separator cannot be empty")); |
| } |
| |
| 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("test@example.com"), |
| 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("test@example.com"))); |
| 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", |
| "test@example.com", |
| "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"); |
| } |
| |
| struct SnapshotModeValidCase { |
| std::string input; |
| SnapshotMode expected; |
| }; |
| |
| class SnapshotLoadingModeValidTest |
| : public ::testing::TestWithParam<SnapshotModeValidCase> {}; |
| |
| TEST_P(SnapshotLoadingModeValidTest, ParsesCorrectly) { |
| auto config = RestCatalogProperties::default_properties(); |
| config.Set(RestCatalogProperties::kSnapshotLoadingMode, GetParam().input); |
| auto result = config.SnapshotLoadingMode(); |
| ASSERT_THAT(result, IsOk()); |
| EXPECT_EQ(result.value(), GetParam().expected); |
| } |
| |
| INSTANTIATE_TEST_SUITE_P(RestCatalogProperties, SnapshotLoadingModeValidTest, |
| ::testing::Values( |
| // Exact uppercase |
| SnapshotModeValidCase{"ALL", SnapshotMode::kAll}, |
| SnapshotModeValidCase{"REFS", SnapshotMode::kRefs}, |
| // Lowercase (Java parity: toUpperCase before parsing) |
| SnapshotModeValidCase{"all", SnapshotMode::kAll}, |
| SnapshotModeValidCase{"refs", SnapshotMode::kRefs}, |
| // Mixed case |
| SnapshotModeValidCase{"All", SnapshotMode::kAll}, |
| SnapshotModeValidCase{"Refs", SnapshotMode::kRefs})); |
| |
| class SnapshotLoadingModeInvalidTest : public ::testing::TestWithParam<std::string> {}; |
| |
| TEST_P(SnapshotLoadingModeInvalidTest, ReturnsError) { |
| auto config = RestCatalogProperties::default_properties(); |
| config.Set(RestCatalogProperties::kSnapshotLoadingMode, GetParam()); |
| EXPECT_THAT(config.SnapshotLoadingMode(), IsError(ErrorKind::kInvalidArgument)); |
| } |
| |
| INSTANTIATE_TEST_SUITE_P(RestCatalogProperties, SnapshotLoadingModeInvalidTest, |
| ::testing::Values("INVALID", "none", "")); |
| |
| TEST(RestCatalogPropertiesTest, SnapshotLoadingModeDefaultIsAll) { |
| auto config = RestCatalogProperties::default_properties(); |
| auto result = config.SnapshotLoadingMode(); |
| ASSERT_THAT(result, IsOk()); |
| EXPECT_EQ(result.value(), SnapshotMode::kAll); |
| } |
| |
| } // namespace iceberg::rest |