1use std::sync::Arc;
9use std::time::Duration;
10
11use bytes::Bytes;
12use rand::RngExt;
13use reqwest::{Method, StatusCode};
14use serde::Serialize;
15use serde::de::DeserializeOwned;
16use tokio::sync::Mutex;
17
18use crate::api::{ApiResponse, HumanVerificationCredential, ResponseCode};
19use crate::config::{API_CONTENT_TYPE, ProtonClientConfiguration, RetryPolicy};
20use crate::error::{ProtonApiError, ProtonError, Result};
21use crate::ids::SessionId;
22use crate::telemetry::{NoopTelemetry, Telemetry, TelemetryExt};
23
24const SESSION_ID_HEADER: &str = "x-pm-uid";
25const APP_VERSION_HEADER: &str = "x-pm-appversion";
26const STORAGE_TOKEN_HEADER: &str = "pm-storage-token";
27const HV_TOKEN_HEADER: &str = "x-pm-human-verification-token";
28const HV_TOKEN_TYPE_HEADER: &str = "x-pm-human-verification-token-type";
29
30#[derive(Debug, Clone)]
33pub struct Tokens {
34 pub access_token: String,
35 pub refresh_token: String,
36}
37
38#[derive(Clone)]
43pub struct ApiHttpClient {
44 inner: Arc<Inner>,
45 route_prefix: Arc<str>,
51}
52
53type TokensRefreshedCallback = Arc<dyn Fn(Tokens) + Send + Sync>;
55
56struct Inner {
57 http: reqwest::Client,
58 base_url: String,
59 config: ProtonClientConfiguration,
60 session_id: SessionId,
61 tokens: Mutex<Tokens>,
62 telemetry: std::sync::Mutex<Arc<dyn Telemetry>>,
68 on_tokens_refreshed: std::sync::Mutex<Option<TokensRefreshedCallback>>,
69}
70
71impl ApiHttpClient {
72 pub fn new(
74 config: ProtonClientConfiguration,
75 session_id: SessionId,
76 tokens: Tokens,
77 ) -> Result<Self> {
78 let http = reqwest::Client::builder()
84 .timeout(config.request_timeout)
85 .gzip(true)
86 .build()?;
87
88 let base_url = ensure_trailing_slash(&config.base_url);
89
90 Ok(Self {
91 inner: Arc::new(Inner {
92 http,
93 base_url,
94 config,
95 session_id,
96 tokens: Mutex::new(tokens),
97 telemetry: std::sync::Mutex::new(NoopTelemetry::shared()),
98 on_tokens_refreshed: std::sync::Mutex::new(None),
99 }),
100 route_prefix: Arc::from(""),
101 })
102 }
103
104 pub fn with_base_route(&self, route: impl Into<Arc<str>>) -> Self {
110 Self {
111 inner: Arc::clone(&self.inner),
112 route_prefix: route.into(),
113 }
114 }
115
116 pub async fn current_tokens(&self) -> Tokens {
118 self.inner.tokens.lock().await.clone()
119 }
120
121 pub fn set_telemetry(&self, telemetry: Arc<dyn Telemetry>) {
128 *self
129 .inner
130 .telemetry
131 .lock()
132 .expect("telemetry mutex poisoned") = telemetry;
133 }
134
135 pub fn set_on_tokens_refreshed(&self, callback: impl Fn(Tokens) + Send + Sync + 'static) {
138 *self
139 .inner
140 .on_tokens_refreshed
141 .lock()
142 .expect("on_tokens_refreshed mutex poisoned") = Some(Arc::new(callback));
143 }
144
145 fn telemetry(&self) -> Arc<dyn Telemetry> {
147 self.inner
148 .telemetry
149 .lock()
150 .expect("telemetry mutex poisoned")
151 .clone()
152 }
153
154 pub async fn get_storage_blob(&self, url: &str, token: &str) -> Result<Bytes> {
168 let mut timer = self.telemetry().start("storage_download");
169 let response = send_retrying(&self.inner.config.retry_policy, || {
170 let mut request = self
173 .inner
174 .http
175 .get(url)
176 .timeout(self.inner.config.storage_timeout)
177 .header(STORAGE_TOKEN_HEADER, token);
178 if !self.inner.config.user_agent.is_empty() {
179 request =
180 request.header(reqwest::header::USER_AGENT, &self.inner.config.user_agent);
181 }
182 request
183 })
184 .await?;
185 let status = response.status();
186 timer.attr("status", status.as_u16());
187 let bytes = response.bytes().await?;
188
189 if let Ok(envelope) = serde_json::from_slice::<ApiResponse>(&bytes) {
192 if !envelope.is_success() {
193 return Err(api_error(status, &bytes));
194 }
195 } else if !status.is_success() {
196 return Err(api_error(status, &bytes));
197 }
198
199 timer.success();
200 Ok(bytes)
201 }
202
203 pub async fn post_storage_blob(&self, url: &str, token: &str, blob: Bytes) -> Result<()> {
216 reqwest::multipart::Part::bytes(Vec::new())
219 .mime_str("application/octet-stream")
220 .map_err(ProtonError::from)?;
221
222 let blob_len = blob.len() as u64;
223 let mut timer = self.telemetry().start("storage_upload");
224 let response = send_retrying(&self.inner.config.retry_policy, || {
225 let body = reqwest::Body::from(blob.clone());
226 let part = reqwest::multipart::Part::stream_with_length(body, blob_len)
227 .file_name("blob")
228 .mime_str("application/octet-stream")
229 .expect("octet-stream is a valid MIME type");
230 let form = reqwest::multipart::Form::new().part("Block", part);
231
232 let mut request = self
233 .inner
234 .http
235 .post(url)
236 .timeout(self.inner.config.storage_timeout)
237 .header(STORAGE_TOKEN_HEADER, token)
238 .multipart(form);
239
240 if !self.inner.config.user_agent.is_empty() {
241 request =
242 request.header(reqwest::header::USER_AGENT, &self.inner.config.user_agent);
243 }
244 request
245 })
246 .await?;
247 let status = response.status();
248 timer.attr("status", status.as_u16());
249 let bytes = response.bytes().await?;
250
251 if let Ok(envelope) = serde_json::from_slice::<ApiResponse>(&bytes) {
252 if !envelope.is_success() {
253 return Err(api_error(status, &bytes));
254 }
255 } else if !status.is_success() {
256 return Err(api_error(status, &bytes));
257 }
258 timer.success();
259 Ok(())
260 }
261
262 pub fn session_id(&self) -> &SessionId {
263 &self.inner.session_id
264 }
265
266 pub async fn get<T: DeserializeOwned>(&self, path: &str) -> Result<T> {
268 self.send::<(), T>(Method::GET, path, None).await
269 }
270
271 pub async fn post<B: Serialize, T: DeserializeOwned>(&self, path: &str, body: &B) -> Result<T> {
273 self.send::<B, T>(Method::POST, path, Some(body)).await
274 }
275
276 pub async fn post_multipart<B: Serialize, T: DeserializeOwned>(
279 &self,
280 path: &str,
281 metadata: &B,
282 binary_parts: &[(String, Vec<u8>)],
283 ) -> Result<T> {
284 let metadata = serde_json::to_vec(metadata)?;
285 let mut timer = self.telemetry().start("http_request");
286 timer.attr("method", "POST");
287 let rejected_token = self.inner.tokens.lock().await.access_token.clone();
288 let response = self
289 .send_multipart_with_token(path, &metadata, binary_parts, &rejected_token)
290 .await?;
291 let response = if response.status() == StatusCode::UNAUTHORIZED {
292 let bytes = response.bytes().await?;
293 if let Ok(envelope) = serde_json::from_slice::<ApiResponse>(&bytes)
294 && matches!(
295 envelope.code,
296 ResponseCode::AccountDeleted | ResponseCode::AccountDisabled
297 )
298 {
299 return Err(api_error(StatusCode::UNAUTHORIZED, &bytes));
300 }
301 let token = self.refresh_access_token(&rejected_token).await?;
302 self.send_multipart_with_token(path, &metadata, binary_parts, &token)
303 .await?
304 } else {
305 response
306 };
307 timer.attr("status", response.status().as_u16());
308 let parsed = parse_response(response).await?;
309 timer.success();
310 Ok(parsed)
311 }
312
313 pub async fn put<B: Serialize, T: DeserializeOwned>(&self, path: &str, body: &B) -> Result<T> {
315 self.send::<B, T>(Method::PUT, path, Some(body)).await
316 }
317
318 pub async fn delete<T: DeserializeOwned>(&self, path: &str) -> Result<T> {
320 self.send::<(), T>(Method::DELETE, path, None).await
321 }
322
323 pub async fn delete_with_body<B: Serialize, T: DeserializeOwned>(
329 &self,
330 path: &str,
331 body: &B,
332 ) -> Result<T> {
333 self.send::<B, T>(Method::DELETE, path, Some(body)).await
334 }
335
336 async fn send<B: Serialize, T: DeserializeOwned>(
337 &self,
338 method: Method,
339 path: &str,
340 body: Option<&B>,
341 ) -> Result<T> {
342 let mut timer = self.telemetry().start("http_request");
343 timer.attr("method", method.as_str());
344
345 let access_token = self.inner.tokens.lock().await.access_token.clone();
346
347 let response = self
349 .send_with_token(method.clone(), path, body, &access_token)
350 .await?;
351
352 let response = if response.status() == StatusCode::UNAUTHORIZED {
353 self.handle_unauthorized(method, path, body, response, access_token)
354 .await?
355 } else {
356 response
357 };
358
359 timer.attr("status", response.status().as_u16());
360 let parsed = parse_response(response).await?;
361 timer.success();
362 Ok(parsed)
363 }
364
365 async fn handle_unauthorized<B: Serialize>(
366 &self,
367 method: Method,
368 path: &str,
369 body: Option<&B>,
370 response: reqwest::Response,
371 rejected_access_token: String,
372 ) -> Result<reqwest::Response> {
373 let bytes = response.bytes().await?;
375 if let Ok(envelope) = serde_json::from_slice::<ApiResponse>(&bytes)
376 && matches!(
377 envelope.code,
378 ResponseCode::AccountDeleted | ResponseCode::AccountDisabled
379 )
380 {
381 return Err(api_error(StatusCode::UNAUTHORIZED, &bytes));
382 }
383
384 let access_token = self.refresh_access_token(&rejected_access_token).await?;
385 self.send_with_token(method, path, body, &access_token)
386 .await
387 }
388
389 async fn send_with_token<B: Serialize>(
390 &self,
391 method: Method,
392 path: &str,
393 body: Option<&B>,
394 access_token: &str,
395 ) -> Result<reqwest::Response> {
396 let url = format!(
397 "{}{}{}",
398 self.inner.base_url,
399 self.route_prefix,
400 path.trim_start_matches('/')
401 );
402 send_retrying(&self.inner.config.retry_policy, || {
403 let mut request = self
404 .inner
405 .http
406 .request(method.clone(), &url)
407 .header(SESSION_ID_HEADER, self.inner.session_id.as_str())
408 .header(APP_VERSION_HEADER, &self.inner.config.app_version)
409 .header(reqwest::header::ACCEPT, API_CONTENT_TYPE)
410 .bearer_auth(access_token);
411
412 if !self.inner.config.user_agent.is_empty() {
413 request =
414 request.header(reqwest::header::USER_AGENT, &self.inner.config.user_agent);
415 }
416
417 if let Some(body) = body {
418 request = request.json(body);
419 }
420
421 request
422 })
423 .await
424 }
425
426 async fn send_multipart_with_token(
427 &self,
428 path: &str,
429 metadata: &[u8],
430 binary_parts: &[(String, Vec<u8>)],
431 access_token: &str,
432 ) -> Result<reqwest::Response> {
433 let url = format!(
434 "{}{}{}",
435 self.inner.base_url,
436 self.route_prefix,
437 path.trim_start_matches('/')
438 );
439 send_retrying(&self.inner.config.retry_policy, || {
440 let metadata_part = reqwest::multipart::Part::bytes(metadata.to_vec())
441 .file_name("Metadata")
442 .mime_str("application/json")
443 .expect("application/json is a valid MIME type");
444 let mut form = reqwest::multipart::Form::new().part("Metadata", metadata_part);
445 for (name, bytes) in binary_parts {
446 let part = reqwest::multipart::Part::bytes(bytes.clone())
447 .file_name(name.clone())
448 .mime_str("application/octet-stream")
449 .expect("octet-stream is a valid MIME type");
450 form = form.part(name.clone(), part);
451 }
452 let mut request = self
453 .inner
454 .http
455 .post(&url)
456 .timeout(self.inner.config.storage_timeout)
457 .header(SESSION_ID_HEADER, self.inner.session_id.as_str())
458 .header(APP_VERSION_HEADER, &self.inner.config.app_version)
459 .header(reqwest::header::ACCEPT, API_CONTENT_TYPE)
460 .bearer_auth(access_token)
461 .multipart(form);
462 if !self.inner.config.user_agent.is_empty() {
463 request =
464 request.header(reqwest::header::USER_AGENT, &self.inner.config.user_agent);
465 }
466 request
467 })
468 .await
469 }
470
471 async fn refresh_access_token(&self, rejected_access_token: &str) -> Result<String> {
475 let mut guard = self.inner.tokens.lock().await;
476
477 if guard.access_token != rejected_access_token {
478 return Ok(guard.access_token.clone());
479 }
480
481 let refreshed = self.request_refresh(&guard.refresh_token).await?;
482 *guard = refreshed.clone();
483
484 if let Some(ref cb) = *self
486 .inner
487 .on_tokens_refreshed
488 .lock()
489 .expect("on_tokens_refreshed mutex poisoned")
490 {
491 cb(refreshed.clone());
492 }
493
494 Ok(refreshed.access_token)
495 }
496
497 async fn request_refresh(&self, refresh_token: &str) -> Result<Tokens> {
498 let url = format!("{}auth/v4/refresh", self.inner.base_url);
499 let body = SessionRefreshRequest {
500 response_type: "token",
501 grant_type: "refresh_token",
502 refresh_token,
503 redirect_uri: &self.inner.config.refresh_redirect_uri,
504 };
505
506 let response = send_retrying(&self.inner.config.retry_policy, || {
509 self.inner
510 .http
511 .post(&url)
512 .header(SESSION_ID_HEADER, self.inner.session_id.as_str())
513 .header(APP_VERSION_HEADER, &self.inner.config.app_version)
514 .header(reqwest::header::ACCEPT, API_CONTENT_TYPE)
515 .json(&body)
516 })
517 .await?;
518
519 let refreshed: SessionRefreshResponse = parse_response(response).await?;
520 Ok(Tokens {
521 access_token: refreshed.access_token,
522 refresh_token: refreshed.refresh_token,
523 })
524 }
525}
526
527#[derive(Serialize)]
528struct SessionRefreshRequest<'a> {
529 #[serde(rename = "ResponseType")]
530 response_type: &'a str,
531 #[serde(rename = "GrantType")]
532 grant_type: &'a str,
533 #[serde(rename = "RefreshToken")]
534 refresh_token: &'a str,
535 #[serde(rename = "RedirectURI")]
536 redirect_uri: &'a str,
537}
538
539#[derive(serde::Deserialize)]
540struct SessionRefreshResponse {
541 #[serde(rename = "AccessToken")]
542 access_token: String,
543 #[serde(rename = "RefreshToken")]
544 refresh_token: String,
545}
546
547pub async fn get_unauthenticated<T: DeserializeOwned>(
553 config: &ProtonClientConfiguration,
554 path: &str,
555) -> Result<T> {
556 let http = reqwest::Client::builder()
557 .timeout(config.request_timeout)
558 .gzip(true)
559 .build()?;
560
561 let base_url = ensure_trailing_slash(&config.base_url);
562 let url = format!("{}{}", base_url, path.trim_start_matches('/'));
563
564 let response = send_retrying(&config.retry_policy, || {
565 let mut request = http
566 .get(&url)
567 .header(APP_VERSION_HEADER, &config.app_version)
568 .header(reqwest::header::ACCEPT, API_CONTENT_TYPE);
569 if !config.user_agent.is_empty() {
570 request = request.header(reqwest::header::USER_AGENT, &config.user_agent);
571 }
572 request
573 })
574 .await?;
575
576 parse_response(response).await
577}
578
579pub async fn post_unauthenticated<B: Serialize, T: DeserializeOwned>(
585 config: &ProtonClientConfiguration,
586 path: &str,
587 body: &B,
588) -> Result<T> {
589 post_unauthenticated_verified(config, path, body, None).await
590}
591
592pub async fn post_unauthenticated_verified<B: Serialize, T: DeserializeOwned>(
601 config: &ProtonClientConfiguration,
602 path: &str,
603 body: &B,
604 verification: Option<&HumanVerificationCredential>,
605) -> Result<T> {
606 let http = reqwest::Client::builder()
607 .timeout(config.request_timeout)
608 .gzip(true)
609 .build()?;
610
611 let base_url = ensure_trailing_slash(&config.base_url);
612 let url = format!("{}{}", base_url, path.trim_start_matches('/'));
613
614 let response = send_retrying(&config.retry_policy, || {
615 let mut request = http
616 .post(&url)
617 .header(APP_VERSION_HEADER, &config.app_version)
618 .header(reqwest::header::ACCEPT, API_CONTENT_TYPE)
619 .json(body);
620 if !config.user_agent.is_empty() {
621 request = request.header(reqwest::header::USER_AGENT, &config.user_agent);
622 }
623 if let Some(hv) = verification {
624 request = request
625 .header(HV_TOKEN_HEADER, &hv.token)
626 .header(HV_TOKEN_TYPE_HEADER, &hv.method);
627 }
628 request
629 })
630 .await?;
631
632 parse_response(response).await
633}
634
635async fn parse_response<T: DeserializeOwned>(response: reqwest::Response) -> Result<T> {
638 let status = response.status();
639 let bytes = response.bytes().await?;
640
641 if let Ok(envelope) = serde_json::from_slice::<ApiResponse>(&bytes) {
646 if !envelope.is_success() && envelope.code != ResponseCode::MultipleResponses {
647 return Err(api_error(status, &bytes));
648 }
649 } else if !status.is_success() {
650 return Err(api_error(status, &bytes));
651 }
652
653 Ok(serde_json::from_slice::<T>(&bytes)?)
654}
655
656fn api_error(status: StatusCode, bytes: &[u8]) -> ProtonError {
657 let envelope = serde_json::from_slice::<ApiResponse>(bytes).ok();
658 let code = envelope
659 .as_ref()
660 .map(|e| e.code)
661 .unwrap_or(ResponseCode::Unknown);
662 let details = envelope.as_ref().and_then(|e| e.details.clone());
663 let message = envelope.and_then(|e| e.error_message).unwrap_or_else(|| {
664 status
665 .canonical_reason()
666 .unwrap_or("unknown error")
667 .to_owned()
668 });
669
670 ProtonError::Api(ProtonApiError {
671 code,
672 http_status: status.as_u16(),
673 message,
674 details,
675 })
676}
677
678async fn send_retrying<F>(policy: &RetryPolicy, build: F) -> Result<reqwest::Response>
690where
691 F: Fn() -> reqwest::RequestBuilder,
692{
693 let mut attempt: u32 = 0;
694 loop {
695 match build().send().await {
696 Ok(response) => {
697 if attempt < policy.max_retries && is_retryable_status(response.status()) {
698 let delay = retry_after(&response).unwrap_or_else(|| backoff(policy, attempt));
699 tokio::time::sleep(delay).await;
700 attempt += 1;
701 continue;
702 }
703 return Ok(response);
704 }
705 Err(err) => {
706 if attempt < policy.max_retries && is_retryable_error(&err) {
707 tokio::time::sleep(backoff(policy, attempt)).await;
708 attempt += 1;
709 continue;
710 }
711 return Err(err.into());
712 }
713 }
714 }
715}
716
717fn is_retryable_status(status: StatusCode) -> bool {
720 matches!(status.as_u16(), 408 | 429 | 502 | 503 | 504)
721}
722
723fn is_retryable_error(err: &reqwest::Error) -> bool {
727 err.is_timeout() || err.is_connect()
728}
729
730fn retry_after(response: &reqwest::Response) -> Option<Duration> {
733 let value = response
734 .headers()
735 .get(reqwest::header::RETRY_AFTER)?
736 .to_str()
737 .ok()?;
738 parse_retry_after_secs(value)
739}
740
741fn parse_retry_after_secs(value: &str) -> Option<Duration> {
744 value.trim().parse().ok().map(Duration::from_secs)
745}
746
747fn backoff(policy: &RetryPolicy, attempt: u32) -> Duration {
750 let ceiling = policy
751 .base_delay
752 .saturating_mul(1u32.checked_shl(attempt).unwrap_or(u32::MAX))
753 .min(policy.max_delay);
754 let ceiling_ms = ceiling.as_millis() as u64;
755 if ceiling_ms == 0 {
756 return Duration::ZERO;
757 }
758 Duration::from_millis(rand::rng().random_range(0..=ceiling_ms))
759}
760
761fn ensure_trailing_slash(url: &str) -> String {
762 if url.ends_with('/') {
763 url.to_owned()
764 } else {
765 format!("{url}/")
766 }
767}
768
769#[cfg(test)]
770mod tests {
771 use super::*;
772
773 #[test]
774 fn retryable_statuses() {
775 for code in [408u16, 429, 502, 503, 504] {
776 assert!(is_retryable_status(StatusCode::from_u16(code).unwrap()));
777 }
778 for code in [200u16, 400, 401, 403, 404, 500] {
779 assert!(!is_retryable_status(StatusCode::from_u16(code).unwrap()));
780 }
781 }
782
783 #[test]
784 fn retry_after_parses_seconds_only() {
785 assert_eq!(parse_retry_after_secs("5"), Some(Duration::from_secs(5)));
786 assert_eq!(
787 parse_retry_after_secs(" 12 "),
788 Some(Duration::from_secs(12))
789 );
790 assert_eq!(parse_retry_after_secs("0"), Some(Duration::ZERO));
791 assert_eq!(
793 parse_retry_after_secs("Wed, 21 Oct 2015 07:28:00 GMT"),
794 None
795 );
796 assert_eq!(parse_retry_after_secs(""), None);
797 }
798
799 #[test]
800 fn backoff_grows_then_caps_within_jitter_bounds() {
801 let policy = RetryPolicy {
802 max_retries: 5,
803 base_delay: Duration::from_millis(100),
804 max_delay: Duration::from_millis(1000),
805 };
806 for attempt in 0..8u32 {
809 let ceiling = Duration::from_millis(100u64.saturating_mul(1 << attempt.min(20)))
810 .min(policy.max_delay);
811 for _ in 0..64 {
812 assert!(backoff(&policy, attempt) <= ceiling);
813 }
814 }
815 }
816
817 #[test]
818 fn backoff_handles_large_attempt_without_overflow() {
819 let policy = RetryPolicy::default();
820 assert!(backoff(&policy, 64) <= policy.max_delay);
822 }
823
824 #[test]
825 fn disabled_policy_has_no_retries() {
826 assert_eq!(RetryPolicy::disabled().max_retries, 0);
827 }
828
829 struct Capture(std::sync::Mutex<Vec<crate::telemetry::TelemetryEvent>>);
831
832 impl Telemetry for Capture {
833 fn record(&self, event: &crate::telemetry::TelemetryEvent) {
834 self.0.lock().unwrap().push(event.clone());
835 }
836 }
837
838 #[tokio::test]
839 async fn http_request_records_telemetry_event() {
840 use crate::telemetry::Outcome;
841 use tokio::io::{AsyncReadExt, AsyncWriteExt};
842
843 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
846 let addr = listener.local_addr().unwrap();
847 let server = tokio::spawn(async move {
848 let (mut sock, _) = listener.accept().await.unwrap();
849 let mut buf = [0u8; 2048];
850 let _ = sock.read(&mut buf).await.unwrap();
851 let body = br#"{"Code":1000}"#;
852 let head = format!(
853 "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n",
854 body.len()
855 );
856 sock.write_all(head.as_bytes()).await.unwrap();
857 sock.write_all(body).await.unwrap();
858 sock.flush().await.unwrap();
859 });
860
861 let config = ProtonClientConfiguration::new("test@1.0")
862 .with_base_url(format!("http://{addr}/"))
863 .with_retry_policy(RetryPolicy::disabled());
864 let client = ApiHttpClient::new(
865 config,
866 SessionId::from("test-session"),
867 Tokens {
868 access_token: "access".into(),
869 refresh_token: "refresh".into(),
870 },
871 )
872 .unwrap();
873
874 let capture = Arc::new(Capture(std::sync::Mutex::new(Vec::new())));
875 client.set_telemetry(capture.clone());
876
877 let _: ApiResponse = client.get("some/path").await.unwrap();
878 server.await.unwrap();
879
880 let events = capture.0.lock().unwrap();
881 assert_eq!(events.len(), 1, "exactly one http_request event");
882 let event = &events[0];
883 assert_eq!(event.operation, "http_request");
884 assert_eq!(event.outcome, Outcome::Success);
885 assert!(
886 event
887 .attributes
888 .iter()
889 .any(|(k, v)| *k == "method" && v == "GET")
890 );
891 assert!(
892 event
893 .attributes
894 .iter()
895 .any(|(k, v)| *k == "status" && v == "200")
896 );
897 }
898}