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
16#![deny(
17    clippy::all,
18    clippy::pedantic,
19    clippy::nursery,
20    clippy::suspicious,
21    clippy::complexity,
22    clippy::perf
23)]
24#![deny(
25    clippy::absolute_paths,
26    clippy::todo,
27    clippy::unimplemented,
28    clippy::tests_outside_test_module,
29    clippy::panic,
30    clippy::unwrap_used,
31    clippy::unwrap_in_result,
32    clippy::unused_trait_names,
33    clippy::print_stdout,
34    clippy::print_stderr
35)]
36#![allow(clippy::doc_markdown)]
37#![allow(clippy::duration_suboptimal_units)]
38#![deny(missing_docs)]
39
40//! HTTP implementation of [`nv_redfish_core::Bmc`] trait.
41
42pub mod cache;
43pub mod credentials;
44
45#[cfg(feature = "reqwest")]
46mod schema;
47
48#[cfg(feature = "reqwest")]
49pub mod reqwest;
50
51use std::collections::HashMap;
52use std::error::Error as StdError;
53use std::future::Future;
54use std::sync::Arc;
55use std::sync::RwLock;
56
57use crate::cache::TypeErasedCarCache;
58
59use http::HeaderMap;
60use nv_redfish_core::query::ExpandQuery;
61use nv_redfish_core::Action;
62use nv_redfish_core::Bmc;
63use nv_redfish_core::BoxTryStream;
64use nv_redfish_core::EntityTypeRef;
65use nv_redfish_core::Expandable;
66use nv_redfish_core::FilterQuery;
67use nv_redfish_core::ModificationResponse;
68use nv_redfish_core::ODataETag;
69use nv_redfish_core::ODataId;
70use nv_redfish_core::SessionCreateResponse;
71use nv_redfish_core::UploadReader;
72use serde::{de::DeserializeOwned, Deserialize, Serialize};
73use url::Url;
74
75#[doc(inline)]
76pub use credentials::BmcCredentials;
77
78#[cfg(feature = "update-service-deprecated")]
79#[doc(inline)]
80pub use nv_redfish_core::HttpPushUriUpdateRequest;
81#[cfg(feature = "update-service-deprecated")]
82#[doc(inline)]
83pub use nv_redfish_core::UploadStream;
84
85#[doc(inline)]
86pub use nv_redfish_core::MultipartUpdateRequest;
87
88/// HTTP Client trait.
89///
90/// nv-redfish-bmc-http supports any HTTP implementation that
91/// implements this [`HttpClient`] trait.
92pub trait HttpClient: Send + Sync {
93    /// HTTP client error.
94    type Error: Send + StdError;
95
96    /// Perform an HTTP GET request with optional conditional headers.
97    fn get<T>(
98        &self,
99        url: Url,
100        credentials: &BmcCredentials,
101        etag: Option<ODataETag>,
102        custom_headers: &HeaderMap,
103    ) -> impl Future<Output = Result<T, Self::Error>> + Send
104    where
105        T: DeserializeOwned + Send + Sync;
106
107    /// Perform an HTTP POST request.
108    fn post<B, T>(
109        &self,
110        url: Url,
111        body: &B,
112        credentials: &BmcCredentials,
113        custom_headers: &HeaderMap,
114    ) -> impl Future<Output = Result<ModificationResponse<T>, Self::Error>> + Send
115    where
116        B: Serialize + Send + Sync,
117        T: DeserializeOwned + Send + Sync;
118
119    /// Perform a Redfish session creation POST request.
120    fn post_session<B, T>(
121        &self,
122        url: Url,
123        body: &B,
124        custom_headers: &HeaderMap,
125    ) -> impl Future<Output = Result<SessionCreateResponse<T>, Self::Error>> + Send
126    where
127        B: Serialize + Send + Sync,
128        T: DeserializeOwned + Send + Sync;
129
130    /// Performs an UpdateService multipart upload with credentials and headers.
131    ///
132    /// The request carries `UpdateParameters`, `UpdateFile`, and optional OEM
133    /// multipart parts.
134    fn post_multipart_update<U, V, T>(
135        &self,
136        url: Url,
137        request: MultipartUpdateRequest<'_, U, V>,
138        credentials: &BmcCredentials,
139        custom_headers: &HeaderMap,
140    ) -> impl Future<Output = Result<ModificationResponse<T>, Self::Error>> + Send
141    where
142        U: UploadReader,
143        T: DeserializeOwned + Send + Sync,
144        V: Serialize + Send + Sync;
145
146    /// Performs a deprecated `UpdateService` raw `HttpPushUri` upload with
147    /// credentials and headers.
148    ///
149    /// This supports the deprecated `HttpPushUri` update path that exists in
150    /// the Redfish spec. Prefer multipart update for BMCs that support
151    /// `MultipartHttpPushUri`.
152    #[cfg(feature = "update-service-deprecated")]
153    fn post_http_push_uri_update<U, T>(
154        &self,
155        url: Url,
156        request: HttpPushUriUpdateRequest<U>,
157        credentials: &BmcCredentials,
158        custom_headers: &HeaderMap,
159    ) -> impl Future<Output = Result<ModificationResponse<T>, Self::Error>> + Send
160    where
161        U: UploadReader,
162        T: DeserializeOwned + Send + Sync;
163
164    /// Perform an HTTP PATCH request.
165    fn patch<B, T>(
166        &self,
167        url: Url,
168        etag: ODataETag,
169        body: &B,
170        credentials: &BmcCredentials,
171        custom_headers: &HeaderMap,
172    ) -> impl Future<Output = Result<ModificationResponse<T>, Self::Error>> + Send
173    where
174        B: Serialize + Send + Sync,
175        T: DeserializeOwned + Send + Sync;
176
177    /// Perform an HTTP DELETE request.
178    fn delete<T>(
179        &self,
180        url: Url,
181        credentials: &BmcCredentials,
182        custom_headers: &HeaderMap,
183    ) -> impl Future<Output = Result<ModificationResponse<T>, Self::Error>> + Send
184    where
185        T: DeserializeOwned + Send + Sync;
186
187    /// Open an SSE stream
188    fn sse<T: Sized + for<'de> Deserialize<'de> + Send>(
189        &self,
190        url: Url,
191        credentials: &BmcCredentials,
192        custom_headers: &HeaderMap,
193    ) -> impl Future<Output = Result<BoxTryStream<T, Self::Error>, Self::Error>> + Send;
194}
195
196/// HTTP-based BMC implementation that wraps an [`HttpClient`].
197///
198/// This struct combines an HTTP client with BMC endpoint information and credentials
199/// to provide a complete Redfish client implementation. It implements the [`Bmc`] trait
200/// to provide standardized access to Redfish services.
201///
202/// # Type Parameters
203///
204/// * `C` - The HTTP client implementation to use
205pub struct HttpBmc<C: HttpClient> {
206    client: C,
207    redfish_endpoint: RedfishEndpoint,
208    credentials: RwLock<Arc<BmcCredentials>>,
209    cache: RwLock<TypeErasedCarCache<ODataId>>,
210    etags: RwLock<HashMap<ODataId, ODataETag>>,
211    custom_headers: HeaderMap,
212}
213
214impl<C: HttpClient> HttpBmc<C>
215where
216    C::Error: CacheableError,
217{
218    /// Create a new HTTP-based BMC client with ETag-based caching.
219    ///
220    /// # Arguments
221    ///
222    /// * `client` - The HTTP client implementation to use for requests
223    /// * `redfish_endpoint` - The base URL of the Redfish service (e.g., `https://192.168.1.100`)
224    /// * `credentials` - Authentication credentials for the BMC
225    ///
226    /// # Examples
227    ///
228    /// ```rust,no_run
229    /// use nv_redfish_bmc_http::HttpBmc;
230    /// use nv_redfish_bmc_http::CacheSettings;
231    /// use nv_redfish_bmc_http::BmcCredentials;
232    /// use nv_redfish_bmc_http::reqwest::Client;
233    /// use url::Url;
234    ///
235    /// # fn example() -> Result<(), Box<dyn std::error::Error>> {
236    /// let credentials = BmcCredentials::username_password("admin".to_string(), Some("password".to_string()));
237    /// let http_client = Client::new()?;
238    /// let endpoint = Url::parse("https://192.168.1.100")?;
239    ///
240    /// let bmc = HttpBmc::new(http_client, endpoint, credentials, CacheSettings::default());
241    /// # Ok(())
242    /// # }
243    /// ```
244    pub fn new(
245        client: C,
246        redfish_endpoint: Url,
247        credentials: BmcCredentials,
248        cache_settings: CacheSettings,
249    ) -> Self {
250        Self::with_custom_headers(
251            client,
252            redfish_endpoint,
253            credentials,
254            cache_settings,
255            HeaderMap::new(),
256        )
257    }
258
259    /// Create a new HTTP-based BMC client with custom headers and ETag-based caching.
260    ///
261    /// This is an alternative constructor that allows specifying custom HTTP headers
262    /// that will be included in all requests. Use this when you need vendor-specific
263    /// headers, custom authentication tokens, or other HTTP headers required by the
264    /// Redfish service at construction time.
265    ///
266    /// For most use cases, prefer [`HttpBmc::new`] which creates a client without
267    /// custom headers.
268    ///
269    /// # Arguments
270    ///
271    /// * `client` - The HTTP client implementation to use for requests
272    /// * `redfish_endpoint` - The base URL of the Redfish service (e.g., `https://192.168.1.100`)
273    /// * `credentials` - Authentication credentials for the BMC
274    /// * `cache_settings` - Cache configuration for response caching
275    /// * `custom_headers` - Custom HTTP headers to include in all requests
276    ///
277    /// # Examples
278    ///
279    /// ```rust,no_run
280    /// use nv_redfish_bmc_http::HttpBmc;
281    /// use nv_redfish_bmc_http::CacheSettings;
282    /// use nv_redfish_bmc_http::BmcCredentials;
283    /// use nv_redfish_bmc_http::reqwest::Client;
284    /// use url::Url;
285    /// use http::HeaderMap;
286    ///
287    /// # fn example() -> Result<(), Box<dyn std::error::Error>> {
288    /// let credentials = BmcCredentials::username_password("admin".to_string(), Some("password".to_string()));
289    /// let http_client = Client::new()?;
290    /// let endpoint = Url::parse("https://192.168.1.100")?;
291    ///
292    /// // Create custom headers
293    /// let mut headers = HeaderMap::new();
294    /// headers.insert("X-Auth-Token", "custom-token-value".parse()?);
295    /// headers.insert("X-Vendor-Header", "vendor-specific-value".parse()?);
296    ///
297    /// // Create BMC client with custom headers
298    /// let bmc = HttpBmc::with_custom_headers(
299    ///     http_client,
300    ///     endpoint,
301    ///     credentials,
302    ///     CacheSettings::default(),
303    ///     headers,
304    /// );
305    ///
306    /// // All requests will include the custom headers
307    /// # Ok(())
308    /// # }
309    /// ```
310    pub fn with_custom_headers(
311        client: C,
312        redfish_endpoint: Url,
313        credentials: BmcCredentials,
314        cache_settings: CacheSettings,
315        custom_headers: HeaderMap,
316    ) -> Self {
317        Self {
318            client,
319            redfish_endpoint: RedfishEndpoint::from(redfish_endpoint),
320            credentials: RwLock::new(Arc::new(credentials)),
321            cache: RwLock::new(TypeErasedCarCache::new(cache_settings.capacity)),
322            etags: RwLock::new(HashMap::new()),
323            custom_headers,
324        }
325    }
326
327    /// Replace the credentials used for subsequent requests.
328    ///
329    /// Existing cache and ETag state is preserved.
330    ///
331    /// # Panics
332    ///
333    /// Panics if the internal credentials lock is poisoned. This should not
334    /// occur in normal operation.
335    #[allow(clippy::panic)] // See panics section.
336    pub fn set_credentials(&self, credentials: BmcCredentials) {
337        *self.credentials.write().expect("poisoned") = Arc::new(credentials);
338    }
339}
340
341/// A tagged type representing a Redfish endpoint URL.
342///
343/// Provides convenient conversion methods to build endpoint URLs from `ODataId` paths.
344#[derive(Debug, Clone)]
345pub struct RedfishEndpoint {
346    base_url: Url,
347}
348
349impl RedfishEndpoint {
350    /// Create a new `RedfishEndpoint` from a base URL
351    #[must_use]
352    pub const fn new(base_url: Url) -> Self {
353        Self { base_url }
354    }
355
356    /// Convert a path to a full Redfish endpoint URL
357    #[must_use]
358    pub fn with_path(&self, path: &str) -> Url {
359        let mut url = self.base_url.clone();
360        url.set_path(path);
361        url
362    }
363
364    /// Convert a path to a full Redfish endpoint URL with query parameters
365    #[must_use]
366    pub fn with_path_and_query(&self, path: &str, query: &str) -> Url {
367        let mut url = self.with_path(path);
368        url.set_query(Some(query));
369        url
370    }
371}
372
373/// `CacheSettings` for internal BMC cache with etags
374#[derive(Clone, Copy)]
375pub struct CacheSettings {
376    capacity: usize,
377}
378
379impl Default for CacheSettings {
380    fn default() -> Self {
381        Self { capacity: 100 }
382    }
383}
384
385impl CacheSettings {
386    /// Define capacity of the cache measured in number of items.
387    #[must_use]
388    pub const fn with_capacity(capacity: usize) -> Self {
389        Self { capacity }
390    }
391}
392
393impl From<Url> for RedfishEndpoint {
394    fn from(url: Url) -> Self {
395        Self::new(url)
396    }
397}
398
399impl From<&RedfishEndpoint> for Url {
400    fn from(endpoint: &RedfishEndpoint) -> Self {
401        endpoint.base_url.clone()
402    }
403}
404
405/// Trait for errors that can indicate whether they represent a cached response
406/// and provide a way to create cache-related errors.
407pub trait CacheableError {
408    /// Returns true if this error indicates the resource should be served from cache.
409    /// Typically true for HTTP 304 Not Modified responses.
410    fn is_cached(&self) -> bool;
411
412    /// Create an error for when cached data is requested but not available.
413    fn cache_miss() -> Self;
414
415    /// Cache error
416    fn cache_error(reason: String) -> Self;
417}
418
419impl<C: HttpClient> HttpBmc<C>
420where
421    C::Error: CacheableError + StdError + Send + Sync,
422{
423    #[allow(clippy::panic)] // See set_credentials Panic doc.
424    fn read_credentials(&self) -> Arc<BmcCredentials> {
425        self.credentials
426            .read()
427            .map(|credentials| Arc::clone(&credentials))
428            .expect("lock poisoned")
429    }
430
431    /// Perform a GET request with `ETag` caching support
432    ///
433    /// This handles:
434    /// - Retrieving cached `ETag` before request
435    /// - Sending conditional GET with If-None-Match
436    /// - Handling 304 Not Modified responses from cache
437    /// - Updating cache and `ETag` storage on success
438    #[allow(clippy::significant_drop_tightening)]
439    async fn get_with_cache<T: EntityTypeRef + for<'de> Deserialize<'de> + 'static>(
440        &self,
441        endpoint_url: Url,
442        id: &ODataId,
443    ) -> Result<Arc<T>, C::Error> {
444        // Retrieve cached etag
445        let etag: Option<ODataETag> = {
446            let etags = self
447                .etags
448                .read()
449                .map_err(|e| C::Error::cache_error(e.to_string()))?;
450            etags.get(id).cloned()
451        };
452        let credentials = self.read_credentials();
453
454        // Perform GET request
455        match self
456            .client
457            .get::<T>(
458                endpoint_url,
459                credentials.as_ref(),
460                etag,
461                &self.custom_headers,
462            )
463            .await
464        {
465            Ok(response) => {
466                let entity = Arc::new(response);
467
468                // Update cache if entity has etag
469                if let Some(etag) = entity.etag() {
470                    let mut cache = self
471                        .cache
472                        .write()
473                        .map_err(|e| C::Error::cache_error(e.to_string()))?;
474
475                    let mut etags = self
476                        .etags
477                        .write()
478                        .map_err(|e| C::Error::cache_error(e.to_string()))?;
479
480                    if let Some(evicted_id) = cache.put_typed(id.clone(), Arc::clone(&entity)) {
481                        etags.remove(&evicted_id);
482                    }
483                    etags.insert(id.clone(), etag.clone());
484                }
485                Ok(entity)
486            }
487            Err(e) => {
488                // Handle 304 Not Modified - return from cache
489                if e.is_cached() {
490                    let mut cache = self
491                        .cache
492                        .write()
493                        .map_err(|e| C::Error::cache_error(e.to_string()))?;
494                    cache
495                        .get_typed::<Arc<T>>(id)
496                        .cloned()
497                        .ok_or_else(C::Error::cache_miss)
498                } else {
499                    Err(e)
500                }
501            }
502        }
503    }
504}
505
506impl<C: HttpClient> Bmc for HttpBmc<C>
507where
508    C::Error: CacheableError + StdError + Send + Sync,
509{
510    type Error = C::Error;
511
512    async fn get<T: EntityTypeRef + for<'de> Deserialize<'de> + 'static>(
513        &self,
514        id: &ODataId,
515    ) -> Result<Arc<T>, Self::Error> {
516        let endpoint_url = self.redfish_endpoint.with_path(&id.to_string());
517        self.get_with_cache(endpoint_url, id).await
518    }
519
520    async fn expand<T: Expandable + 'static>(
521        &self,
522        id: &ODataId,
523        query: ExpandQuery,
524    ) -> Result<Arc<T>, Self::Error> {
525        let endpoint_url = self
526            .redfish_endpoint
527            .with_path_and_query(&id.to_string(), &query.to_query_string());
528
529        self.get_with_cache(endpoint_url, id).await
530    }
531
532    async fn create<V: Sync + Send + Serialize, R: Sync + Send + for<'de> Deserialize<'de>>(
533        &self,
534        id: &ODataId,
535        v: &V,
536    ) -> Result<ModificationResponse<R>, Self::Error> {
537        let endpoint_url = self.redfish_endpoint.with_path(&id.to_string());
538        let credentials = self.read_credentials();
539        self.client
540            .post(endpoint_url, v, credentials.as_ref(), &self.custom_headers)
541            .await
542    }
543
544    async fn create_session<
545        V: Sync + Send + Serialize,
546        R: Sync + Send + for<'de> Deserialize<'de>,
547    >(
548        &self,
549        id: &ODataId,
550        v: &V,
551    ) -> Result<SessionCreateResponse<R>, Self::Error> {
552        let endpoint_url = self.redfish_endpoint.with_path(&id.to_string());
553        self.client
554            .post_session(endpoint_url, v, &self.custom_headers)
555            .await
556    }
557
558    async fn update<V: Sync + Send + Serialize, R: Sync + Send + for<'de> Deserialize<'de>>(
559        &self,
560        id: &ODataId,
561        etag: Option<&ODataETag>,
562        v: &V,
563    ) -> Result<ModificationResponse<R>, Self::Error> {
564        let endpoint_url = self.redfish_endpoint.with_path(&id.to_string());
565        let etag = etag
566            .cloned()
567            .unwrap_or_else(|| ODataETag::from(String::from("*")));
568        let credentials = self.read_credentials();
569        self.client
570            .patch(
571                endpoint_url,
572                etag,
573                v,
574                credentials.as_ref(),
575                &self.custom_headers,
576            )
577            .await
578    }
579
580    async fn delete<T: Sync + Send + for<'de> Deserialize<'de>>(
581        &self,
582        id: &ODataId,
583    ) -> Result<ModificationResponse<T>, Self::Error> {
584        let endpoint_url = self.redfish_endpoint.with_path(&id.to_string());
585        let credentials = self.read_credentials();
586        self.client
587            .delete(endpoint_url, credentials.as_ref(), &self.custom_headers)
588            .await
589    }
590
591    async fn action<T: Send + Sync + Serialize, R: Send + Sync + for<'de> Deserialize<'de>>(
592        &self,
593        action: &Action<T, R>,
594        params: &T,
595    ) -> Result<ModificationResponse<R>, Self::Error> {
596        let endpoint_url = self.redfish_endpoint.with_path(&action.target.to_string());
597        let credentials = self.read_credentials();
598        self.client
599            .post(
600                endpoint_url,
601                params,
602                credentials.as_ref(),
603                &self.custom_headers,
604            )
605            .await
606    }
607
608    async fn multipart_update<U, V, R>(
609        &self,
610        uri: &str,
611        request: MultipartUpdateRequest<'_, U, V>,
612    ) -> Result<ModificationResponse<R>, Self::Error>
613    where
614        U: UploadReader,
615        R: Send + Sync + for<'de> Deserialize<'de>,
616        V: Send + Sync + Serialize,
617    {
618        // MultipartHttpPushUri can be absolute or BMC-relative.
619        // Match existing URI handling before adding auth and headers.
620        let endpoint_url = Url::parse(uri).unwrap_or_else(|_| self.redfish_endpoint.with_path(uri));
621        let credentials = self.read_credentials();
622
623        self.client
624            .post_multipart_update(
625                endpoint_url,
626                request,
627                credentials.as_ref(),
628                &self.custom_headers,
629            )
630            .await
631    }
632
633    #[cfg(feature = "update-service-deprecated")]
634    async fn http_push_uri_update<U, R>(
635        &self,
636        uri: &str,
637        request: HttpPushUriUpdateRequest<U>,
638    ) -> Result<ModificationResponse<R>, Self::Error>
639    where
640        U: UploadReader,
641        R: Send + Sync + for<'de> Deserialize<'de>,
642    {
643        // HttpPushUri can be absolute or BMC-relative.
644        // Match existing multipart URI handling before adding auth and headers.
645        let endpoint_url = Url::parse(uri).unwrap_or_else(|_| self.redfish_endpoint.with_path(uri));
646        let credentials = self.read_credentials();
647
648        self.client
649            .post_http_push_uri_update(
650                endpoint_url,
651                request,
652                credentials.as_ref(),
653                &self.custom_headers,
654            )
655            .await
656    }
657
658    async fn filter<T: EntityTypeRef + for<'de> Deserialize<'de> + 'static>(
659        &self,
660        id: &ODataId,
661        query: FilterQuery,
662    ) -> Result<Arc<T>, Self::Error> {
663        let endpoint_url = self
664            .redfish_endpoint
665            .with_path_and_query(&id.to_string(), &query.to_query_string());
666
667        self.get_with_cache(endpoint_url, id).await
668    }
669
670    async fn stream<T: Send + Sized + for<'de> Deserialize<'de>>(
671        &self,
672        uri: &str,
673    ) -> Result<BoxTryStream<T, Self::Error>, Self::Error> {
674        let endpoint_url = Url::parse(uri).unwrap_or_else(|_| self.redfish_endpoint.with_path(uri));
675        let credentials = self.read_credentials();
676        self.client
677            .sse(endpoint_url, credentials.as_ref(), &self.custom_headers)
678            .await
679    }
680}