Your browser sucks, JavaScript disabled! Gunnar's console needs JavaScript and WebAssembly. Git over SSH on port 2222 does not.

nordisk/skade

vendor/iceberg-catalog-rest-arrow59/src/endpoint.rs

// 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.
//! The REST catalog uses the advertised set internally to select supported
//! operations and compatibility fallbacks.

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) -> 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) -> Result<S::Ok, S::Error>
    where S: Serializer {
        serializer.collect_str(self)
    }
}

impl<'de> Deserialize<'de> for Endpoint {
    fn deserialize<D>(deserializer: D) -> 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) -> Result<Endpoint, E>
            where E: DeError {
                Endpoint::from_str(v).map_err(E::custom)
            }
        }

        deserializer.deserialize_str(EndpointVisitor)
    }
}

/// Declares named [`Endpoint`] constants for routes the client may negotiate.
macro_rules! endpoints {
    ($($name:ident => $method:ident $path:literal),+ $(,)?) => {
        $(
            pub(crate) static $name: LazyLock<Endpoint> =
                LazyLock::new(|| Endpoint::new(Method::$method, $path));
        )+
    };
}

endpoints! {
    V1_LIST_NAMESPACES => GET "/v1/{prefix}/namespaces",
    V1_CREATE_NAMESPACE => POST "/v1/{prefix}/namespaces",
    V1_LOAD_NAMESPACE => GET "/v1/{prefix}/namespaces/{namespace}",
    V1_DELETE_NAMESPACE => DELETE "/v1/{prefix}/namespaces/{namespace}",
    V1_UPDATE_NAMESPACE => POST "/v1/{prefix}/namespaces/{namespace}/properties",
    V1_NAMESPACE_EXISTS => HEAD "/v1/{prefix}/namespaces/{namespace}",
    V1_LIST_TABLES => GET "/v1/{prefix}/namespaces/{namespace}/tables",
    V1_CREATE_TABLE => POST "/v1/{prefix}/namespaces/{namespace}/tables",
    V1_LOAD_TABLE => GET "/v1/{prefix}/namespaces/{namespace}/tables/{table}",
    V1_UPDATE_TABLE => POST "/v1/{prefix}/namespaces/{namespace}/tables/{table}",
    V1_DELETE_TABLE => DELETE "/v1/{prefix}/namespaces/{namespace}/tables/{table}",
    V1_TABLE_EXISTS => HEAD "/v1/{prefix}/namespaces/{namespace}/tables/{table}",
    V1_RENAME_TABLE => POST "/v1/{prefix}/tables/rename",
    V1_REGISTER_TABLE => POST "/v1/{prefix}/namespaces/{namespace}/register",
    V1_REPORT_METRICS => POST "/v1/{prefix}/namespaces/{namespace}/tables/{table}/metrics",
    V1_COMMIT_TRANSACTION => POST "/v1/{prefix}/transactions/commit",
}

/// The standard v1 endpoints assumed to be supported when a server's
/// `GET /v1/config` response omits the `endpoints` field or sends an empty list
/// (the two are treated alike). These are the minimum a server is expected to
/// support; a server that advertises a non-empty list is taken at its word
/// instead.
///
/// Note: existence-check `HEAD` routes are intentionally omitted — servers that
/// do not advertise them are expected to fall back to `GET` load paths.
pub(crate) static DEFAULT_ENDPOINTS: LazyLock<HashSet<Endpoint>> = LazyLock::new(|| {
    [
        &*V1_LIST_NAMESPACES,
        &*V1_CREATE_NAMESPACE,
        &*V1_LOAD_NAMESPACE,
        &*V1_DELETE_NAMESPACE,
        &*V1_UPDATE_NAMESPACE,
        &*V1_LIST_TABLES,
        &*V1_CREATE_TABLE,
        &*V1_LOAD_TABLE,
        &*V1_UPDATE_TABLE,
        &*V1_DELETE_TABLE,
        &*V1_RENAME_TABLE,
        &*V1_REGISTER_TABLE,
        &*V1_REPORT_METRICS,
        &*V1_COMMIT_TRANSACTION,
    ]
    .into_iter()
    .cloned()
    .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() {
        for invalid in ["GET", "GET  /v1", " GET /v1", "GET ", " /v1", ""] {
            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");
    }
}