Skip to main content

unitycatalog_common/
reference.rs

1//! URL scheme for referencing Unity Catalog securables.
2//!
3//! Defines a single, conventional URL grammar that addresses Unity Catalog
4//! volumes, tables, and raw external paths through one parser. The Rust
5//! `unitycatalog-object-store` factory and the Python `unitycatalog_client`
6//! bindings both consume this type so the URL surface stays in lock-step
7//! across languages.
8//!
9//! # Scheme
10//!
11//! | URL shape                                                  | Vending endpoint               |
12//! |------------------------------------------------------------|--------------------------------|
13//! | `uc:///Volumes/<catalog>/<schema>/<volume>[/<path>]`       | `temporary-volume-credentials` |
14//! | `uc:///Tables/<catalog>/<schema>/<table>`                  | `temporary-table-credentials`  |
15//! | `s3://`, `s3a://`, `gs://`, `abfs://`, `abfss://`, `az://`, `azure://`, `r2://` raw cloud URL | `temporary-path-credentials` |
16//!
17//! For ecosystem compatibility, the parser also accepts the
18//! `vol+dbfs:/Volumes/<catalog>/<schema>/<volume>[/<path>]` form used by
19//! some Databricks credential-vending samples — it is treated as identical
20//! to the `uc:///Volumes/...` form.
21//!
22//! ## Why the kind goes in the path
23//!
24//! The capitalised `Volumes` / `Tables` segment mirrors the Databricks
25//! workspace POSIX convention (`/Volumes/<catalog>/<schema>/<volume>/...`).
26//! It can't live in the URL authority because Unity Catalog names commonly
27//! contain underscores, which are not allowed in hostnames per RFC 1123 and
28//! which `url::Url` would silently lowercase.
29//!
30//! ## Case-insensitivity
31//!
32//! Kind segments are matched **case-insensitively**, so `/Volumes/...`,
33//! `/volumes/...`, and `/VOLUMES/...` all dispatch the same way. The
34//! capitalised form is canonical in docs because it matches the Databricks
35//! workspace path convention; the lowercase form matches the REST API.
36//! Catalog, schema, volume, and table names are passed through verbatim.
37
38use std::str::FromStr;
39
40use url::Url;
41
42use crate::error::{Error, Result};
43
44/// A parsed reference to a Unity Catalog securable.
45///
46/// Construct one via [`UCReference::parse`] (or its [`FromStr`] impl). The
47/// variants map 1:1 to the three credential-vending endpoints exposed by
48/// Unity Catalog.
49#[derive(Debug, Clone, PartialEq, Eq)]
50pub enum UCReference {
51    /// A Unity Catalog volume, with an optional sub-path inside the volume.
52    Volume {
53        catalog: String,
54        schema: String,
55        volume: String,
56        /// Sub-path inside the volume, e.g. `raw/2024/01/file.parquet`.
57        ///
58        /// Empty when the URL addresses the volume root.
59        path: String,
60    },
61    /// A Unity Catalog table (referenced by its three-level name).
62    Table {
63        catalog: String,
64        schema: String,
65        table: String,
66    },
67    /// A raw cloud URL (`s3://`, `gs://`, `abfss://`, ...). Credentials are
68    /// vended via the `temporary-path-credentials` endpoint.
69    Path(Url),
70}
71
72impl UCReference {
73    /// Parse a URL string into a [`UCReference`].
74    ///
75    /// See the [module-level documentation](self) for the supported shapes.
76    pub fn parse(input: &str) -> Result<Self> {
77        // Handle the `vol+dbfs:` alias before passing through to `url::Url`,
78        // because `vol+dbfs:/Volumes/...` is path-only (no authority) and we
79        // want to share the rest of the volume-parsing logic with `uc://`.
80        if let Some(rest) = input.strip_prefix("vol+dbfs:") {
81            return parse_volume_form(rest).map_err(|e| {
82                Error::invalid_argument(format!("invalid vol+dbfs URL `{input}`: {e}"))
83            });
84        }
85
86        let url = Url::parse(input)
87            .map_err(|e| Error::invalid_argument(format!("could not parse `{input}`: {e}")))?;
88
89        match url.scheme() {
90            "uc" => parse_uc(&url)
91                .map_err(|e| Error::invalid_argument(format!("invalid uc URL `{input}`: {e}"))),
92            // Treat all known cloud schemes as raw external paths.
93            "s3" | "s3a" | "gs" | "gcs" | "abfs" | "abfss" | "az" | "azure" | "adl" | "r2"
94            | "file" | "http" | "https" => Ok(UCReference::Path(url)),
95            other => Err(Error::invalid_argument(format!(
96                "unsupported URL scheme `{other}` (expected `uc`, `vol+dbfs`, or a cloud scheme like `s3`/`gs`/`abfss`)"
97            ))),
98        }
99    }
100
101    /// Convenience accessor: returns the three-level dotted name for
102    /// [`Volume`] and [`Table`] references, [`None`] for raw paths.
103    ///
104    /// [`Volume`]: UCReference::Volume
105    /// [`Table`]: UCReference::Table
106    pub fn full_name(&self) -> Option<String> {
107        match self {
108            UCReference::Volume {
109                catalog,
110                schema,
111                volume,
112                ..
113            } => Some(format!("{catalog}.{schema}.{volume}")),
114            UCReference::Table {
115                catalog,
116                schema,
117                table,
118            } => Some(format!("{catalog}.{schema}.{table}")),
119            UCReference::Path(_) => None,
120        }
121    }
122}
123
124impl FromStr for UCReference {
125    type Err = Error;
126
127    fn from_str(s: &str) -> Result<Self> {
128        Self::parse(s)
129    }
130}
131
132fn parse_uc(url: &Url) -> std::result::Result<UCReference, String> {
133    // `uc:///Volumes/...` parses with `url::Url` as host == Some("") and
134    // path == "/Volumes/..." — a leading slash on the path. We treat both
135    // `uc:///` and `uc://` (no host) consistently by working off the path.
136    if url.host_str().is_some_and(|h| !h.is_empty()) {
137        return Err(format!(
138            "`uc` URLs must use an empty authority (e.g. `uc:///Volumes/...`), got host `{}`",
139            url.host_str().unwrap()
140        ));
141    }
142    let path = url.path();
143    if !path.starts_with('/') {
144        return Err(format!("expected an absolute path, got `{path}`"));
145    }
146    let segments: Vec<&str> = path
147        .trim_start_matches('/')
148        .split('/')
149        .filter(|s| !s.is_empty())
150        .collect();
151    parse_securable_segments(&segments)
152}
153
154/// Shared logic for `uc:///Volumes/...`, `uc:///Tables/...`, and the
155/// `vol+dbfs:/Volumes/...` alias.
156///
157/// The first segment (the "kind") is matched case-insensitively so any of
158/// `Volumes`, `volumes`, or `VOLUMES` work; all other segments are
159/// preserved verbatim.
160fn parse_securable_segments(segments: &[&str]) -> std::result::Result<UCReference, String> {
161    let kind = match segments.first() {
162        None => return Err("empty path; expected `/Volumes/...` or `/Tables/...`".to_string()),
163        Some(k) => *k,
164    };
165    let tail = &segments[1..];
166
167    if kind.eq_ignore_ascii_case("Volumes") {
168        return match tail {
169            [catalog, schema, volume, rest @ ..] => Ok(UCReference::Volume {
170                catalog: percent_decode(catalog)?,
171                schema: percent_decode(schema)?,
172                volume: percent_decode(volume)?,
173                path: rest.join("/"),
174            }),
175            _ => Err(
176                "`uc:///Volumes/<catalog>/<schema>/<volume>[/<path>]` requires all three names"
177                    .to_string(),
178            ),
179        };
180    }
181
182    if kind.eq_ignore_ascii_case("Tables") {
183        return match tail {
184            [catalog, schema, table] => Ok(UCReference::Table {
185                catalog: percent_decode(catalog)?,
186                schema: percent_decode(schema)?,
187                table: percent_decode(table)?,
188            }),
189            _ => Err(
190                "`uc:///Tables/<catalog>/<schema>/<table>` requires exactly three name segments"
191                    .to_string(),
192            ),
193        };
194    }
195
196    Err(format!(
197        "unknown securable kind `{kind}`; expected `Volumes` or `Tables` (case-insensitive)"
198    ))
199}
200
201/// Parse the path component of a `vol+dbfs:/Volumes/<c>/<s>/<v>/<p>` URL.
202///
203/// The `vol+dbfs` scheme is non-hierarchical (no `//` authority), so callers
204/// pass the raw remainder after the `vol+dbfs:` prefix.
205fn parse_volume_form(rest: &str) -> std::result::Result<UCReference, String> {
206    let path = rest.trim_start_matches('/');
207    let segments: Vec<&str> = path.split('/').filter(|s| !s.is_empty()).collect();
208    match parse_securable_segments(&segments)? {
209        volume @ UCReference::Volume { .. } => Ok(volume),
210        _ => Err("expected `vol+dbfs:/Volumes/<catalog>/<schema>/<volume>[/<path>]`".to_string()),
211    }
212}
213
214fn percent_decode(s: &str) -> std::result::Result<String, String> {
215    // The `url` crate already decodes most of the path for us; we just
216    // surface invalid UTF-8 explicitly so callers see a clean error.
217    percent_encoding::percent_decode_str(s)
218        .decode_utf8()
219        .map(|c| c.into_owned())
220        .map_err(|e| format!("invalid percent-encoding in `{s}`: {e}"))
221}
222
223#[cfg(test)]
224mod tests {
225    use super::*;
226
227    #[test]
228    fn parses_volume_with_path() {
229        let r =
230            UCReference::parse("uc:///Volumes/main/default/landing/raw/2024/file.parquet").unwrap();
231        assert_eq!(
232            r,
233            UCReference::Volume {
234                catalog: "main".into(),
235                schema: "default".into(),
236                volume: "landing".into(),
237                path: "raw/2024/file.parquet".into(),
238            }
239        );
240        assert_eq!(r.full_name().as_deref(), Some("main.default.landing"));
241    }
242
243    #[test]
244    fn parses_volume_root() {
245        let r = UCReference::parse("uc:///Volumes/main/default/landing").unwrap();
246        assert!(matches!(&r, UCReference::Volume { path, .. } if path.is_empty()));
247    }
248
249    #[test]
250    fn parses_volume_root_trailing_slash() {
251        let r = UCReference::parse("uc:///Volumes/main/default/landing/").unwrap();
252        assert!(matches!(&r, UCReference::Volume { path, .. } if path.is_empty()));
253    }
254
255    #[test]
256    fn parses_table() {
257        let r = UCReference::parse("uc:///Tables/main/default/orders").unwrap();
258        assert_eq!(
259            r,
260            UCReference::Table {
261                catalog: "main".into(),
262                schema: "default".into(),
263                table: "orders".into(),
264            }
265        );
266        assert_eq!(r.full_name().as_deref(), Some("main.default.orders"));
267    }
268
269    #[test]
270    fn vol_dbfs_alias() {
271        let r = UCReference::parse("vol+dbfs:/Volumes/main/default/landing/sub/file.bin").unwrap();
272        assert_eq!(
273            r,
274            UCReference::Volume {
275                catalog: "main".into(),
276                schema: "default".into(),
277                volume: "landing".into(),
278                path: "sub/file.bin".into(),
279            }
280        );
281    }
282
283    #[test]
284    fn raw_cloud_urls_are_paths() {
285        for raw in [
286            "s3://bucket/prefix/key",
287            "s3a://bucket/prefix",
288            "gs://bucket/x",
289            "abfss://container@account.dfs.core.windows.net/path",
290            "az://account/container/blob",
291            "r2://bucket/key",
292        ] {
293            let r = UCReference::parse(raw).unwrap();
294            assert!(matches!(r, UCReference::Path(_)), "expected Path for {raw}");
295            assert!(r.full_name().is_none());
296        }
297    }
298
299    #[test]
300    fn rejects_volume_with_missing_segments() {
301        let err = UCReference::parse("uc:///Volumes/main/default").unwrap_err();
302        assert!(format!("{err}").contains("Volumes"));
303    }
304
305    #[test]
306    fn rejects_table_with_extra_segments() {
307        let err = UCReference::parse("uc:///Tables/main/default/orders/extra").unwrap_err();
308        assert!(format!("{err}").contains("Tables"));
309    }
310
311    #[test]
312    fn rejects_unknown_securable_kind() {
313        let err = UCReference::parse("uc:///Functions/main/default/fn").unwrap_err();
314        assert!(format!("{err}").contains("Functions"));
315    }
316
317    #[test]
318    fn rejects_unknown_scheme() {
319        let err = UCReference::parse("ftp://server/path").unwrap_err();
320        assert!(format!("{err}").contains("unsupported"));
321    }
322
323    #[test]
324    fn rejects_uc_with_authority() {
325        let err = UCReference::parse("uc://some-host/Volumes/main/default/v").unwrap_err();
326        assert!(format!("{err}").contains("empty authority"));
327    }
328
329    #[test]
330    fn kind_segment_is_case_insensitive() {
331        for url in [
332            "uc:///Volumes/main/default/landing/raw/file.parquet",
333            "uc:///volumes/main/default/landing/raw/file.parquet",
334            "uc:///VOLUMES/main/default/landing/raw/file.parquet",
335            "uc:///VoLuMeS/main/default/landing/raw/file.parquet",
336        ] {
337            let r = UCReference::parse(url).unwrap();
338            assert_eq!(
339                r,
340                UCReference::Volume {
341                    catalog: "main".into(),
342                    schema: "default".into(),
343                    volume: "landing".into(),
344                    path: "raw/file.parquet".into(),
345                },
346                "parsing {url}",
347            );
348        }
349
350        for url in [
351            "uc:///Tables/main/default/orders",
352            "uc:///tables/main/default/orders",
353            "uc:///TABLES/main/default/orders",
354        ] {
355            let r = UCReference::parse(url).unwrap();
356            assert!(matches!(r, UCReference::Table { .. }), "parsing {url}");
357        }
358    }
359
360    #[test]
361    fn vol_dbfs_alias_kind_is_case_insensitive() {
362        for url in [
363            "vol+dbfs:/Volumes/main/default/landing/file.bin",
364            "vol+dbfs:/volumes/main/default/landing/file.bin",
365            "vol+dbfs:/VOLUMES/main/default/landing/file.bin",
366        ] {
367            let r = UCReference::parse(url).unwrap();
368            assert!(matches!(r, UCReference::Volume { .. }), "parsing {url}");
369        }
370    }
371
372    #[test]
373    fn percent_encoded_names_are_decoded() {
374        let r = UCReference::parse("uc:///Volumes/main/default/my%5Fvolume/file").unwrap();
375        let UCReference::Volume { volume, .. } = r else {
376            panic!()
377        };
378        assert_eq!(volume, "my_volume");
379    }
380}