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