Skip to main content

nv_redfish_bmc_http/
lib.rs

1// SPDX-FileCopyrightText: Copyright (c) 2025 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
2// SPDX-License-Identifier: Apache-2.0
3//
4// Licensed under the Apache License, Version 2.0 (the "License");
5// you may not use this file except in compliance with the License.
6// You may obtain a copy of the License at
7//
8// http://www.apache.org/licenses/LICENSE-2.0
9//
10// Unless required by applicable law or agreed to in writing, software
11// distributed under the License is distributed on an "AS IS" BASIS,
12// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13// See the License for the specific language governing permissions and
14// limitations under the License.
15
16pub mod cache;
17pub mod credentials;
18
19#[cfg(feature = "reqwest")]
20pub mod reqwest;
21
22use crate::cache::TypeErasedCarCache;
23use http::HeaderMap;
24use nv_redfish_core::query::ExpandQuery;
25use nv_redfish_core::Action;
26use nv_redfish_core::Bmc;
27use nv_redfish_core::BoxTryStream;
28use nv_redfish_core::EntityTypeRef;
29use nv_redfish_core::Expandable;
30use nv_redfish_core::FilterQuery;
31use nv_redfish_core::ModificationResponse;
32use nv_redfish_core::ODataETag;
33use nv_redfish_core::ODataId;
34use serde::{de::DeserializeOwned, Deserialize, Serialize};
35use std::{
36    collections::HashMap,
37    error::Error as StdError,
38    future::Future,
39    sync::{Arc, RwLock},
40};
41use url::Url;
42
43#[doc(inline)]
44pub use credentials::BmcCredentials;
45
46pub trait HttpClient: Send + Sync {
47    type Error: Send + StdError;
48
49    /// Perform an HTTP GET request with optional conditional headers.
50    fn get<T>(
51        &self,
52        url: Url,
53        credentials: &BmcCredentials,
54        etag: Option<ODataETag>,
55        custom_headers: &HeaderMap,
56    ) -> impl Future<Output = Result<T, Self::Error>> + Send
57    where
58        T: DeserializeOwned + Send + Sync;
59
60    /// Perform an HTTP POST request.
61    fn post<B, T>(
62        &self,
63        url: Url,
64        body: &B,
65        credentials: &BmcCredentials,
66        custom_headers: &HeaderMap,
67    ) -> impl Future<Output = Result<ModificationResponse<T>, Self::Error>> + Send
68    where
69        B: Serialize + Send + Sync,
70        T: DeserializeOwned + Send + Sync;
71
72    /// Perform an HTTP PATCH request.
73    fn patch<B, T>(
74        &self,
75        url: Url,
76        etag: ODataETag,
77        body: &B,
78        credentials: &BmcCredentials,
79        custom_headers: &HeaderMap,
80    ) -> impl Future<Output = Result<ModificationResponse<T>, Self::Error>> + Send
81    where
82        B: Serialize + Send + Sync,
83        T: DeserializeOwned + Send + Sync;
84
85    /// Perform an HTTP DELETE request.
86    fn delete<T>(
87        &self,
88        url: Url,
89        credentials: &BmcCredentials,
90        custom_headers: &HeaderMap,
91    ) -> impl Future<Output = Result<ModificationResponse<T>, Self::Error>> + Send
92    where
93        T: DeserializeOwned + Send + Sync;
94
95    /// Open an SSE stream
96    fn sse<T: Sized + for<'a> Deserialize<'a> + Send + 'static>(
97        &self,
98        url: Url,
99        credentials: &BmcCredentials,
100        custom_headers: &HeaderMap,
101    ) -> impl Future<Output = Result<BoxTryStream<T, Self::Error>, Self::Error>> + Send;
102}
103
104/// HTTP-based BMC implementation that wraps an [`HttpClient`].
105///
106/// This struct combines an HTTP client with BMC endpoint information and credentials
107/// to provide a complete Redfish client implementation. It implements the [`Bmc`] trait
108/// to provide standardized access to Redfish services.
109///
110/// # Type Parameters
111///
112/// * `C` - The HTTP client implementation to use
113///
114pub struct HttpBmc<C: HttpClient> {
115    client: C,
116    redfish_endpoint: RedfishEndpoint,
117    credentials: RwLock<Arc<BmcCredentials>>,
118    cache: RwLock<TypeErasedCarCache<ODataId>>,
119    etags: RwLock<HashMap<ODataId, ODataETag>>,
120    custom_headers: HeaderMap,
121}
122
123impl<C: HttpClient> HttpBmc<C>
124where
125    C::Error: CacheableError,
126{
127    /// Create a new HTTP-based BMC client with ETag-based caching.
128    ///
129    /// # Arguments
130    ///
131    /// * `client` - The HTTP client implementation to use for requests
132    /// * `redfish_endpoint` - The base URL of the Redfish service (e.g., `https://192.168.1.100`)
133    /// * `credentials` - Authentication credentials for the BMC
134    ///
135    /// # Examples
136    ///
137    /// ```rust,no_run
138    /// use nv_redfish_bmc_http::HttpBmc;
139    /// use nv_redfish_bmc_http::CacheSettings;
140    /// use nv_redfish_bmc_http::BmcCredentials;
141    /// use nv_redfish_bmc_http::reqwest::Client;
142    /// use url::Url;
143    ///
144    /// # fn example() -> Result<(), Box<dyn std::error::Error>> {
145    /// let credentials = BmcCredentials::username_password("admin".to_string(), Some("password".to_string()));
146    /// let http_client = Client::new()?;
147    /// let endpoint = Url::parse("https://192.168.1.100")?;
148    ///
149    /// let bmc = HttpBmc::new(http_client, endpoint, credentials, CacheSettings::default());
150    /// # Ok(())
151    /// # }
152    /// ```
153    pub fn new(
154        client: C,
155        redfish_endpoint: Url,
156        credentials: BmcCredentials,
157        cache_settings: CacheSettings,
158    ) -> Self {
159        Self::with_custom_headers(
160            client,
161            redfish_endpoint,
162            credentials,
163            cache_settings,
164            HeaderMap::new(),
165        )
166    }
167
168    /// Create a new HTTP-based BMC client with custom headers and ETag-based caching.
169    ///
170    /// This is an alternative constructor that allows specifying custom HTTP headers
171    /// that will be included in all requests. Use this when you need vendor-specific
172    /// headers, custom authentication tokens, or other HTTP headers required by the
173    /// Redfish service at construction time.
174    ///
175    /// For most use cases, prefer [`HttpBmc::new`] which creates a client without
176    /// custom headers.
177    ///
178    /// # Arguments
179    ///
180    /// * `client` - The HTTP client implementation to use for requests
181    /// * `redfish_endpoint` - The base URL of the Redfish service (e.g., `https://192.168.1.100`)
182    /// * `credentials` - Authentication credentials for the BMC
183    /// * `cache_settings` - Cache configuration for response caching
184    /// * `custom_headers` - Custom HTTP headers to include in all requests
185    ///
186    /// # Examples
187    ///
188    /// ```rust,no_run
189    /// use nv_redfish_bmc_http::HttpBmc;
190    /// use nv_redfish_bmc_http::CacheSettings;
191    /// use nv_redfish_bmc_http::BmcCredentials;
192    /// use nv_redfish_bmc_http::reqwest::Client;
193    /// use url::Url;
194    /// use http::HeaderMap;
195    ///
196    /// # fn example() -> Result<(), Box<dyn std::error::Error>> {
197    /// let credentials = BmcCredentials::username_password("admin".to_string(), Some("password".to_string()));
198    /// let http_client = Client::new()?;
199    /// let endpoint = Url::parse("https://192.168.1.100")?;
200    ///
201    /// // Create custom headers
202    /// let mut headers = HeaderMap::new();
203    /// headers.insert("X-Auth-Token", "custom-token-value".parse()?);
204    /// headers.insert("X-Vendor-Header", "vendor-specific-value".parse()?);
205    ///
206    /// // Create BMC client with custom headers
207    /// let bmc = HttpBmc::with_custom_headers(
208    ///     http_client,
209    ///     endpoint,
210    ///     credentials,
211    ///     CacheSettings::default(),
212    ///     headers,
213    /// );
214    ///
215    /// // All requests will include the custom headers
216    /// # Ok(())
217    /// # }
218    /// ```
219    pub fn with_custom_headers(
220        client: C,
221        redfish_endpoint: Url,
222        credentials: BmcCredentials,
223        cache_settings: CacheSettings,
224        custom_headers: HeaderMap,
225    ) -> Self {
226        Self {
227            client,
228            redfish_endpoint: RedfishEndpoint::from(redfish_endpoint),
229            credentials: RwLock::new(Arc::new(credentials)),
230            cache: RwLock::new(TypeErasedCarCache::new(cache_settings.capacity)),
231            etags: RwLock::new(HashMap::new()),
232            custom_headers,
233        }
234    }
235
236    /// Replace the credentials used for subsequent requests.
237    ///
238    /// Existing cache and ETag state is preserved.
239    ///
240    /// # Errors
241    ///
242    /// Returns an error if the credentials lock is poisoned.
243    pub fn set_credentials(&self, credentials: BmcCredentials) -> Result<(), String> {
244        let mut current = self.credentials.write().expect("poisoned");
245        *current = Arc::new(credentials);
246        Ok(())
247    }
248}
249
250/// A tagged type representing a Redfish endpoint URL.
251///
252/// Provides convenient conversion methods to build endpoint URLs from `ODataId` paths.
253#[derive(Debug, Clone)]
254pub struct RedfishEndpoint {
255    base_url: Url,
256}
257
258impl RedfishEndpoint {
259    /// Create a new `RedfishEndpoint` from a base URL
260    #[must_use]
261    pub const fn new(base_url: Url) -> Self {
262        Self { base_url }
263    }
264
265    /// Convert a path to a full Redfish endpoint URL
266    #[must_use]
267    pub fn with_path(&self, path: &str) -> Url {
268        let mut url = self.base_url.clone();
269        url.set_path(path);
270        url
271    }
272
273    /// Convert a path to a full Redfish endpoint URL with query parameters
274    #[must_use]
275    pub fn with_path_and_query(&self, path: &str, query: &str) -> Url {
276        let mut url = self.with_path(path);
277        url.set_query(Some(query));
278        url
279    }
280}
281
282/// `CacheSettings` for internal BMC cache with etags
283pub struct CacheSettings {
284    capacity: usize,
285}
286
287impl Default for CacheSettings {
288    fn default() -> Self {
289        Self { capacity: 100 }
290    }
291}
292
293impl CacheSettings {
294    pub fn with_capacity(capacity: usize) -> Self {
295        Self { capacity }
296    }
297}
298
299impl From<Url> for RedfishEndpoint {
300    fn from(url: Url) -> Self {
301        Self::new(url)
302    }
303}
304
305impl From<&RedfishEndpoint> for Url {
306    fn from(endpoint: &RedfishEndpoint) -> Self {
307        endpoint.base_url.clone()
308    }
309}
310
311/// Trait for errors that can indicate whether they represent a cached response
312/// and provide a way to create cache-related errors.
313pub trait CacheableError {
314    /// Returns true if this error indicates the resource should be served from cache.
315    /// Typically true for HTTP 304 Not Modified responses.
316    fn is_cached(&self) -> bool;
317
318    /// Create an error for when cached data is requested but not available.
319    fn cache_miss() -> Self;
320
321    /// Cache error
322    fn cache_error(reason: String) -> Self;
323}
324
325impl<C: HttpClient> HttpBmc<C>
326where
327    C::Error: CacheableError + StdError + Send + Sync,
328{
329    fn read_credentials(&self) -> Arc<BmcCredentials> {
330        self.credentials
331            .read()
332            .map(|credentials| Arc::clone(&credentials))
333            .expect("lock poisoned")
334    }
335
336    /// Perform a GET request with `ETag` caching support
337    ///
338    /// This handles:
339    /// - Retrieving cached `ETag` before request
340    /// - Sending conditional GET with If-None-Match
341    /// - Handling 304 Not Modified responses from cache
342    /// - Updating cache and `ETag` storage on success
343    #[allow(clippy::significant_drop_tightening)]
344    async fn get_with_cache<
345        T: EntityTypeRef + Sized + for<'de> Deserialize<'de> + 'static + Send + Sync,
346    >(
347        &self,
348        endpoint_url: Url,
349        id: &ODataId,
350    ) -> Result<Arc<T>, C::Error> {
351        // Retrieve cached etag
352        let etag: Option<ODataETag> = {
353            let etags = self
354                .etags
355                .read()
356                .map_err(|e| C::Error::cache_error(e.to_string()))?;
357            etags.get(id).cloned()
358        };
359        let credentials = self.read_credentials();
360
361        // Perform GET request
362        match self
363            .client
364            .get::<T>(
365                endpoint_url,
366                credentials.as_ref(),
367                etag,
368                &self.custom_headers,
369            )
370            .await
371        {
372            Ok(response) => {
373                let entity = Arc::new(response);
374
375                // Update cache if entity has etag
376                if let Some(etag) = entity.etag() {
377                    let mut cache = self
378                        .cache
379                        .write()
380                        .map_err(|e| C::Error::cache_error(e.to_string()))?;
381
382                    let mut etags = self
383                        .etags
384                        .write()
385                        .map_err(|e| C::Error::cache_error(e.to_string()))?;
386
387                    if let Some(evicted_id) = cache.put_typed(id.clone(), Arc::clone(&entity)) {
388                        etags.remove(&evicted_id);
389                    }
390                    etags.insert(id.clone(), etag.clone());
391                }
392                Ok(entity)
393            }
394            Err(e) => {
395                // Handle 304 Not Modified - return from cache
396                if e.is_cached() {
397                    let mut cache = self
398                        .cache
399                        .write()
400                        .map_err(|e| C::Error::cache_error(e.to_string()))?;
401                    cache
402                        .get_typed::<Arc<T>>(id)
403                        .cloned()
404                        .ok_or_else(C::Error::cache_miss)
405                } else {
406                    Err(e)
407                }
408            }
409        }
410    }
411}
412
413impl<C: HttpClient> Bmc for HttpBmc<C>
414where
415    C::Error: CacheableError + StdError + Send + Sync,
416{
417    type Error = C::Error;
418
419    async fn get<T: EntityTypeRef + Sized + for<'de> Deserialize<'de> + 'static + Send + Sync>(
420        &self,
421        id: &ODataId,
422    ) -> Result<Arc<T>, Self::Error> {
423        let endpoint_url = self.redfish_endpoint.with_path(&id.to_string());
424        self.get_with_cache(endpoint_url, id).await
425    }
426
427    async fn expand<T: Expandable + Send + Sync + 'static>(
428        &self,
429        id: &ODataId,
430        query: ExpandQuery,
431    ) -> Result<Arc<T>, Self::Error> {
432        let endpoint_url = self
433            .redfish_endpoint
434            .with_path_and_query(&id.to_string(), &query.to_query_string());
435
436        self.get_with_cache(endpoint_url, id).await
437    }
438
439    async fn create<V: Sync + Send + Serialize, R: Sync + Send + for<'de> Deserialize<'de>>(
440        &self,
441        id: &ODataId,
442        v: &V,
443    ) -> Result<ModificationResponse<R>, Self::Error> {
444        let endpoint_url = self.redfish_endpoint.with_path(&id.to_string());
445        let credentials = self.read_credentials();
446        self.client
447            .post(endpoint_url, v, credentials.as_ref(), &self.custom_headers)
448            .await
449    }
450
451    async fn update<V: Sync + Send + Serialize, R: Sync + Send + for<'de> Deserialize<'de>>(
452        &self,
453        id: &ODataId,
454        etag: Option<&ODataETag>,
455        v: &V,
456    ) -> Result<ModificationResponse<R>, Self::Error> {
457        let endpoint_url = self.redfish_endpoint.with_path(&id.to_string());
458        let etag = etag
459            .cloned()
460            .unwrap_or_else(|| ODataETag::from(String::from("*")));
461        let credentials = self.read_credentials();
462        self.client
463            .patch(
464                endpoint_url,
465                etag,
466                v,
467                credentials.as_ref(),
468                &self.custom_headers,
469            )
470            .await
471    }
472
473    async fn delete<T: Sync + Send + for<'de> Deserialize<'de>>(
474        &self,
475        id: &ODataId,
476    ) -> Result<ModificationResponse<T>, Self::Error> {
477        let endpoint_url = self.redfish_endpoint.with_path(&id.to_string());
478        let credentials = self.read_credentials();
479        self.client
480            .delete(endpoint_url, credentials.as_ref(), &self.custom_headers)
481            .await
482    }
483
484    async fn action<
485        T: Sync + Send + Serialize,
486        R: Sync + Send + Sized + for<'de> Deserialize<'de>,
487    >(
488        &self,
489        action: &Action<T, R>,
490        params: &T,
491    ) -> Result<ModificationResponse<R>, Self::Error> {
492        let endpoint_url = self.redfish_endpoint.with_path(&action.target.to_string());
493        let credentials = self.read_credentials();
494        self.client
495            .post(
496                endpoint_url,
497                params,
498                credentials.as_ref(),
499                &self.custom_headers,
500            )
501            .await
502    }
503
504    async fn filter<T: EntityTypeRef + Sized + for<'a> Deserialize<'a> + 'static + Send + Sync>(
505        &self,
506        id: &ODataId,
507        query: FilterQuery,
508    ) -> Result<Arc<T>, Self::Error> {
509        let endpoint_url = self
510            .redfish_endpoint
511            .with_path_and_query(&id.to_string(), &query.to_query_string());
512
513        self.get_with_cache(endpoint_url, id).await
514    }
515
516    async fn stream<T: Sized + for<'a> Deserialize<'a> + Send + 'static>(
517        &self,
518        uri: &str,
519    ) -> Result<BoxTryStream<T, Self::Error>, Self::Error> {
520        let endpoint_url = Url::parse(uri).unwrap_or_else(|_| self.redfish_endpoint.with_path(uri));
521        let credentials = self.read_credentials();
522        self.client
523            .sse(endpoint_url, credentials.as_ref(), &self.custom_headers)
524            .await
525    }
526}