use std::sync::Arc;
use futures::StreamExt;
use object_store::path::Path;
use object_store::{
CopyOptions, GetOptions, GetResult, ListResult, MultipartUpload, ObjectMeta, ObjectStore,
PutMultipartOptions, PutOptions, PutPayload, PutResult, Result as OsResult,
};
use url::Url;
#[cfg(target_arch = "wasm32")]
use crate::error::Error;
#[cfg(target_arch = "wasm32")]
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub(crate) enum StorageAccess {
#[default]
Direct,
Proxy { base: Url },
}
#[cfg(target_arch = "wasm32")]
pub(crate) async fn discover_storage_access(uc_uri: &Url, token: Option<&str>) -> StorageAccess {
match try_discover(uc_uri, token).await {
Ok(access) => access,
Err(_) => StorageAccess::Direct,
}
}
#[cfg(target_arch = "wasm32")]
async fn try_discover(uc_uri: &Url, token: Option<&str>) -> Result<StorageAccess, Error> {
let caps_url = uc_uri
.join("/capabilities")
.map_err(|e| Error::invalid_url(format!("capabilities url: {e}")))?;
let mut req = reqwest::Client::new().get(caps_url.clone());
if let Some(token) = token {
req = req.bearer_auth(token);
}
let resp = req
.send()
.await
.map_err(|e| Error::invalid_config(format!("capabilities request failed: {e}")))?;
if !resp.status().is_success() {
return Err(Error::invalid_config(format!(
"capabilities returned {}",
resp.status()
)));
}
let body: CapabilitiesBody = resp
.json()
.await
.map_err(|e| Error::invalid_config(format!("capabilities body: {e}")))?;
Ok(body.into_storage_access(uc_uri))
}
#[cfg(target_arch = "wasm32")]
#[derive(serde::Deserialize)]
struct CapabilitiesBody {
#[serde(rename = "storageAccess", default)]
storage_access: String,
#[serde(rename = "storageProxy", default)]
storage_proxy: Option<StorageProxyBody>,
}
#[cfg(target_arch = "wasm32")]
#[derive(serde::Deserialize)]
struct StorageProxyBody {
#[serde(rename = "basePath", default)]
base_path: Option<String>,
}
#[cfg(target_arch = "wasm32")]
impl CapabilitiesBody {
fn into_storage_access(self, uc_uri: &Url) -> StorageAccess {
if self.storage_access != "proxy" {
return StorageAccess::Direct;
}
let base_path = self
.storage_proxy
.and_then(|p| p.base_path)
.unwrap_or_else(|| "/storage-proxy".to_string());
match uc_uri.join(&base_path) {
Ok(base) => StorageAccess::Proxy {
base: trim_trailing_slash(base),
},
Err(_) => StorageAccess::Direct,
}
}
}
#[cfg(target_arch = "wasm32")]
fn trim_trailing_slash(mut url: Url) -> Url {
let trimmed = url.path().trim_end_matches('/').to_string();
url.set_path(&trimmed);
url
}
#[cfg_attr(not(target_arch = "wasm32"), allow(dead_code))]
pub(crate) fn path_securable(url: &Url) -> String {
format!("path:{}", encode_segment(url.as_str()))
}
#[cfg_attr(not(target_arch = "wasm32"), allow(dead_code))]
pub(crate) fn encode_segment(s: &str) -> String {
let mut out = String::with_capacity(s.len());
for &b in s.as_bytes() {
match b {
b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'.' | b'_' | b'~' => {
out.push(b as char)
}
_ => out.push_str(&format!("%{b:02X}")),
}
}
out
}
#[cfg_attr(not(target_arch = "wasm32"), allow(dead_code))]
#[derive(Debug)]
pub(crate) struct StripPrefixStore {
prefix: Path,
inner: Arc<dyn ObjectStore>,
}
#[cfg_attr(not(target_arch = "wasm32"), allow(dead_code))]
impl StripPrefixStore {
pub(crate) fn new(prefix: Path, inner: Arc<dyn ObjectStore>) -> Self {
Self { prefix, inner }
}
fn strip(&self, location: &Path) -> Path {
strip_path(&self.prefix, location)
}
}
impl std::fmt::Display for StripPrefixStore {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "StripPrefixStore({}, {})", self.prefix, self.inner)
}
}
#[async_trait::async_trait]
impl ObjectStore for StripPrefixStore {
async fn put_opts(
&self,
location: &Path,
payload: PutPayload,
opts: PutOptions,
) -> OsResult<PutResult> {
self.inner
.put_opts(&self.strip(location), payload, opts)
.await
}
async fn put_multipart_opts(
&self,
location: &Path,
opts: PutMultipartOptions,
) -> OsResult<Box<dyn MultipartUpload>> {
self.inner
.put_multipart_opts(&self.strip(location), opts)
.await
}
async fn get_opts(&self, location: &Path, options: GetOptions) -> OsResult<GetResult> {
self.inner.get_opts(&self.strip(location), options).await
}
fn delete_stream(
&self,
locations: futures::stream::BoxStream<'static, OsResult<Path>>,
) -> futures::stream::BoxStream<'static, OsResult<Path>> {
let prefix = self.prefix.clone();
let stripped = locations
.map(move |loc| loc.map(|p| strip_path(&prefix, &p)))
.boxed();
let prefix_out = self.prefix.clone();
self.inner
.delete_stream(stripped)
.map(move |res| res.map(|p| prepend_path(&prefix_out, &p)))
.boxed()
}
fn list(
&self,
prefix: Option<&Path>,
) -> futures::stream::BoxStream<'static, OsResult<ObjectMeta>> {
let mapped = prefix.map(|p| self.strip(p));
self.inner.list(mapped.as_ref())
}
async fn list_with_delimiter(&self, prefix: Option<&Path>) -> OsResult<ListResult> {
let mapped = prefix.map(|p| self.strip(p));
self.inner.list_with_delimiter(mapped.as_ref()).await
}
async fn copy_opts(&self, from: &Path, to: &Path, options: CopyOptions) -> OsResult<()> {
self.inner
.copy_opts(&self.strip(from), &self.strip(to), options)
.await
}
}
#[cfg_attr(not(target_arch = "wasm32"), allow(dead_code))]
fn strip_path(prefix: &Path, location: &Path) -> Path {
if prefix.parts().count() == 0 {
return location.clone();
}
match location.prefix_match(prefix) {
Some(rest) => Path::from_iter(rest),
None => location.clone(),
}
}
#[cfg_attr(not(target_arch = "wasm32"), allow(dead_code))]
fn prepend_path(prefix: &Path, location: &Path) -> Path {
if prefix.parts().count() == 0 {
return location.clone();
}
Path::from_iter(prefix.parts().chain(location.parts()))
}
#[cfg(target_arch = "wasm32")]
pub(crate) fn build_proxy_store(
base: &Url,
securable_seg: &str,
prefix: Path,
token: Option<&str>,
) -> OsResult<Arc<dyn ObjectStore>> {
use object_store::ClientOptions;
use reqwest::header::{AUTHORIZATION, HeaderMap, HeaderValue};
let store_url = format!("{}/{}/", base.as_str(), securable_seg);
let mut client_options = ClientOptions::default();
if let Some(token) = token {
let mut headers = HeaderMap::new();
if let Ok(value) = HeaderValue::from_str(&format!("Bearer {token}")) {
headers.insert(AUTHORIZATION, value);
}
client_options = client_options.with_default_headers(headers);
}
let http = object_store::http::HttpBuilder::new()
.with_url(store_url)
.with_client_options(client_options)
.with_retry(object_store::RetryConfig {
max_retries: 0,
..Default::default()
})
.build()?;
Ok(Arc::new(StripPrefixStore::new(prefix, Arc::new(http))))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn path_securable_encodes_url_as_one_segment() {
let url = Url::parse("s3://bucket/prefix/").unwrap();
assert_eq!(path_securable(&url), "path:s3%3A%2F%2Fbucket%2Fprefix%2F");
}
#[test]
fn encode_segment_leaves_unreserved_and_escapes_reserved() {
assert_eq!(encode_segment("aZ0-._~"), "aZ0-._~");
assert_eq!(encode_segment("a/b:c"), "a%2Fb%3Ac");
}
#[test]
fn strip_removes_known_prefix() {
let inner: Arc<dyn ObjectStore> = Arc::new(object_store::memory::InMemory::new());
let store = StripPrefixStore::new(Path::from("container/tbl"), inner);
assert_eq!(
store.strip(&Path::from("container/tbl/_delta_log/00.json")),
Path::from("_delta_log/00.json")
);
}
#[test]
fn strip_empty_prefix_is_passthrough() {
let inner: Arc<dyn ObjectStore> = Arc::new(object_store::memory::InMemory::new());
let store = StripPrefixStore::new(Path::default(), inner);
assert_eq!(
store.strip(&Path::from("data/part-0.parquet")),
Path::from("data/part-0.parquet")
);
}
#[test]
fn strip_leaves_out_of_prefix_path_unchanged() {
let inner: Arc<dyn ObjectStore> = Arc::new(object_store::memory::InMemory::new());
let store = StripPrefixStore::new(Path::from("container/tbl"), inner);
assert_eq!(
store.strip(&Path::from("other/thing")),
Path::from("other/thing")
);
}
}