huan233usc commented on code in PR #2692:
URL: https://github.com/apache/iceberg-rust/pull/2692#discussion_r3494046976


##########
crates/catalog/rest/src/endpoint.rs:
##########
@@ -0,0 +1,245 @@
+// 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.
+
+//! Server capability negotiation via the `endpoints` field of `GET 
/v1/config`.
+//!
+//! A REST server may advertise the set of routes it supports in the 
`endpoints`
+//! field of its configuration response, letting clients negotiate optional
+//! capabilities instead of assuming every server implements every operation.
+//! Each entry is a `"{method} {path}"` string, for example
+//! `"POST /v1/{prefix}/namespaces/{namespace}/tables"`; parse one through
+//! [`Endpoint`]'s [`FromStr`] implementation.
+//!
+//! Use 
[`RestCatalog::supports_endpoint`](crate::RestCatalog::supports_endpoint)
+//! to check whether the connected server advertised a given [`Endpoint`].
+
+use std::collections::HashSet;
+use std::fmt::{self, Display, Formatter};
+use std::str::FromStr;
+use std::sync::LazyLock;
+
+use iceberg::{Error, ErrorKind};
+use reqwest::Method;
+use serde::de::{Error as DeError, Visitor};
+use serde::{Deserialize, Deserializer, Serialize, Serializer};
+
+/// A single route a REST server advertises support for, parsed from the
+/// `endpoints` field of `GET /v1/config`.
+///
+/// The wire form is `"{method} {path}"` — an HTTP method and a path template
+/// separated by a single space, e.g.
+/// `"POST /v1/{prefix}/namespaces/{namespace}/tables"`. Parse one with
+/// [`str::parse`]: the method is validated and normalized, and the path is the
+/// template the server advertises (with `{prefix}`, `{namespace}`, …
+/// placeholders), compared verbatim.
+#[derive(Clone, Debug, PartialEq, Eq, Hash)]
+pub struct Endpoint {
+    method: Method,
+    path: String,
+}
+
+impl Endpoint {
+    /// Builds an endpoint from a known-valid method and path template.
+    ///
+    /// Intended for internal constants and tests; untrusted input (such as a
+    /// server's config response) is parsed through [`FromStr`], which 
validates
+    /// it.
+    pub(crate) fn new(method: Method, path: impl Into<String>) -> Self {
+        Self {
+            method,
+            path: path.into(),
+        }
+    }
+
+    /// The HTTP method, e.g. `GET` or `POST`.
+    pub fn method(&self) -> &str {
+        self.method.as_str()
+    }
+
+    /// The path template, e.g. `/v1/{prefix}/namespaces`.
+    pub fn path(&self) -> &str {
+        &self.path
+    }
+}
+
+impl FromStr for Endpoint {
+    type Err = Error;
+
+    fn from_str(s: &str) -> std::result::Result<Self, Self::Err> {
+        // The wire form is exactly `"<method> <path>"` separated by a single
+        // space; paths never contain spaces, so require exactly two non-empty
+        // parts and a valid HTTP method.
+        let mut parts = s.split(' ');
+        match (parts.next(), parts.next(), parts.next()) {
+            (Some(method), Some(path), None) if !method.is_empty() && 
!path.is_empty() => {
+                let method = 
Method::from_str(&method.to_ascii_uppercase()).map_err(|_| {
+                    Error::new(
+                        ErrorKind::DataInvalid,
+                        format!("invalid HTTP method in endpoint: {s:?}"),
+                    )
+                })?;
+                Ok(Self {
+                    method,
+                    path: path.to_string(),
+                })
+            }
+            _ => Err(Error::new(
+                ErrorKind::DataInvalid,
+                format!(
+                    r#"invalid endpoint {s:?}: expected "<method> <path>" 
separated by a single space"#
+                ),
+            )),
+        }
+    }
+}
+
+impl Display for Endpoint {
+    fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result {
+        write!(f, "{} {}", self.method, self.path)
+    }
+}
+
+impl Serialize for Endpoint {
+    fn serialize<S>(&self, serializer: S) -> std::result::Result<S::Ok, 
S::Error>
+    where S: Serializer {
+        serializer.collect_str(self)
+    }
+}
+
+impl<'de> Deserialize<'de> for Endpoint {
+    fn deserialize<D>(deserializer: D) -> std::result::Result<Self, D::Error>
+    where D: Deserializer<'de> {
+        struct EndpointVisitor;
+
+        impl Visitor<'_> for EndpointVisitor {
+            type Value = Endpoint;
+
+            fn expecting(&self, f: &mut Formatter<'_>) -> fmt::Result {
+                f.write_str(r#"an endpoint string of the form "<method> 
<path>""#)
+            }
+
+            fn visit_str<E>(self, v: &str) -> std::result::Result<Endpoint, E>
+            where E: DeError {
+                Endpoint::from_str(v).map_err(E::custom)
+            }
+        }
+
+        deserializer.deserialize_str(EndpointVisitor)
+    }
+}
+
+/// The standard v1 endpoints assumed to be supported when a server does not
+/// advertise an `endpoints` list in its `GET /v1/config` response.
+///
+/// A server that omits the field (for example one that predates it) is treated
+/// as supporting this base set of namespace and table operations; a server 
that
+/// sends an explicit list — even an empty one — is taken at its word.
+pub(crate) static DEFAULT_ENDPOINTS: LazyLock<HashSet<Endpoint>> = 
LazyLock::new(|| {
+    [
+        (Method::GET, "/v1/{prefix}/namespaces"),
+        (Method::POST, "/v1/{prefix}/namespaces"),
+        (Method::GET, "/v1/{prefix}/namespaces/{namespace}"),
+        (Method::DELETE, "/v1/{prefix}/namespaces/{namespace}"),
+        (
+            Method::POST,
+            "/v1/{prefix}/namespaces/{namespace}/properties",
+        ),
+        (Method::GET, "/v1/{prefix}/namespaces/{namespace}/tables"),
+        (Method::POST, "/v1/{prefix}/namespaces/{namespace}/tables"),
+        (
+            Method::GET,
+            "/v1/{prefix}/namespaces/{namespace}/tables/{table}",
+        ),
+        (
+            Method::POST,
+            "/v1/{prefix}/namespaces/{namespace}/tables/{table}",
+        ),
+        (
+            Method::DELETE,
+            "/v1/{prefix}/namespaces/{namespace}/tables/{table}",
+        ),
+        (Method::POST, "/v1/{prefix}/tables/rename"),
+        (Method::POST, "/v1/{prefix}/namespaces/{namespace}/register"),
+        (
+            Method::POST,
+            "/v1/{prefix}/namespaces/{namespace}/tables/{table}/metrics",
+        ),
+        (Method::POST, "/v1/{prefix}/transactions/commit"),
+    ]
+    .into_iter()
+    .map(|(method, path)| Endpoint::new(method, path))
+    .collect()
+});
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+
+    #[test]
+    fn parses_and_round_trips() {
+        let ep: Endpoint = "POST /v1/{prefix}/namespaces/{namespace}/tables"
+            .parse()
+            .unwrap();
+        assert_eq!(ep.method(), "POST");
+        assert_eq!(ep.path(), "/v1/{prefix}/namespaces/{namespace}/tables");
+
+        let json = serde_json::to_string(&ep).unwrap();
+        assert_eq!(json, r#""POST 
/v1/{prefix}/namespaces/{namespace}/tables""#);
+        assert_eq!(serde_json::from_str::<Endpoint>(&json).unwrap(), ep);
+    }
+
+    #[test]
+    fn rejects_malformed_endpoints() {
+        // Must be exactly "<method> <path>" with a single separating space.
+        for invalid in [
+            "GET",      // no space
+            "GET  /v1", // two spaces
+            " GET /v1", // leading space (empty method)
+            "GET ",     // trailing space (empty path)
+            " /v1",     // empty method
+            "",         // empty
+        ] {
+            assert!(
+                invalid.parse::<Endpoint>().is_err(),
+                "expected {invalid:?} to be rejected"
+            );
+        }
+    }
+
+    #[test]
+    fn normalizes_http_method_to_uppercase() {
+        assert_eq!("get /v1/x".parse::<Endpoint>().unwrap().method(), "GET");
+    }
+
+    #[test]
+    fn default_endpoints_cover_base_ops_but_not_optional_ones() {

Review Comment:
   Removed — agreed it just restated DEFAULT_ENDPOINTS, and 
test_config_without_endpoints_falls_back_to_default_set already covers the 
behavior.



-- 
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.

To unsubscribe, e-mail: [email protected]

For queries about this service, please contact Infrastructure at:
[email protected]


---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]

Reply via email to