iceberg_catalog_rest/
endpoint.rs1use 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#[derive(Clone, Debug, PartialEq, Eq, Hash)]
49pub struct Endpoint {
50 method: Method,
51 path: String,
52}
53
54impl Endpoint {
55 pub(crate) fn new(method: Method, path: impl Into<String>) -> Self {
61 Self {
62 method,
63 path: path.into(),
64 }
65 }
66
67 pub fn method(&self) -> &str {
69 self.method.as_str()
70 }
71
72 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 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
144macro_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
173pub(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}