1use std::collections::HashMap;
22use std::sync::{Arc, RwLock};
23
24use async_trait::async_trait;
25use bytes::Bytes;
26use futures::StreamExt;
27use futures::stream::BoxStream;
28use iceberg::io::{
29 FileMetadata, FileRead, FileWrite, InputFile, OutputFile, Storage, StorageConfig,
30 StorageFactory,
31};
32use iceberg::{Error, ErrorKind, Result};
33use serde::{Deserialize, Serialize};
34use url::Url;
35
36use crate::OpenDalStorage;
37#[cfg(feature = "opendal-s3")]
38use crate::s3::CustomAwsCredentialLoader;
39
40pub const SCHEME_MEMORY: &str = "memory";
42pub const SCHEME_FILE: &str = "file";
43pub const SCHEME_S3: &str = "s3";
44pub const SCHEME_S3A: &str = "s3a";
45pub const SCHEME_S3N: &str = "s3n";
46pub const SCHEME_GS: &str = "gs";
47pub const SCHEME_GCS: &str = "gcs";
48pub const SCHEME_OSS: &str = "oss";
49pub const SCHEME_ABFSS: &str = "abfss";
50pub const SCHEME_ABFS: &str = "abfs";
51pub const SCHEME_WASBS: &str = "wasbs";
52pub const SCHEME_WASB: &str = "wasb";
53pub const SCHEME_HF: &str = "hf";
54
55fn parse_scheme(scheme: &str) -> Result<&'static str> {
57 match scheme {
58 SCHEME_MEMORY => Ok("memory"),
59 SCHEME_FILE | "" => Ok("file"),
60 SCHEME_S3 | SCHEME_S3A | SCHEME_S3N => Ok("s3"),
61 SCHEME_GS | SCHEME_GCS => Ok("gcs"),
62 SCHEME_OSS => Ok("oss"),
63 SCHEME_ABFSS | SCHEME_ABFS | SCHEME_WASBS | SCHEME_WASB => Ok("azdls"),
64 SCHEME_HF => Ok("hf"),
65 s => Err(Error::new(
66 ErrorKind::FeatureUnsupported,
67 format!("Unsupported storage scheme: {s}"),
68 )),
69 }
70}
71
72fn extract_scheme(path: &str) -> Result<&'static str> {
74 let url = Url::parse(path).map_err(|e| {
75 Error::new(
76 ErrorKind::DataInvalid,
77 format!("Invalid path: {path}, failed to parse URL: {e}"),
78 )
79 })?;
80 parse_scheme(url.scheme())
81}
82
83fn build_storage_for_scheme(
85 scheme: &'static str,
86 props: &HashMap<String, String>,
87 #[cfg(feature = "opendal-s3")] customized_credential_load: &Option<CustomAwsCredentialLoader>,
88) -> Result<OpenDalStorage> {
89 match scheme {
90 #[cfg(feature = "opendal-s3")]
91 "s3" => {
92 let config = crate::s3::s3_config_parse(props.clone())?;
93 Ok(OpenDalStorage::S3 {
94 config: Arc::new(config),
95 customized_credential_load: customized_credential_load.clone(),
96 })
97 }
98 #[cfg(feature = "opendal-gcs")]
99 "gcs" => {
100 let config = crate::gcs::gcs_config_parse(props.clone())?;
101 Ok(OpenDalStorage::Gcs {
102 config: Arc::new(config),
103 })
104 }
105 #[cfg(feature = "opendal-oss")]
106 "oss" => {
107 let config = crate::oss::oss_config_parse(props.clone())?;
108 Ok(OpenDalStorage::Oss {
109 config: Arc::new(config),
110 })
111 }
112 #[cfg(feature = "opendal-azdls")]
113 "azdls" => {
114 let config = crate::azdls::azdls_config_parse(props.clone())?;
115 Ok(OpenDalStorage::Azdls {
116 config: Arc::new(config),
117 })
118 }
119 #[cfg(feature = "opendal-fs")]
120 "file" => Ok(OpenDalStorage::LocalFs),
121 #[cfg(feature = "opendal-memory")]
122 "memory" => Ok(OpenDalStorage::Memory(crate::memory::memory_config_build()?)),
123 #[cfg(feature = "opendal-hf")]
124 "hf" => {
125 let config = crate::hf::hf_config_parse(props.clone())?;
126 Ok(OpenDalStorage::Hf {
127 config: Arc::new(config),
128 })
129 }
130 unsupported => Err(Error::new(
131 ErrorKind::FeatureUnsupported,
132 format!("Unsupported storage scheme: {unsupported}"),
133 )),
134 }
135}
136
137#[derive(Clone, Debug, Serialize, Deserialize)]
162pub struct OpenDalResolvingStorageFactory {
163 #[cfg(feature = "opendal-s3")]
165 #[serde(
166 skip_deserializing,
167 skip_serializing_if = "Option::is_none",
168 serialize_with = "crate::serialize_custom_credential_loader"
169 )]
170 customized_credential_load: Option<CustomAwsCredentialLoader>,
171}
172
173impl Default for OpenDalResolvingStorageFactory {
174 fn default() -> Self {
175 Self::new()
176 }
177}
178
179impl OpenDalResolvingStorageFactory {
180 pub fn new() -> Self {
182 Self {
183 #[cfg(feature = "opendal-s3")]
184 customized_credential_load: None,
185 }
186 }
187
188 #[cfg(feature = "opendal-s3")]
190 pub fn with_s3_credential_loader(mut self, loader: CustomAwsCredentialLoader) -> Self {
191 self.customized_credential_load = Some(loader);
192 self
193 }
194}
195
196#[typetag::serde]
197impl StorageFactory for OpenDalResolvingStorageFactory {
198 fn build(&self, config: &StorageConfig) -> Result<Arc<dyn Storage>> {
199 Ok(Arc::new(OpenDalResolvingStorage {
200 props: config.props().clone(),
201 storages: RwLock::new(HashMap::new()),
202 #[cfg(feature = "opendal-s3")]
203 customized_credential_load: self.customized_credential_load.clone(),
204 }))
205 }
206}
207
208#[derive(Debug, Serialize, Deserialize)]
215pub struct OpenDalResolvingStorage {
216 props: HashMap<String, String>,
218 #[serde(skip, default)]
220 storages: RwLock<HashMap<&'static str, Arc<OpenDalStorage>>>,
221 #[cfg(feature = "opendal-s3")]
223 #[serde(skip)]
224 customized_credential_load: Option<CustomAwsCredentialLoader>,
225}
226
227impl OpenDalResolvingStorage {
228 fn resolve(&self, path: &str) -> Result<Arc<OpenDalStorage>> {
231 let scheme = extract_scheme(path)?;
232
233 {
235 let cache = self
236 .storages
237 .read()
238 .map_err(|_| Error::new(ErrorKind::Unexpected, "Storage cache lock poisoned"))?;
239 if let Some(storage) = cache.get(&scheme) {
240 return Ok(storage.clone());
241 }
242 }
243
244 let mut cache = self
246 .storages
247 .write()
248 .map_err(|_| Error::new(ErrorKind::Unexpected, "Storage cache lock poisoned"))?;
249
250 if let Some(storage) = cache.get(&scheme) {
252 return Ok(storage.clone());
253 }
254
255 let storage = build_storage_for_scheme(
256 scheme,
257 &self.props,
258 #[cfg(feature = "opendal-s3")]
259 &self.customized_credential_load,
260 )?;
261 let storage = Arc::new(storage);
262 cache.insert(scheme, storage.clone());
263 Ok(storage)
264 }
265}
266
267#[async_trait]
268#[typetag::serde]
269impl Storage for OpenDalResolvingStorage {
270 async fn exists(&self, path: &str) -> Result<bool> {
271 self.resolve(path)?.exists(path).await
272 }
273
274 async fn metadata(&self, path: &str) -> Result<FileMetadata> {
275 self.resolve(path)?.metadata(path).await
276 }
277
278 async fn read(&self, path: &str) -> Result<Bytes> {
279 self.resolve(path)?.read(path).await
280 }
281
282 async fn reader(&self, path: &str) -> Result<Box<dyn FileRead>> {
283 self.resolve(path)?.reader(path).await
284 }
285
286 async fn write(&self, path: &str, bs: Bytes) -> Result<()> {
287 self.resolve(path)?.write(path, bs).await
288 }
289
290 async fn writer(&self, path: &str) -> Result<Box<dyn FileWrite>> {
291 self.resolve(path)?.writer(path).await
292 }
293
294 async fn delete(&self, path: &str) -> Result<()> {
295 self.resolve(path)?.delete(path).await
296 }
297
298 async fn delete_prefix(&self, path: &str) -> Result<()> {
299 self.resolve(path)?.delete_prefix(path).await
300 }
301
302 async fn delete_stream(&self, mut paths: BoxStream<'static, String>) -> Result<()> {
303 let mut grouped: HashMap<&'static str, Vec<String>> = HashMap::new();
306 while let Some(path) = paths.next().await {
307 let scheme = extract_scheme(&path)?;
308 grouped.entry(scheme).or_default().push(path);
309 }
310
311 for (_, paths) in grouped {
312 let storage = self.resolve(&paths[0])?;
313 storage
314 .delete_stream(futures::stream::iter(paths).boxed())
315 .await?;
316 }
317 Ok(())
318 }
319
320 fn new_input(&self, path: &str) -> Result<InputFile> {
321 Ok(InputFile::new(
322 Arc::new(self.resolve(path)?.as_ref().clone()),
323 path.to_string(),
324 ))
325 }
326
327 fn new_output(&self, path: &str) -> Result<OutputFile> {
328 Ok(OutputFile::new(
329 Arc::new(self.resolve(path)?.as_ref().clone()),
330 path.to_string(),
331 ))
332 }
333}
334
335#[cfg(test)]
336mod tests {
337 use super::*;
338
339 #[cfg(feature = "opendal-s3")]
340 #[derive(Debug)]
341 struct EmptyCredentialLoader;
342
343 #[cfg(feature = "opendal-s3")]
344 impl crate::s3::ProvideCredential for EmptyCredentialLoader {
345 type Credential = crate::s3::AwsCredential;
346
347 async fn provide_credential(
348 &self,
349 _ctx: &reqsign_core::Context,
350 ) -> reqsign_core::Result<Option<Self::Credential>> {
351 Ok(None)
352 }
353 }
354
355 #[cfg(feature = "opendal-s3")]
356 #[test]
357 fn test_custom_credential_loader_serialization_fails() {
358 let factory = OpenDalResolvingStorageFactory::new()
359 .with_s3_credential_loader(CustomAwsCredentialLoader::new(EmptyCredentialLoader));
360 let file_io = iceberg::io::FileIOBuilder::new(Arc::new(factory)).build();
361
362 let err = file_io.serialize_all().unwrap_err();
363 assert!(
364 err.to_string()
365 .contains("custom AWS credential loaders cannot be serialized")
366 );
367 }
368
369 fn empty_resolving_storage() -> OpenDalResolvingStorage {
372 OpenDalResolvingStorage {
373 props: HashMap::new(),
374 storages: RwLock::new(HashMap::new()),
375 #[cfg(feature = "opendal-s3")]
376 customized_credential_load: None,
377 }
378 }
379
380 #[cfg(feature = "opendal-s3")]
381 #[test]
382 fn test_resolve_s3_aliases_share_instance() {
383 let storage = empty_resolving_storage();
384
385 let a = storage.resolve("s3://bucket/key").unwrap();
389 let b = storage.resolve("s3a://bucket/key").unwrap();
390 let c = storage.resolve("s3n://bucket/key").unwrap();
391
392 assert!(Arc::ptr_eq(&a, &b), "s3 and s3a should share one instance");
393 assert!(Arc::ptr_eq(&a, &c), "s3 and s3n should share one instance");
394 }
395
396 #[cfg(feature = "opendal-azdls")]
397 #[test]
398 fn test_resolve_azdls_aliases_share_instance() {
399 let storage = empty_resolving_storage();
400
401 let path_for = |scheme: &str| {
402 format!("{scheme}://myfs@myaccount.dfs.core.windows.net/path/to/file.parquet")
403 };
404
405 let abfss = storage.resolve(&path_for("abfss")).unwrap();
407 let abfs = storage.resolve(&path_for("abfs")).unwrap();
408
409 assert!(
410 Arc::ptr_eq(&abfss, &abfs),
411 "abfss and abfs should share one instance"
412 );
413 }
414}