Skip to main content

iceberg_catalog_rest/
endpoint.rs

1// Licensed to the Apache Software Foundation (ASF) under one
2// or more contributor license agreements.  See the NOTICE file
3// distributed with this work for additional information
4// regarding copyright ownership.  The ASF licenses this file
5// to you under the Apache License, Version 2.0 (the
6// "License"); you may not use this file except in compliance
7// with the License.  You may obtain a copy of the License at
8//
9//   http://www.apache.org/licenses/LICENSE-2.0
10//
11// Unless required by applicable law or agreed to in writing,
12// software distributed under the License is distributed on an
13// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
14// KIND, either express or implied.  See the License for the
15// specific language governing permissions and limitations
16// under the License.
17
18//! Server capability negotiation via the `endpoints` field of `GET /v1/config`.
19//!
20//! A REST server may advertise the set of routes it supports in the `endpoints`
21//! field of its configuration response, letting clients negotiate optional
22//! capabilities instead of assuming every server implements every operation.
23//! Each entry is a `"{method} {path}"` string, for example
24//! `"POST /v1/{prefix}/namespaces/{namespace}/tables"`; parse one through
25//! [`Endpoint`]'s [`FromStr`] implementation.
26//!
27//! Use [`RestCatalog::supports_endpoint`](crate::RestCatalog::supports_endpoint)
28//! to check whether the connected server advertised a given [`Endpoint`].
29
30use std::collections::HashSet;
31use std::fmt::{self, Display, Formatter};
32use std::str::FromStr;
33use std::sync::LazyLock;
34
35use iceberg::{Error, ErrorKind};
36use reqwest::Method;
37use serde::de::{Error as DeError, Visitor};
38use serde::{Deserialize, Deserializer, Serialize, Serializer};
39
40/// A single route a REST server advertises support for, parsed from the
41/// `endpoints` field of `GET /v1/config`.
42///
43/// The wire form is `"{method} {path}"` — an HTTP method and a path template
44/// separated by a single space, e.g.
45/// `"POST /v1/{prefix}/namespaces/{namespace}/tables"`. Parse one with
46/// [`str::parse`]: the method is validated and normalized, and the path is the
47/// template the server advertises (with `{prefix}`, `{namespace}`, …
48/// placeholders), compared verbatim.
49#[derive(Clone, Debug, PartialEq, Eq, Hash)]
50pub struct Endpoint {
51    method: Method,
52    path: String,
53}
54
55impl Endpoint {
56    /// Builds an endpoint from a known-valid method and path template.
57    ///
58    /// Intended for internal constants and tests; untrusted input (such as a
59    /// server's config response) is parsed through [`FromStr`], which validates
60    /// it.
61    pub(crate) fn new(method: Method, path: impl Into<String>) -> Self {
62        Self {
63            method,
64            path: path.into(),
65        }
66    }
67
68    /// The HTTP method, e.g. `GET` or `POST`.
69    pub fn method(&self) -> &str {
70        self.method.as_str()
71    }
72
73    /// The path template, e.g. `/v1/{prefix}/namespaces`.
74    pub fn path(&self) -> &str {
75        &self.path
76    }
77}
78
79impl FromStr for Endpoint {
80    type Err = Error;
81
82    fn from_str(s: &str) -> Result<Self, Self::Err> {
83        // The wire form is exactly `"<method> <path>"` separated by a single
84        // space; paths never contain spaces, so require exactly two non-empty
85        // parts and a valid HTTP method.
86        let mut parts = s.split(' ');
87        match (parts.next(), parts.next(), parts.next()) {
88            (Some(method), Some(path), None) if !method.is_empty() && !path.is_empty() => {
89                let method = Method::from_str(&method.to_ascii_uppercase()).map_err(|_| {
90                    Error::new(
91                        ErrorKind::DataInvalid,
92                        format!("invalid HTTP method in endpoint: {s:?}"),
93                    )
94                })?;
95                Ok(Self {
96                    method,
97                    path: path.to_string(),
98                })
99            }
100            _ => Err(Error::new(
101                ErrorKind::DataInvalid,
102                format!(
103                    r#"invalid endpoint {s:?}: expected "<method> <path>" separated by a single space"#
104                ),
105            )),
106        }
107    }
108}
109
110impl Display for Endpoint {
111    fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result {
112        write!(f, "{} {}", self.method, self.path)
113    }
114}
115
116impl Serialize for Endpoint {
117    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
118    where S: Serializer {
119        serializer.collect_str(self)
120    }
121}
122
123impl<'de> Deserialize<'de> for Endpoint {
124    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
125    where D: Deserializer<'de> {
126        struct EndpointVisitor;
127
128        impl Visitor<'_> for EndpointVisitor {
129            type Value = Endpoint;
130
131            fn expecting(&self, f: &mut Formatter<'_>) -> fmt::Result {
132                f.write_str(r#"an endpoint string of the form "<method> <path>""#)
133            }
134
135            fn visit_str<E>(self, v: &str) -> Result<Endpoint, E>
136            where E: DeError {
137                Endpoint::from_str(v).map_err(E::custom)
138            }
139        }
140
141        deserializer.deserialize_str(EndpointVisitor)
142    }
143}
144
145/// Declares named [`Endpoint`] constants for routes the client may negotiate.
146macro_rules! endpoints {
147    ($($name:ident => $method:ident $path:literal),+ $(,)?) => {
148        $(
149            pub(crate) static $name: LazyLock<Endpoint> =
150                LazyLock::new(|| Endpoint::new(Method::$method, $path));
151        )+
152    };
153}
154
155endpoints! {
156    V1_LIST_NAMESPACES => GET "/v1/{prefix}/namespaces",
157    V1_CREATE_NAMESPACE => POST "/v1/{prefix}/namespaces",
158    V1_LOAD_NAMESPACE => GET "/v1/{prefix}/namespaces/{namespace}",
159    V1_DELETE_NAMESPACE => DELETE "/v1/{prefix}/namespaces/{namespace}",
160    V1_UPDATE_NAMESPACE => POST "/v1/{prefix}/namespaces/{namespace}/properties",
161    V1_NAMESPACE_EXISTS => HEAD "/v1/{prefix}/namespaces/{namespace}",
162    V1_LIST_TABLES => GET "/v1/{prefix}/namespaces/{namespace}/tables",
163    V1_CREATE_TABLE => POST "/v1/{prefix}/namespaces/{namespace}/tables",
164    V1_LOAD_TABLE => GET "/v1/{prefix}/namespaces/{namespace}/tables/{table}",
165    V1_UPDATE_TABLE => POST "/v1/{prefix}/namespaces/{namespace}/tables/{table}",
166    V1_DELETE_TABLE => DELETE "/v1/{prefix}/namespaces/{namespace}/tables/{table}",
167    V1_TABLE_EXISTS => HEAD "/v1/{prefix}/namespaces/{namespace}/tables/{table}",
168    V1_RENAME_TABLE => POST "/v1/{prefix}/tables/rename",
169    V1_REGISTER_TABLE => POST "/v1/{prefix}/namespaces/{namespace}/register",
170    V1_REPORT_METRICS => POST "/v1/{prefix}/namespaces/{namespace}/tables/{table}/metrics",
171    V1_COMMIT_TRANSACTION => POST "/v1/{prefix}/transactions/commit",
172}
173
174/// The standard v1 endpoints assumed to be supported when a server's
175/// `GET /v1/config` response omits the `endpoints` field or sends an empty list
176/// (the two are treated alike). These are the minimum a server is expected to
177/// support; a server that advertises a non-empty list is taken at its word
178/// instead.
179///
180/// Note: existence-check `HEAD` routes are intentionally omitted — servers that
181/// do not advertise them are expected to fall back to `GET` load paths.
182pub(crate) static DEFAULT_ENDPOINTS: LazyLock<HashSet<Endpoint>> = LazyLock::new(|| {
183    [
184        &*V1_LIST_NAMESPACES,
185        &*V1_CREATE_NAMESPACE,
186        &*V1_LOAD_NAMESPACE,
187        &*V1_DELETE_NAMESPACE,
188        &*V1_UPDATE_NAMESPACE,
189        &*V1_LIST_TABLES,
190        &*V1_CREATE_TABLE,
191        &*V1_LOAD_TABLE,
192        &*V1_UPDATE_TABLE,
193        &*V1_DELETE_TABLE,
194        &*V1_RENAME_TABLE,
195        &*V1_REGISTER_TABLE,
196        &*V1_REPORT_METRICS,
197        &*V1_COMMIT_TRANSACTION,
198    ]
199    .into_iter()
200    .cloned()
201    .collect()
202});
203
204#[cfg(test)]
205mod tests {
206    use super::*;
207
208    #[test]
209    fn parses_and_round_trips() {
210        let ep: Endpoint = "POST /v1/{prefix}/namespaces/{namespace}/tables"
211            .parse()
212            .unwrap();
213        assert_eq!(ep.method(), "POST");
214        assert_eq!(ep.path(), "/v1/{prefix}/namespaces/{namespace}/tables");
215
216        let json = serde_json::to_string(&ep).unwrap();
217        assert_eq!(json, r#""POST /v1/{prefix}/namespaces/{namespace}/tables""#);
218        assert_eq!(serde_json::from_str::<Endpoint>(&json).unwrap(), ep);
219    }
220
221    #[test]
222    fn rejects_malformed_endpoints() {
223        for invalid in ["GET", "GET  /v1", " GET /v1", "GET ", " /v1", ""] {
224            assert!(
225                invalid.parse::<Endpoint>().is_err(),
226                "expected {invalid:?} to be rejected"
227            );
228        }
229    }
230
231    #[test]
232    fn normalizes_http_method_to_uppercase() {
233        assert_eq!("get /v1/x".parse::<Endpoint>().unwrap().method(), "GET");
234    }
235}