Skip to main content

nv_redfish_bmc_http/
reqwest.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//! Implementation of [`HttpClient`] trait using reqwest crate.
17
18use std::error::Error as StdErr;
19use std::fmt;
20use std::sync::Arc;
21use std::time::Duration;
22
23use crate::schema::redfish::message::Message;
24use crate::schema::redfish::redfish_error::RedfishError;
25use crate::BmcCredentials;
26use crate::CacheableError;
27use crate::HttpClient;
28#[cfg(feature = "update-service-deprecated")]
29use crate::HttpPushUriUpdateRequest;
30use crate::MultipartUpdateRequest;
31
32use futures_util::StreamExt as _;
33use http::header;
34use http::HeaderMap;
35use nv_redfish_core::AsyncTask;
36use nv_redfish_core::BoxTryStream;
37use nv_redfish_core::DataStream;
38use nv_redfish_core::ModificationResponse;
39use nv_redfish_core::ODataETag;
40use nv_redfish_core::ODataId;
41use nv_redfish_core::OemMultipartPart;
42use nv_redfish_core::SessionCreateResponse;
43use nv_redfish_core::UploadReader;
44#[cfg(feature = "update-service-deprecated")]
45use nv_redfish_core::UploadStream;
46use reqwest::multipart::Form;
47use reqwest::multipart::Part;
48use reqwest::redirect::Policy as RedirectPolicy;
49use reqwest::Client as ReqwestClient;
50use reqwest::Error as ReqwestError;
51use serde::de::DeserializeOwned;
52use serde::Serialize;
53use tokio::time::sleep;
54use tokio_util::compat::FuturesAsyncReadCompatExt as _;
55use tokio_util::io::ReaderStream;
56use url::Url;
57
58/// Errors of reqwest implementation of the HTTP trait.
59#[derive(Debug)]
60pub enum BmcError {
61    /// Direct mapping of underlying reqwest error.
62    ReqwestError(reqwest::Error),
63    /// JSON to model deserialize error with path tracking.
64    JsonError(serde_path_to_error::Error<serde_json::Error>),
65    /// Unexpected HTTP response.
66    InvalidResponse {
67        /// URL in request that caused error.
68        url: url::Url,
69        /// Returned status.
70        status: reqwest::StatusCode,
71        /// Text in the response.
72        text: String,
73    },
74    /// SSE stream error.
75    SseStreamError(sse_stream::Error),
76    /// No resource found in cache.
77    CacheMiss,
78    /// HTTP cache error.
79    CacheError(String),
80    /// JSON deserialization error.
81    DecodeError(serde_json::Error),
82    /// JSON serialization error.
83    EncodeError(serde_json::Error),
84    /// Invalid request error - data in the request didn't pass validation.
85    InvalidRequest(String),
86}
87
88impl From<reqwest::Error> for BmcError {
89    fn from(value: reqwest::Error) -> Self {
90        Self::ReqwestError(value)
91    }
92}
93
94impl CacheableError for BmcError {
95    fn is_cached(&self) -> bool {
96        match self {
97            Self::InvalidResponse { status, .. } => status == &reqwest::StatusCode::NOT_MODIFIED,
98            _ => false,
99        }
100    }
101
102    fn cache_miss() -> Self {
103        Self::CacheMiss
104    }
105
106    fn cache_error(reason: String) -> Self {
107        Self::CacheError(reason)
108    }
109}
110
111impl fmt::Display for BmcError {
112    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
113        match self {
114            Self::ReqwestError(e) => write!(f, "HTTP client error: {e:?}"),
115            Self::InvalidResponse { url, status, text } => {
116                write!(
117                    f,
118                    "Invalid HTTP response - url: {url} status: {status} text: {text}"
119                )
120            }
121            Self::CacheMiss => write!(f, "Resource not found in cache"),
122            Self::CacheError(r) => write!(f, "Error occurred in cache {r:?}"),
123            Self::JsonError(e) => write!(
124                f,
125                "JSON deserialization error at line {} column {} path {}: {e}",
126                e.inner().line(),
127                e.inner().column(),
128                e.path(),
129            ),
130            Self::SseStreamError(e) => write!(f, "SSE stream decode error: {e}"),
131            Self::DecodeError(e) => write!(f, "JSON Decode error: {e}"),
132            Self::EncodeError(e) => write!(f, "JSON Encode error: {e}"),
133            Self::InvalidRequest(e) => write!(f, "Invalid request: {e}"),
134        }
135    }
136}
137
138impl StdErr for BmcError {
139    fn source(&self) -> Option<&(dyn StdErr + 'static)> {
140        match self {
141            Self::ReqwestError(e) => Some(e),
142            Self::JsonError(e) => Some(e.inner()),
143            Self::SseStreamError(e) => Some(e),
144            Self::DecodeError(e) | Self::EncodeError(e) => Some(e),
145            _ => None,
146        }
147    }
148}
149
150/// Classifier deciding whether a response should be retried.
151///
152/// Receives the request and the response, returns `true` if the request
153/// should be retried.
154type RetryClassifier =
155    dyn Fn(&reqwest::Request, &reqwest::Response) -> bool + Send + Sync + 'static;
156
157/// Retry policy with a configurable delay between attempts.
158///
159/// While retries remain, the classifier is called for every received HTTP
160/// response, regardless of the request method, and decides whether to retry.
161/// Transport and connection errors are returned immediately. Requests with
162/// non-clonable (streaming) bodies, such as multipart uploads, are sent exactly
163/// once and never retried.
164///
165/// # Examples
166///
167/// Retry only `GET` requests that return `503 Service Unavailable`. The
168/// classifier returns `false` for every other method, so requests such as
169/// `POST`, `PATCH`, or `DELETE` are never retried:
170///
171/// ```rust
172/// use nv_redfish_bmc_http::reqwest::{ClientParams, RetryPolicy};
173/// use std::time::Duration;
174///
175/// let policy = RetryPolicy::new(|request, response| {
176///     *request.method() == http::Method::GET
177///         && response.status() == http::StatusCode::SERVICE_UNAVAILABLE
178/// })
179/// .max_retries(3)
180/// .delay(Duration::from_millis(500));
181///
182/// let params = ClientParams::new().retry(policy);
183/// ```
184#[derive(Clone)]
185pub struct RetryPolicy {
186    /// Number of extra attempts after the first one.
187    max_retries: u32,
188    /// Fixed sleep between attempts; `None` retries immediately.
189    delay: Option<Duration>,
190    /// Decides whether a response should be retried.
191    classifier: Arc<RetryClassifier>,
192}
193
194impl RetryPolicy {
195    /// Creates a policy that retries responses accepted by `classifier`.
196    ///
197    /// By default no retries are performed; configure them with
198    /// [`Self::max_retries`] and [`Self::delay`].
199    #[must_use]
200    pub fn new<F>(classifier: F) -> Self
201    where
202        F: Fn(&reqwest::Request, &reqwest::Response) -> bool + Send + Sync + 'static,
203    {
204        Self {
205            max_retries: 0,
206            delay: None,
207            classifier: Arc::new(classifier),
208        }
209    }
210
211    /// Maximum number of extra attempts after the initial request.
212    #[must_use]
213    pub const fn max_retries(mut self, max_retries: u32) -> Self {
214        self.max_retries = max_retries;
215        self
216    }
217
218    /// Fixed delay to sleep between attempts.
219    #[must_use]
220    pub const fn delay(mut self, delay: Duration) -> Self {
221        self.delay = Some(delay);
222        self
223    }
224}
225
226impl fmt::Debug for RetryPolicy {
227    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
228        f.debug_struct("RetryPolicy")
229            .field("max_retries", &self.max_retries)
230            .field("delay", &self.delay)
231            .field("classifier", &"<closure>")
232            .finish()
233    }
234}
235
236/// Configuration parameters for the reqwest HTTP client.
237///
238/// This struct allows customizing various aspects of the reqwest client behavior,
239/// including timeouts, TLS settings, and connection pooling.
240///
241/// # Examples
242///
243/// ```rust
244/// use nv_redfish_bmc_http::reqwest::ClientParams;
245/// use std::time::Duration;
246///
247/// let params = ClientParams::new()
248///     .timeout(Duration::from_secs(30))
249///     .connect_timeout(Duration::from_secs(10))
250///     .user_agent("MyApp/1.0")
251///     .accept_invalid_certs(true);
252/// ```
253#[derive(Debug, Clone)]
254pub struct ClientParams {
255    /// HTTP request timeout
256    pub timeout: Option<Duration>,
257    /// TCP connection timeout
258    pub connect_timeout: Option<Duration>,
259    /// User-Agent header value
260    pub user_agent: Option<String>,
261    /// Whether to accept invalid TLS certificates
262    pub accept_invalid_certs: bool,
263    /// Maximum number of HTTP redirects to follow
264    pub max_redirects: Option<usize>,
265    /// TCP keep-alive timeout
266    pub tcp_keepalive: Option<Duration>,
267    /// Connection pool idle timeout
268    pub pool_idle_timeout: Option<Duration>,
269    /// Maximum idle connections per host
270    pub pool_max_idle_per_host: Option<usize>,
271    /// List of default headers, added to every request
272    pub default_headers: Option<HeaderMap>,
273    /// Forces use of rust TLS, enabled by default
274    pub use_rust_tls: bool,
275    /// Retry policy for received responses, `None` disables retries
276    pub retry: Option<RetryPolicy>,
277}
278
279impl Default for ClientParams {
280    fn default() -> Self {
281        Self {
282            timeout: Some(Duration::from_secs(120)),
283            connect_timeout: Some(Duration::from_secs(5)),
284            user_agent: Some("nv-redfish/v1".to_string()),
285            accept_invalid_certs: false,
286            max_redirects: Some(10),
287            tcp_keepalive: Some(Duration::from_secs(60)),
288            pool_idle_timeout: Some(Duration::from_secs(90)),
289            pool_max_idle_per_host: Some(1),
290            default_headers: None,
291            use_rust_tls: true,
292            retry: None,
293        }
294    }
295}
296
297impl ClientParams {
298    /// Creates new client parameters.
299    #[must_use]
300    pub fn new() -> Self {
301        Self::default()
302    }
303
304    /// See: [`reqwest::ClientBuilder::timeout`].
305    #[must_use]
306    pub const fn timeout(mut self, timeout: Duration) -> Self {
307        self.timeout = Some(timeout);
308        self
309    }
310
311    /// See: [`reqwest::ClientBuilder::connect_timeout`].
312    #[must_use]
313    pub const fn connect_timeout(mut self, timeout: Duration) -> Self {
314        self.connect_timeout = Some(timeout);
315        self
316    }
317
318    /// See: [`reqwest::ClientBuilder::user_agent`].
319    #[must_use]
320    pub fn user_agent<S: Into<String>>(mut self, user_agent: S) -> Self {
321        self.user_agent = Some(user_agent.into());
322        self
323    }
324
325    /// See: [`reqwest::ClientBuilder::danger_accept_invalid_certs`].
326    #[must_use]
327    pub const fn accept_invalid_certs(mut self, accept: bool) -> Self {
328        self.accept_invalid_certs = accept;
329        self
330    }
331
332    /// See: [`reqwest::ClientBuilder::redirect`].
333    #[must_use]
334    pub const fn max_redirects(mut self, max: usize) -> Self {
335        self.max_redirects = Some(max);
336        self
337    }
338
339    /// See: [`reqwest::ClientBuilder::tcp_keepalive`].
340    #[must_use]
341    pub const fn tcp_keepalive(mut self, keepalive: Duration) -> Self {
342        self.tcp_keepalive = Some(keepalive);
343        self
344    }
345
346    /// See: [`reqwest::ClientBuilder::pool_max_idle_per_host`].
347    #[must_use]
348    pub const fn pool_max_idle_per_host(mut self, pool_max_idle_per_host: usize) -> Self {
349        self.pool_max_idle_per_host = Some(pool_max_idle_per_host);
350        self
351    }
352
353    /// See: [`reqwest::ClientBuilder::pool_idle_timeout`].
354    #[must_use]
355    pub const fn idle_timeout(mut self, pool_idle_timeout: Duration) -> Self {
356        self.pool_idle_timeout = Some(pool_idle_timeout);
357        self
358    }
359
360    /// Clears timeout for this client.
361    #[must_use]
362    pub const fn no_timeout(mut self) -> Self {
363        self.timeout = None;
364        self
365    }
366
367    /// See: [`reqwest::ClientBuilder::default_headers`].
368    #[must_use]
369    pub fn default_headers(mut self, default_headers: HeaderMap) -> Self {
370        self.default_headers = Some(default_headers);
371        self
372    }
373
374    /// Sets the [`RetryPolicy`] applied to every request.
375    #[must_use]
376    pub fn retry(mut self, retry: RetryPolicy) -> Self {
377        self.retry = Some(retry);
378        self
379    }
380}
381
382/// HTTP client implementation using the reqwest library.
383///
384/// This provides a concrete implementation of [`HttpClient`] using the popular
385/// reqwest HTTP client library. It supports all standard HTTP features including
386/// TLS, authentication, and connection pooling.
387#[derive(Clone)]
388pub struct Client {
389    client: ReqwestClient,
390    retry: Option<RetryPolicy>,
391}
392
393impl Client {
394    /// Create client with default [`ClientParams`].
395    ///
396    /// # Errors
397    ///
398    /// Internally it builds [`reqwest::ClientBuilder::build`]. This function
399    /// transparently passes errors of this call to caller.
400    pub fn new() -> Result<Self, ReqwestError> {
401        Self::with_params(ClientParams::default())
402    }
403
404    /// Build client from parameters.
405    ///
406    /// # Errors
407    ///
408    /// Internally it builds [`reqwest::ClientBuilder::build`]. This function
409    /// transparently passes errors of this call to caller.
410    pub fn with_params(params: ClientParams) -> Result<Self, reqwest::Error> {
411        let mut builder = ReqwestClient::builder();
412
413        if params.use_rust_tls {
414            builder = builder.use_rustls_tls();
415        }
416
417        if let Some(timeout) = params.timeout {
418            builder = builder.timeout(timeout);
419        }
420
421        if let Some(connect_timeout) = params.connect_timeout {
422            builder = builder.connect_timeout(connect_timeout);
423        }
424
425        if let Some(user_agent) = params.user_agent {
426            builder = builder.user_agent(user_agent);
427        }
428
429        if params.accept_invalid_certs {
430            builder = builder.danger_accept_invalid_certs(true);
431        }
432
433        if let Some(max_redirects) = params.max_redirects {
434            builder = builder.redirect(RedirectPolicy::limited(max_redirects));
435        }
436
437        if let Some(keepalive) = params.tcp_keepalive {
438            builder = builder.tcp_keepalive(keepalive);
439        }
440
441        if let Some(idle_timeout) = params.pool_idle_timeout {
442            builder = builder.pool_idle_timeout(idle_timeout);
443        }
444
445        if let Some(max_idle) = params.pool_max_idle_per_host {
446            builder = builder.pool_max_idle_per_host(max_idle);
447        }
448
449        if let Some(default_headers) = params.default_headers {
450            builder = builder.default_headers(default_headers);
451        }
452
453        Ok(Self {
454            client: builder.build()?,
455            retry: params.retry,
456        })
457    }
458
459    /// Use pre-built [`reqwest::Client`] as internal client.
460    #[must_use]
461    pub const fn with_client(client: ReqwestClient) -> Self {
462        Self {
463            client,
464            retry: None,
465        }
466    }
467}
468
469impl Client {
470    /// Sends the request, retrying according to the configured [`RetryPolicy`].
471    ///
472    /// Transport errors are returned immediately. Requests with streaming
473    /// bodies cannot be cloned and are sent exactly once.
474    async fn send(&self, request: reqwest::Request) -> Result<reqwest::Response, BmcError> {
475        let Some(policy) = &self.retry else {
476            return Ok(self.client.execute(request).await?);
477        };
478
479        let mut attempt: u32 = 0;
480        let mut current = request;
481        loop {
482            let is_last = attempt >= policy.max_retries;
483            // try_clone() returns None for streaming bodies, which therefore
484            // get a single attempt.
485            let next = if is_last { None } else { current.try_clone() };
486            let response = self.client.execute(current).await?;
487            match next {
488                // The clone is identical to the request just sent, so the
489                // classifier sees what went over the wire.
490                Some(next_request) if (policy.classifier)(&next_request, &response) => {
491                    if let Some(delay) = policy.delay {
492                        sleep(delay).await;
493                    }
494                    current = next_request;
495                    attempt += 1;
496                }
497                _ => return Ok(response),
498            }
499        }
500    }
501
502    async fn handle_response<T>(&self, response: reqwest::Response) -> Result<T, BmcError>
503    where
504        T: DeserializeOwned,
505    {
506        if !response.status().is_success() {
507            return Err(BmcError::InvalidResponse {
508                url: response.url().clone(),
509                status: response.status(),
510                text: response.text().await.unwrap_or_else(|_| "<no data>".into()),
511            });
512        }
513
514        let headers = response.headers().clone();
515
516        let etag_header = etag_from_headers(&headers);
517
518        let mut value: serde_json::Value = response.json().await.map_err(BmcError::ReqwestError)?;
519
520        if let Some(etag) = etag_header {
521            inject_etag(&etag, &mut value);
522        }
523
524        serde_path_to_error::deserialize(value).map_err(BmcError::JsonError)
525    }
526
527    async fn handle_modification_response<T>(
528        &self,
529        response: reqwest::Response,
530    ) -> Result<ModificationResponse<T>, BmcError>
531    where
532        T: DeserializeOwned + Send + Sync,
533    {
534        let status = response.status();
535        let url = response.url().clone();
536        let headers = response.headers().clone();
537        if !status.is_success() {
538            return Err(BmcError::InvalidResponse {
539                url,
540                status,
541                text: response.text().await.unwrap_or_else(|_| "<no data>".into()),
542            });
543        }
544
545        let etag = etag_from_headers(&headers);
546        let location = location_from_headers(&headers);
547
548        match status {
549            reqwest::StatusCode::NO_CONTENT => Ok(ModificationResponse::Empty),
550            reqwest::StatusCode::ACCEPTED => {
551                let Some(task_location) = location else {
552                    return Err(BmcError::InvalidResponse {
553                        url,
554                        status,
555                        text: String::from("202 Accepted without Location header"),
556                    });
557                };
558
559                Ok(ModificationResponse::Task(AsyncTask {
560                    location: task_location.into(),
561                    retry_after: retry_after_from_headers(&headers),
562                }))
563            }
564            reqwest::StatusCode::OK | reqwest::StatusCode::CREATED => {
565                let bytes = response.bytes().await.map_err(BmcError::ReqwestError)?;
566                if !bytes.is_empty() {
567                    let value: serde_json::Value =
568                        serde_json::from_slice(&bytes).map_err(BmcError::DecodeError)?;
569                    let mut value = value;
570
571                    if value.get("@odata.id").is_some() {
572                        if let Some(etag) = etag {
573                            inject_etag(&etag, &mut value);
574                        }
575                    }
576
577                    // Non-empty 200/201 bodies are typed responses selected by the caller.
578                    //
579                    // - These bodies are not required to be Redfish resources with @odata.id.
580                    //
581                    // - DSP0266 POST actions with no response body may still return "an error
582                    //   response, with a message that indicates success".
583                    return match serde_path_to_error::deserialize(&value) {
584                        // Non-empty 200/201 body matched the caller-selected type.
585                        Ok(entity) => Ok(ModificationResponse::Entity(entity)),
586                        Err(err) => {
587                            if is_redfish_success_response(&value) {
588                                // No-response action returned a Redfish success envelope.
589                                Ok(ModificationResponse::Empty)
590                            } else {
591                                // The response was successful JSON, but it did
592                                // not match the caller-selected response type.
593                                Err(BmcError::JsonError(err))
594                            }
595                        }
596                    };
597                }
598
599                if let Some(location) = location {
600                    let value = serde_json::json!({ "@odata.id": location });
601                    return serde_path_to_error::deserialize(value)
602                        .map(ModificationResponse::Entity)
603                        .map_err(BmcError::JsonError);
604                }
605
606                Ok(ModificationResponse::Empty)
607            }
608            _ => Err(BmcError::InvalidResponse {
609                url,
610                status,
611                text: format!("Unexpected successful status code: {status}"),
612            }),
613        }
614    }
615
616    async fn handle_session_response<T>(
617        &self,
618        response: reqwest::Response,
619    ) -> Result<SessionCreateResponse<T>, BmcError>
620    where
621        T: DeserializeOwned + Send + Sync,
622    {
623        let status = response.status();
624        let url = response.url().clone();
625        let headers = response.headers().clone();
626        if !status.is_success() {
627            return Err(BmcError::InvalidResponse {
628                url,
629                status,
630                text: response.text().await.unwrap_or_else(|_| "<no data>".into()),
631            });
632        }
633
634        let Some(auth_token) = auth_token_from_headers(&headers) else {
635            return Err(BmcError::InvalidResponse {
636                url,
637                status,
638                text: String::from("session creation response missing X-Auth-Token header"),
639            });
640        };
641        let Some(location) = location_from_headers(&headers) else {
642            return Err(BmcError::InvalidResponse {
643                url,
644                status,
645                text: String::from("session creation response missing Location header"),
646            });
647        };
648
649        match status {
650            reqwest::StatusCode::OK | reqwest::StatusCode::CREATED => {
651                let etag = etag_from_headers(&headers);
652                let bytes = response.bytes().await.map_err(BmcError::ReqwestError)?;
653                if bytes.is_empty() {
654                    return Err(BmcError::InvalidResponse {
655                        url,
656                        status,
657                        text: String::from("session creation response missing entity body"),
658                    });
659                }
660
661                let mut value: serde_json::Value =
662                    serde_json::from_slice(&bytes).map_err(BmcError::DecodeError)?;
663                if let Some(etag) = etag {
664                    inject_etag(&etag, &mut value);
665                }
666                let entity =
667                    serde_path_to_error::deserialize(value).map_err(BmcError::JsonError)?;
668
669                Ok(SessionCreateResponse {
670                    entity,
671                    auth_token,
672                    location,
673                })
674            }
675            reqwest::StatusCode::ACCEPTED => Err(BmcError::InvalidResponse {
676                url,
677                status,
678                text: String::from("session creation returned 202 Accepted without session entity"),
679            }),
680            reqwest::StatusCode::NO_CONTENT => Err(BmcError::InvalidResponse {
681                url,
682                status,
683                text: String::from("session creation returned 204 No Content"),
684            }),
685            _ => Err(BmcError::InvalidResponse {
686                url,
687                status,
688                text: format!("Unexpected successful status code for session creation: {status}"),
689            }),
690        }
691    }
692}
693
694fn location_from_headers(headers: &HeaderMap) -> Option<ODataId> {
695    headers
696        .get(header::LOCATION)
697        .and_then(|value| value.to_str().ok())
698        .map(|raw| {
699            Url::parse(raw).map_or_else(
700                |_| raw.to_string().into(),
701                |url| {
702                    let mut path = url.path().to_string();
703                    if let Some(query) = url.query() {
704                        path.push('?');
705                        path.push_str(query);
706                    }
707                    path.into()
708                },
709            )
710        })
711}
712
713fn auth_token_from_headers(headers: &HeaderMap) -> Option<String> {
714    headers
715        .get("x-auth-token")
716        .and_then(|value| value.to_str().ok())
717        .map(ToString::to_string)
718}
719
720fn etag_from_headers(headers: &HeaderMap) -> Option<ODataETag> {
721    headers
722        .get(header::ETAG)
723        .and_then(|value| value.to_str().ok())
724        .map(|v| v.to_string().into())
725}
726
727fn retry_after_from_headers(headers: &HeaderMap) -> Option<Duration> {
728    headers
729        .get(header::RETRY_AFTER)
730        .and_then(|value| value.to_str().ok())
731        // RFC 9110 defines the numeric Retry-After form as delay-seconds.
732        // This helper handles that form and leaves HTTP-date support out of scope.
733        .and_then(|v| v.trim().parse::<u64>().ok())
734        .map(Duration::from_secs)
735}
736
737fn inject_etag(etag: &ODataETag, body: &mut serde_json::Value) {
738    if let Some(obj) = body.as_object_mut() {
739        let etag_value = serde_json::Value::String(etag.to_string());
740
741        // Handles both absent and null values
742        obj.entry("@odata.etag")
743            .and_modify(|v| *v = etag_value.clone())
744            .or_insert(etag_value);
745    }
746}
747
748/// DSP0266 7.11, Table 10 allows actions without response bodies to return
749/// an error-shaped success body. Only that body should become Empty.
750#[inline]
751fn is_redfish_success_response(value: &serde_json::Value) -> bool {
752    #[derive(serde::Deserialize)]
753    struct ExtendedInfoEnvelope {
754        #[serde(rename = "@Message.ExtendedInfo")]
755        _extended_info: Vec<Message>,
756    }
757
758    // If we recieved extended info, this means we got a success response
759    if <ExtendedInfoEnvelope as serde::Deserialize>::deserialize(value).is_ok() {
760        return true;
761    }
762
763    let Ok(response) = <RedfishError as serde::Deserialize>::deserialize(value) else {
764        return false;
765    };
766
767    let code = response.error.code.as_str();
768    let message = code.rsplit_once('.').map_or(code, |(_, message)| message);
769
770    matches!(message, "Success" | "Created" | "NoOperation")
771}
772
773fn auth_headers(
774    request: reqwest::RequestBuilder,
775    credentials: &BmcCredentials,
776) -> reqwest::RequestBuilder {
777    match credentials {
778        BmcCredentials::UsernamePassword { username, password } => {
779            request.basic_auth(username, password.as_ref())
780        }
781        BmcCredentials::Token { token } => request.header("X-Auth-Token", token),
782    }
783}
784
785impl HttpClient for Client {
786    type Error = BmcError;
787
788    async fn get<T>(
789        &self,
790        url: Url,
791        credentials: &BmcCredentials,
792        etag: Option<ODataETag>,
793        custom_headers: &HeaderMap,
794    ) -> Result<T, Self::Error>
795    where
796        T: DeserializeOwned,
797    {
798        let mut request =
799            auth_headers(self.client.get(url), credentials).headers(custom_headers.clone());
800
801        if let Some(etag) = etag {
802            request = request.header(header::IF_NONE_MATCH, etag.to_string());
803        }
804
805        let response = self.send(request.build()?).await?;
806        self.handle_response(response).await
807    }
808
809    async fn post<B, T>(
810        &self,
811        url: Url,
812        body: &B,
813        credentials: &BmcCredentials,
814        custom_headers: &HeaderMap,
815    ) -> Result<ModificationResponse<T>, Self::Error>
816    where
817        B: Serialize + Send + Sync,
818        T: DeserializeOwned + Send + Sync,
819    {
820        let request = auth_headers(self.client.post(url), credentials)
821            .headers(custom_headers.clone())
822            .json(body);
823
824        let response = self.send(request.build()?).await?;
825        self.handle_modification_response(response).await
826    }
827
828    async fn post_session<B, T>(
829        &self,
830        url: Url,
831        body: &B,
832        custom_headers: &HeaderMap,
833    ) -> Result<SessionCreateResponse<T>, Self::Error>
834    where
835        B: Serialize + Send + Sync,
836        T: DeserializeOwned + Send + Sync,
837    {
838        let request = self
839            .client
840            .post(url)
841            .headers(custom_headers.clone())
842            .json(body);
843
844        let response = self.send(request.build()?).await?;
845        self.handle_session_response(response).await
846    }
847
848    async fn patch<B, T>(
849        &self,
850        url: Url,
851        etag: ODataETag,
852        body: &B,
853        credentials: &BmcCredentials,
854        custom_headers: &HeaderMap,
855    ) -> Result<ModificationResponse<T>, Self::Error>
856    where
857        B: Serialize + Send + Sync,
858        T: DeserializeOwned + Send + Sync,
859    {
860        let mut request =
861            auth_headers(self.client.patch(url), credentials).headers(custom_headers.clone());
862
863        request = request.header(header::IF_MATCH, etag.to_string());
864
865        let response = self.send(request.json(body).build()?).await?;
866        self.handle_modification_response(response).await
867    }
868
869    async fn delete<T>(
870        &self,
871        url: Url,
872        credentials: &BmcCredentials,
873        custom_headers: &HeaderMap,
874    ) -> Result<ModificationResponse<T>, Self::Error>
875    where
876        T: DeserializeOwned + Send + Sync,
877    {
878        let request =
879            auth_headers(self.client.delete(url), credentials).headers(custom_headers.clone());
880
881        let response = self.send(request.build()?).await?;
882        self.handle_modification_response(response).await
883    }
884
885    async fn post_multipart_update<U, V, T>(
886        &self,
887        url: Url,
888        update_request: MultipartUpdateRequest<'_, U, V>,
889        credentials: &BmcCredentials,
890        custom_headers: &HeaderMap,
891    ) -> Result<ModificationResponse<T>, Self::Error>
892    where
893        U: UploadReader,
894        T: DeserializeOwned + Send + Sync,
895        V: Serialize + Send + Sync,
896    {
897        let MultipartUpdateRequest {
898            update_parameters,
899            update_stream,
900            oem_parts,
901            upload_timeout,
902        } = update_request;
903
904        // First, check if all OEM parts have valid names.
905        for part in &oem_parts {
906            if !part.is_name_valid() {
907                return Err(BmcError::InvalidRequest(format!(
908                    "OEM part's name `{}` is invalid",
909                    part.name
910                )));
911            }
912        }
913
914        let stream_part = build_stream_part(update_stream, "application/octet-stream")?;
915        let update_parameters_part = build_update_parameters_part(update_parameters)?;
916
917        let mut form = Form::new()
918            .part("UpdateParameters", update_parameters_part)
919            .part("UpdateFile", stream_part);
920
921        for part in oem_parts {
922            let (name, part) = build_oem_part(part)?;
923            form = form.part(name, part);
924        }
925
926        let request = auth_headers(self.client.post(url), credentials)
927            .headers(custom_headers.clone())
928            .multipart(form)
929            .timeout(upload_timeout);
930
931        let response = self.send(request.build()?).await?;
932        self.handle_modification_response(response).await
933    }
934
935    #[cfg(feature = "update-service-deprecated")]
936    async fn post_http_push_uri_update<U, T>(
937        &self,
938        url: Url,
939        update_request: HttpPushUriUpdateRequest<U>,
940        credentials: &BmcCredentials,
941        custom_headers: &HeaderMap,
942    ) -> Result<ModificationResponse<T>, Self::Error>
943    where
944        U: UploadReader,
945        T: DeserializeOwned + Send + Sync,
946    {
947        let HttpPushUriUpdateRequest {
948            update_stream,
949            upload_timeout,
950        } = update_request;
951
952        let UploadStream {
953            reader,
954            content_length,
955        } = update_stream;
956
957        let body = reqwest::Body::wrap_stream(ReaderStream::new(reader.compat()));
958
959        let mut request = auth_headers(self.client.post(url), credentials)
960            .headers(custom_headers.clone())
961            .header(header::CONTENT_TYPE, "application/octet-stream")
962            .body(body)
963            .timeout(upload_timeout);
964
965        if let Some(content_length) = content_length {
966            request = request.header(header::CONTENT_LENGTH, content_length.to_string());
967        }
968
969        let response = self.send(request.build()?).await?;
970        self.handle_modification_response(response).await
971    }
972
973    async fn sse<T: Send + Sized + for<'de> serde::Deserialize<'de>>(
974        &self,
975        url: Url,
976        credentials: &BmcCredentials,
977        custom_headers: &HeaderMap,
978    ) -> Result<BoxTryStream<T, Self::Error>, Self::Error> {
979        let request = auth_headers(self.client.get(url), credentials)
980            .headers(custom_headers.clone())
981            .header(header::ACCEPT, "text/event-stream")
982            .timeout(Duration::MAX);
983
984        let response = self.send(request.build()?).await?;
985
986        if !response.status().is_success() {
987            return Err(BmcError::InvalidResponse {
988                url: response.url().clone(),
989                status: response.status(),
990                text: response.text().await.unwrap_or_else(|_| "<no data>".into()),
991            });
992        }
993
994        let stream = sse_stream::SseStream::from_byte_stream(response.bytes_stream()).filter_map(
995            |event| async move {
996                match event {
997                    Err(err) => Some(Err(BmcError::SseStreamError(err))),
998                    Ok(sse) => sse.data.map(|data| {
999                        serde_path_to_error::deserialize(&mut serde_json::Deserializer::from_str(
1000                            &data,
1001                        ))
1002                        .map_err(BmcError::JsonError)
1003                    }),
1004                }
1005            },
1006        );
1007
1008        Ok(Box::pin(stream))
1009    }
1010}
1011
1012fn build_update_parameters_part<V>(update_parameters: &V) -> Result<Part, BmcError>
1013where
1014    V: Serialize + Send + Sync,
1015{
1016    Part::bytes(serde_json::to_vec(update_parameters).map_err(BmcError::EncodeError)?)
1017        .mime_str("application/json")
1018        .map_err(BmcError::ReqwestError)
1019}
1020
1021fn build_stream_part<U>(stream: DataStream<U>, content_type: &'static str) -> Result<Part, BmcError>
1022where
1023    U: UploadReader,
1024{
1025    let DataStream {
1026        name,
1027        reader,
1028        content_length,
1029    } = stream;
1030
1031    let body = reqwest::Body::wrap_stream(ReaderStream::new(reader.compat()));
1032    let part = match content_length {
1033        Some(length) => Part::stream_with_length(body, length),
1034        None => Part::stream(body),
1035    };
1036
1037    part.file_name(name)
1038        .mime_str(content_type)
1039        .map_err(BmcError::ReqwestError)
1040}
1041
1042fn build_oem_part(part: OemMultipartPart) -> Result<(String, Part), BmcError> {
1043    let OemMultipartPart {
1044        name,
1045        reader,
1046        content_type,
1047        content_length,
1048    } = part;
1049
1050    let body = reqwest::Body::wrap_stream(ReaderStream::new(reader.compat()));
1051
1052    let mut part = match content_length {
1053        Some(length) => Part::stream_with_length(body, length),
1054        None => Part::stream(body),
1055    };
1056
1057    if let Some(content_type) = content_type {
1058        part = part.mime_str(&content_type)?;
1059    }
1060
1061    Ok((name, part))
1062}
1063
1064#[cfg(test)]
1065mod tests {
1066    use std::error::Error as StdError;
1067
1068    use super::*;
1069
1070    use futures_util::io::Cursor;
1071    use wiremock::matchers::header;
1072    use wiremock::matchers::method;
1073    use wiremock::matchers::path;
1074    use wiremock::Mock;
1075    use wiremock::MockServer;
1076    use wiremock::Request;
1077    use wiremock::ResponseTemplate;
1078
1079    #[derive(serde::Serialize)]
1080    struct MultipartParameters {
1081        #[serde(rename = "ForceUpdate")]
1082        force_update: bool,
1083
1084        #[serde(rename = "Targets")]
1085        targets: Vec<String>,
1086    }
1087
1088    #[test]
1089    fn test_cacheable_error_trait() {
1090        let mock_response = reqwest::Response::from(
1091            http::Response::builder()
1092                .status(304)
1093                .body("")
1094                .expect("Valid empty body"),
1095        );
1096        let error = BmcError::InvalidResponse {
1097            url: "http://example.com/redfish/v1".parse().unwrap(),
1098            status: mock_response.status(),
1099            text: "".into(),
1100        };
1101        assert!(error.is_cached());
1102
1103        let cache_miss = BmcError::CacheMiss;
1104        assert!(!cache_miss.is_cached());
1105
1106        let created_miss = BmcError::cache_miss();
1107        assert!(matches!(created_miss, BmcError::CacheMiss));
1108    }
1109
1110    /// Retry policy used in tests: retries GET requests on 503 responses.
1111    fn test_retry_policy(max_retries: u32, delay: Option<Duration>) -> RetryPolicy {
1112        let policy = RetryPolicy::new(|request, response| {
1113            *request.method() == http::Method::GET
1114                && response.status() == http::StatusCode::SERVICE_UNAVAILABLE
1115        })
1116        .max_retries(max_retries);
1117        match delay {
1118            Some(delay) => policy.delay(delay),
1119            None => policy,
1120        }
1121    }
1122
1123    /// Mounts mocks that respond with `unavailable` 503s followed by a 200.
1124    async fn mount_unavailable_then_ok(
1125        mock_server: &MockServer,
1126        resource_path: &str,
1127        unavailable: u64,
1128    ) {
1129        Mock::given(method("GET"))
1130            .and(path(resource_path))
1131            .respond_with(ResponseTemplate::new(503))
1132            .up_to_n_times(unavailable)
1133            .expect(unavailable)
1134            .mount(mock_server)
1135            .await;
1136
1137        Mock::given(method("GET"))
1138            .and(path(resource_path))
1139            .respond_with(
1140                ResponseTemplate::new(200)
1141                    .set_body_json(serde_json::json!({ "@odata.id": resource_path })),
1142            )
1143            .expect(1)
1144            .mount(mock_server)
1145            .await;
1146    }
1147
1148    #[tokio::test]
1149    async fn test_get_is_retried_on_retryable_status() -> Result<(), Box<dyn StdError>> {
1150        let mock_server = MockServer::start().await;
1151        let resource_path = "/redfish/v1";
1152        mount_unavailable_then_ok(&mock_server, resource_path, 2).await;
1153
1154        let client = Client::with_params(ClientParams::new().retry(test_retry_policy(2, None)))?;
1155        let credentials = BmcCredentials::new("root".to_string(), "password".to_string());
1156
1157        let response: serde_json::Value = client
1158            .get(
1159                Url::parse(&format!("{}{resource_path}", mock_server.uri()))?,
1160                &credentials,
1161                None,
1162                &HeaderMap::new(),
1163            )
1164            .await?;
1165
1166        assert_eq!(response["@odata.id"], resource_path);
1167
1168        Ok(())
1169    }
1170
1171    #[tokio::test]
1172    async fn test_post_is_not_retried() -> Result<(), Box<dyn StdError>> {
1173        let mock_server = MockServer::start().await;
1174        let resource_path = "/redfish/v1/Systems/1/Actions/ComputerSystem.Reset";
1175
1176        // The policy retries 503s, but only for GET requests. A POST returning
1177        // 503 must therefore reach the server exactly once.
1178        Mock::given(method("POST"))
1179            .and(path(resource_path))
1180            .respond_with(ResponseTemplate::new(503))
1181            .expect(1)
1182            .mount(&mock_server)
1183            .await;
1184
1185        let client = Client::with_params(ClientParams::new().retry(test_retry_policy(3, None)))?;
1186        let credentials = BmcCredentials::new("root".to_string(), "password".to_string());
1187
1188        let response = client
1189            .post::<_, serde_json::Value>(
1190                Url::parse(&format!("{}{resource_path}", mock_server.uri()))?,
1191                &serde_json::json!({ "ResetType": "ForceRestart" }),
1192                &credentials,
1193                &HeaderMap::new(),
1194            )
1195            .await;
1196
1197        assert!(matches!(
1198            response,
1199            Err(BmcError::InvalidResponse { status, .. })
1200                if status == reqwest::StatusCode::SERVICE_UNAVAILABLE
1201        ));
1202
1203        Ok(())
1204    }
1205
1206    #[tokio::test]
1207    async fn test_retry_delay_is_observed() -> Result<(), Box<dyn StdError>> {
1208        let mock_server = MockServer::start().await;
1209        let resource_path = "/redfish/v1";
1210        mount_unavailable_then_ok(&mock_server, resource_path, 2).await;
1211
1212        let delay = Duration::from_millis(100);
1213        let client =
1214            Client::with_params(ClientParams::new().retry(test_retry_policy(2, Some(delay))))?;
1215        let credentials = BmcCredentials::new("root".to_string(), "password".to_string());
1216
1217        let started = std::time::Instant::now();
1218        let response: serde_json::Value = client
1219            .get(
1220                Url::parse(&format!("{}{resource_path}", mock_server.uri()))?,
1221                &credentials,
1222                None,
1223                &HeaderMap::new(),
1224            )
1225            .await?;
1226
1227        // Two retries mean two sleeps; only assert the lower bound to keep
1228        // the test robust on slow CI machines.
1229        assert!(started.elapsed() >= Duration::from_millis(180));
1230        assert_eq!(response["@odata.id"], resource_path);
1231
1232        Ok(())
1233    }
1234
1235    #[tokio::test]
1236    async fn test_streaming_body_is_not_retried() -> Result<(), Box<dyn StdError>> {
1237        let mock_server = MockServer::start().await;
1238        let upload_path = "/redfish/v1/UpdateService/update-multipart";
1239
1240        // Exactly one request must reach the server even though the policy
1241        // retries every 503: try_clone() returns None for streaming bodies,
1242        // which therefore get a single attempt.
1243        Mock::given(method("POST"))
1244            .and(path(upload_path))
1245            .respond_with(ResponseTemplate::new(503))
1246            .expect(1)
1247            .mount(&mock_server)
1248            .await;
1249
1250        let policy = RetryPolicy::new(|_request, response| {
1251            response.status() == http::StatusCode::SERVICE_UNAVAILABLE
1252        })
1253        .max_retries(3);
1254        let client = Client::with_params(ClientParams::new().retry(policy))?;
1255        let credentials = BmcCredentials::new("root".to_string(), "password".to_string());
1256
1257        let params = MultipartParameters {
1258            force_update: true,
1259            targets: vec!["/redfish/v1/Systems/1".to_string()],
1260        };
1261
1262        let update_stream =
1263            DataStream::new("firmware.bin", Cursor::new(b"firmware-bytes".to_vec()))
1264                .with_content_length(14);
1265
1266        let update_request = MultipartUpdateRequest {
1267            update_parameters: &params,
1268            update_stream,
1269            oem_parts: vec![],
1270            upload_timeout: Duration::from_secs(600),
1271        };
1272
1273        let response = client
1274            .post_multipart_update::<_, _, serde_json::Value>(
1275                Url::parse(&format!("{}{upload_path}", mock_server.uri()))?,
1276                update_request,
1277                &credentials,
1278                &HeaderMap::new(),
1279            )
1280            .await;
1281
1282        assert!(matches!(
1283            response,
1284            Err(BmcError::InvalidResponse { status, .. })
1285                if status == reqwest::StatusCode::SERVICE_UNAVAILABLE
1286        ));
1287
1288        Ok(())
1289    }
1290
1291    #[tokio::test]
1292    async fn test_multipart_form_fails_oem_validation() -> Result<(), Box<dyn StdError>> {
1293        let mock_server = MockServer::start().await;
1294        let upload_path = "/redfish/v1/UpdateService/update-multipart";
1295        let task_path = "/redfish/v1/TaskService/Tasks/42";
1296
1297        Mock::given(method("POST"))
1298            .and(path(upload_path))
1299            .and(header("authorization", "Basic cm9vdDpwYXNzd29yZA=="))
1300            .and(header("X-Upload-Mode", "multipart"))
1301            .and(|request: &Request| {
1302                multipart_body_contains(request, "firmware.bin", "firmware-bytes")
1303            })
1304            .respond_with(
1305                ResponseTemplate::new(202)
1306                    .insert_header("Location", format!("https://bmc.example{task_path}"))
1307                    .insert_header("Retry-After", "15"),
1308            )
1309            .expect(0)
1310            .mount(&mock_server)
1311            .await;
1312
1313        let params = MultipartParameters {
1314            force_update: true,
1315            targets: vec!["/redfish/v1/Systems/1".to_string()],
1316        };
1317
1318        let mut custom_headers = HeaderMap::new();
1319        custom_headers.insert("X-Upload-Mode", http::HeaderValue::from_static("multipart"));
1320
1321        let client = Client::new()?;
1322        let credentials = BmcCredentials::new("root".to_string(), "password".to_string());
1323
1324        //
1325        // Invalid OEM part.
1326        //
1327        let update_stream =
1328            DataStream::new("firmware.bin", Cursor::new(b"firmware-bytes".to_vec()))
1329                .with_content_length(14);
1330
1331        // Construction error - fails name validation.
1332        let r = OemMultipartPart::new("oemNvidia", Cursor::new(br#"{"Mode":"Rms"}"#.to_vec()));
1333        assert!(r.is_err());
1334
1335        let mut invalid_oem_part =
1336            OemMultipartPart::new("OemNvidia", Cursor::new(br#"{"Mode":"Rms"}"#.to_vec()))?
1337                .with_content_type("application/json");
1338        invalid_oem_part.name = "oemNvidia".to_string();
1339
1340        let update_request = MultipartUpdateRequest {
1341            update_parameters: &params,
1342            update_stream,
1343            oem_parts: vec![invalid_oem_part],
1344            upload_timeout: Duration::from_secs(600),
1345        };
1346
1347        let response = client
1348            .post_multipart_update::<_, _, serde_json::Value>(
1349                Url::parse(&format!("{}{upload_path}", mock_server.uri()))?,
1350                update_request,
1351                &credentials,
1352                &custom_headers,
1353            )
1354            .await;
1355
1356        assert!(response.is_err());
1357
1358        Ok(())
1359    }
1360
1361    #[tokio::test]
1362    async fn test_multipart_form_sends_parts_and_returns_task() -> Result<(), Box<dyn StdError>> {
1363        let mock_server = MockServer::start().await;
1364        let upload_path = "/redfish/v1/UpdateService/update-multipart";
1365        let task_path = "/redfish/v1/TaskService/Tasks/42";
1366
1367        Mock::given(method("POST"))
1368            .and(path(upload_path))
1369            .and(header("authorization", "Basic cm9vdDpwYXNzd29yZA=="))
1370            .and(header("X-Upload-Mode", "multipart"))
1371            .and(|request: &Request| {
1372                multipart_body_contains(request, "firmware.bin", "firmware-bytes")
1373            })
1374            .respond_with(
1375                ResponseTemplate::new(202)
1376                    .insert_header("Location", format!("https://bmc.example{task_path}"))
1377                    .insert_header("Retry-After", "15"),
1378            )
1379            .expect(1)
1380            .mount(&mock_server)
1381            .await;
1382
1383        let params = MultipartParameters {
1384            force_update: true,
1385            targets: vec!["/redfish/v1/Systems/1".to_string()],
1386        };
1387
1388        let mut custom_headers = HeaderMap::new();
1389        custom_headers.insert("X-Upload-Mode", http::HeaderValue::from_static("multipart"));
1390
1391        let client = Client::new()?;
1392        let credentials = BmcCredentials::new("root".to_string(), "password".to_string());
1393
1394        let update_stream =
1395            DataStream::new("firmware.bin", Cursor::new(b"firmware-bytes".to_vec()))
1396                .with_content_length(14);
1397
1398        let update_request = MultipartUpdateRequest {
1399            update_parameters: &params,
1400            update_stream,
1401            oem_parts: vec![OemMultipartPart::new(
1402                "OemNvidia",
1403                Cursor::new(br#"{"Mode":"Rms"}"#.to_vec()),
1404            )?
1405            .with_content_type("application/json")],
1406            upload_timeout: Duration::from_secs(600),
1407        };
1408
1409        let response = client
1410            .post_multipart_update::<_, _, serde_json::Value>(
1411                Url::parse(&format!("{}{upload_path}", mock_server.uri()))?,
1412                update_request,
1413                &credentials,
1414                &custom_headers,
1415            )
1416            .await?;
1417
1418        let ModificationResponse::Task(task) = response else {
1419            return Err(String::from("expected task response").into());
1420        };
1421
1422        assert_eq!(task.location.0.to_string(), task_path);
1423        assert_eq!(task.retry_after, Some(Duration::from_secs(15)));
1424
1425        Ok(())
1426    }
1427
1428    fn multipart_body_contains(request: &Request, file_name: &str, file_body: &str) -> bool {
1429        let Some(content_type) = request
1430            .headers
1431            .get("content-type")
1432            .and_then(|value| value.to_str().ok())
1433        else {
1434            return false;
1435        };
1436
1437        let body = String::from_utf8_lossy(&request.body);
1438
1439        content_type.starts_with("multipart/form-data; boundary=")
1440            && body.contains("name=\"UpdateParameters\"")
1441            && body.contains("Content-Type: application/json")
1442            && body.contains("\"ForceUpdate\":true")
1443            && body.contains("\"Targets\":[\"/redfish/v1/Systems/1\"]")
1444            && body.contains("name=\"UpdateFile\"")
1445            && body.contains("Content-Type: application/octet-stream")
1446            && body.contains(&format!("filename=\"{file_name}\""))
1447            && body.contains("name=\"OemNvidia\"")
1448            && !body.contains("name=\"OemNvidia\"; filename=")
1449            && body.contains("{\"Mode\":\"Rms\"}")
1450            && body.contains(file_body)
1451    }
1452}