1use 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#[derive(Debug)]
60pub enum BmcError {
61 ReqwestError(reqwest::Error),
63 JsonError(serde_path_to_error::Error<serde_json::Error>),
65 InvalidResponse {
67 url: url::Url,
69 status: reqwest::StatusCode,
71 text: String,
73 },
74 SseStreamError(sse_stream::Error),
76 CacheMiss,
78 CacheError(String),
80 DecodeError(serde_json::Error),
82 EncodeError(serde_json::Error),
84 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
150type RetryClassifier =
155 dyn Fn(&reqwest::Request, &reqwest::Response) -> bool + Send + Sync + 'static;
156
157#[derive(Clone)]
185pub struct RetryPolicy {
186 max_retries: u32,
188 delay: Option<Duration>,
190 classifier: Arc<RetryClassifier>,
192}
193
194impl RetryPolicy {
195 #[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 #[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 #[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#[derive(Debug, Clone)]
254pub struct ClientParams {
255 pub timeout: Option<Duration>,
257 pub connect_timeout: Option<Duration>,
259 pub user_agent: Option<String>,
261 pub accept_invalid_certs: bool,
263 pub max_redirects: Option<usize>,
265 pub tcp_keepalive: Option<Duration>,
267 pub pool_idle_timeout: Option<Duration>,
269 pub pool_max_idle_per_host: Option<usize>,
271 pub default_headers: Option<HeaderMap>,
273 pub use_rust_tls: bool,
275 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 #[must_use]
300 pub fn new() -> Self {
301 Self::default()
302 }
303
304 #[must_use]
306 pub const fn timeout(mut self, timeout: Duration) -> Self {
307 self.timeout = Some(timeout);
308 self
309 }
310
311 #[must_use]
313 pub const fn connect_timeout(mut self, timeout: Duration) -> Self {
314 self.connect_timeout = Some(timeout);
315 self
316 }
317
318 #[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 #[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 #[must_use]
334 pub const fn max_redirects(mut self, max: usize) -> Self {
335 self.max_redirects = Some(max);
336 self
337 }
338
339 #[must_use]
341 pub const fn tcp_keepalive(mut self, keepalive: Duration) -> Self {
342 self.tcp_keepalive = Some(keepalive);
343 self
344 }
345
346 #[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 #[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 #[must_use]
362 pub const fn no_timeout(mut self) -> Self {
363 self.timeout = None;
364 self
365 }
366
367 #[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 #[must_use]
376 pub fn retry(mut self, retry: RetryPolicy) -> Self {
377 self.retry = Some(retry);
378 self
379 }
380}
381
382#[derive(Clone)]
388pub struct Client {
389 client: ReqwestClient,
390 retry: Option<RetryPolicy>,
391}
392
393impl Client {
394 pub fn new() -> Result<Self, ReqwestError> {
401 Self::with_params(ClientParams::default())
402 }
403
404 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 #[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 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 let next = if is_last { None } else { current.try_clone() };
486 let response = self.client.execute(current).await?;
487 match next {
488 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 return match serde_path_to_error::deserialize(&value) {
584 Ok(entity) => Ok(ModificationResponse::Entity(entity)),
586 Err(err) => {
587 if is_redfish_success_response(&value) {
588 Ok(ModificationResponse::Empty)
590 } else {
591 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 .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 obj.entry("@odata.etag")
743 .and_modify(|v| *v = etag_value.clone())
744 .or_insert(etag_value);
745 }
746}
747
748#[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 <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 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 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 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 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 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 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: ¶ms,
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 let update_stream =
1328 DataStream::new("firmware.bin", Cursor::new(b"firmware-bytes".to_vec()))
1329 .with_content_length(14);
1330
1331 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: ¶ms,
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: ¶ms,
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}