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