Skip to main content

azure_storage_blob/clients/
blob_container_client.rs

1// Copyright (c) Microsoft Corporation. All rights reserved.
2// Licensed under the MIT License.
3
4pub use crate::generated::clients::{BlobContainerClient, BlobContainerClientOptions};
5
6use crate::{models::StorageErrorCode, BlobClient};
7use azure_core::{
8    credentials::TokenCredential,
9    error::ErrorKind,
10    http::{
11        policies::{auth::BearerTokenAuthorizationPolicy, Policy},
12        Pipeline, StatusCode, Url,
13    },
14    tracing, Result,
15};
16use std::sync::Arc;
17
18impl BlobContainerClient {
19    /// Creates a new BlobContainerClient from a container URL.
20    ///
21    /// # Arguments
22    ///
23    /// * `container_url` - The full URL of the container, for example `https://myaccount.blob.core.windows.net/mycontainer`.
24    ///   The caller is responsible for percent-encoding the URL correctly; it will be used as-is.
25    /// * `credential` - An optional implementation of [`TokenCredential`] that can provide an Entra ID token to use when authenticating.
26    /// * `options` - Optional configuration for the client.
27    #[tracing::new("Storage.Blob.Container")]
28    pub fn new(
29        container_url: Url,
30        credential: Option<Arc<dyn TokenCredential>>,
31        options: Option<BlobContainerClientOptions>,
32    ) -> Result<Self> {
33        // Storage endpoints must be base URLs.
34        if container_url.cannot_be_a_base() {
35            return Err(azure_core::Error::with_message(
36                azure_core::error::ErrorKind::Other,
37                format!("{container_url} is not a valid base URL"),
38            ));
39        }
40
41        let mut options = options.unwrap_or_default();
42        super::apply_client_defaults(&mut options.client_options);
43
44        let mut per_retry_policies: Vec<Arc<dyn Policy>> = Vec::default();
45        if let Some(token_credential) = credential {
46            if !container_url.scheme().starts_with("https") {
47                return Err(azure_core::Error::with_message(
48                    azure_core::error::ErrorKind::Other,
49                    format!("{container_url} must use https"),
50                ));
51            }
52            per_retry_policies.push(Arc::new(BearerTokenAuthorizationPolicy::new(
53                token_credential,
54                vec!["https://storage.azure.com/.default"],
55            )));
56        }
57
58        let pipeline = Pipeline::new(
59            option_env!("CARGO_PKG_NAME"),
60            option_env!("CARGO_PKG_VERSION"),
61            options.client_options.clone(),
62            Vec::default(),
63            per_retry_policies,
64            None,
65        );
66
67        Ok(Self {
68            endpoint: container_url,
69            version: options.version,
70            pipeline,
71        })
72    }
73
74    /// Returns a new instance of BlobClient.
75    ///
76    /// # Arguments
77    ///
78    /// * `blob_name` - The name of the blob.
79    pub fn blob_client(&self, blob_name: &str) -> BlobClient {
80        let mut blob_url = self.url().clone();
81        blob_url
82            .path_segments_mut()
83            // This should not fail as container URL has already been validated on client construction.
84            .expect("Invalid endpoint URL: Cannot append blob_name to the blob endpoint.")
85            .extend([blob_name]);
86
87        BlobClient {
88            endpoint: blob_url,
89            pipeline: self.pipeline.clone(),
90            version: self.version.clone(),
91            tracer: self.tracer.clone(),
92        }
93    }
94
95    /// Gets the URL of the container.
96    pub fn url(&self) -> &Url {
97        &self.endpoint
98    }
99
100    /// Checks if the container exists.
101    ///
102    /// Returns `true` if the container exists, `false` if the container does not exist, and propagates all other errors.
103    pub async fn exists(&self) -> Result<bool> {
104        match self.get_properties(None).await {
105            Ok(_) => Ok(true),
106            Err(e) if e.http_status() == Some(StatusCode::NotFound) => match e.kind() {
107                ErrorKind::HttpResponse {
108                    error_code: Some(error_code),
109                    ..
110                } if error_code == StorageErrorCode::ContainerNotFound.as_ref() => Ok(false),
111                // Propagate all other error types.
112                _ => Err(e),
113            },
114            Err(e) => Err(e),
115        }
116    }
117}
118
119#[cfg(test)]
120mod tests {
121    use super::*;
122    use azure_core::{
123        http::{
124            headers::Headers, pager::PagerContinuation, AsyncRawResponse, ClientOptions,
125            StatusCode, Transport,
126        },
127        Bytes,
128    };
129    use azure_core_test::http::MockHttpClient;
130    use futures::{FutureExt as _, TryStreamExt as _};
131    use std::sync::Arc;
132
133    const LIST_BLOBS_PAGE: &[u8] = br#"<?xml version="1.0" encoding="utf-8"?>
134<EnumerationResults ServiceEndpoint="https://example.blob.core.windows.net/" ContainerName="container">
135  <Blobs>
136    <Blob>
137      <Name>blob1</Name>
138      <Properties>
139        <BlobType>BlockBlob</BlobType>
140      </Properties>
141    </Blob>
142  </Blobs>
143  <NextMarker>page-2</NextMarker>
144</EnumerationResults>"#;
145
146    #[test]
147    fn from_url_rejects_cannot_be_a_base_url() {
148        let url = Url::parse("data:text/plain,hello").unwrap();
149        assert!(BlobContainerClient::new(url, None, None).is_err());
150    }
151
152    #[test]
153    fn from_url_accepts_http_without_credential() {
154        let url = Url::parse("http://127.0.0.1:10000/devstoreaccount1/container").unwrap();
155        let container = BlobContainerClient::new(url, None, None).unwrap();
156        assert_eq!(
157            container.blob_client("blob").url().path(),
158            "/devstoreaccount1/container/blob"
159        );
160    }
161
162    #[test]
163    fn from_url_accepts_https_custom_hostname() {
164        // CDN / Front Door / private endpoint hostnames are still https URLs.
165        let url = Url::parse("https://cdn.contoso.com/container").unwrap();
166        assert!(BlobContainerClient::new(url, None, None).is_ok());
167    }
168
169    #[tokio::test]
170    async fn list_blobs_page_keeps_body_for_into_model() -> Result<()> {
171        let mock_client = Arc::new(MockHttpClient::new(|req| {
172            assert_eq!(req.url().path(), "/container");
173            assert!(req
174                .url()
175                .query()
176                .is_some_and(|query| query.contains("comp=list")));
177            async move {
178                Ok(AsyncRawResponse::from_bytes(
179                    StatusCode::Ok,
180                    Headers::new(),
181                    Bytes::from_static(LIST_BLOBS_PAGE),
182                ))
183            }
184            .boxed()
185        }));
186        let client = BlobContainerClient::new(
187            Url::parse("https://example.blob.core.windows.net/container").unwrap(),
188            None,
189            Some(BlobContainerClientOptions {
190                client_options: ClientOptions {
191                    transport: Some(Transport::new(mock_client)),
192                    ..Default::default()
193                },
194                ..Default::default()
195            }),
196        )?;
197
198        let mut pages = client.list_blobs(None)?.into_pages();
199        let page = pages.try_next().await?.expect("expected a page");
200
201        assert!(matches!(
202            pages.continuation(),
203            Some(PagerContinuation::Token(token)) if token == "page-2"
204        ));
205
206        let page = page.into_model()?;
207        assert_eq!(page.next_marker.as_deref(), Some("page-2"));
208        assert_eq!(page.blob_items.len(), 1);
209        assert_eq!(page.blob_items[0].name.as_deref(), Some("blob1"));
210
211        Ok(())
212    }
213}