Skip to main content

aws_smithy_http_client/test_util/
replay.rs

1/*
2 * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
3 * SPDX-License-Identifier: Apache-2.0
4 */
5
6use aws_smithy_protocol_test::{assert_ok, validate_body, MediaType};
7use aws_smithy_runtime_api::client::connector_metadata::ConnectorMetadata;
8use aws_smithy_runtime_api::client::http::{
9    HttpClient, HttpConnector, HttpConnectorFuture, HttpConnectorSettings, SharedHttpConnector,
10};
11use aws_smithy_runtime_api::client::orchestrator::{HttpRequest, HttpResponse};
12use aws_smithy_runtime_api::client::result::ConnectorError;
13use aws_smithy_runtime_api::client::runtime_components::RuntimeComponents;
14use aws_smithy_runtime_api::shared::IntoShared;
15use http_1x::header::CONTENT_TYPE;
16use std::ops::Deref;
17use std::sync::{Arc, Mutex, MutexGuard};
18
19type ReplayEvents = Vec<ReplayEvent>;
20
21pub(crate) const DEFAULT_RELAXED_HEADERS: &[&str] =
22    &["x-amz-user-agent", "user-agent", "authorization"];
23
24/// Test data for the [`StaticReplayClient`].
25///
26/// Each `ReplayEvent` represents one HTTP request and response
27/// through the connector.
28#[derive(Debug)]
29pub struct ReplayEvent {
30    request: HttpRequest,
31    response: HttpResponse,
32}
33
34impl ReplayEvent {
35    /// Creates a new `ReplayEvent`.
36    pub fn new(request: impl TryInto<HttpRequest>, response: impl TryInto<HttpResponse>) -> Self {
37        Self {
38            request: request.try_into().ok().expect("invalid request"),
39            response: response.try_into().ok().expect("invalid response"),
40        }
41    }
42
43    /// Returns the test request.
44    pub fn request(&self) -> &HttpRequest {
45        &self.request
46    }
47
48    /// Returns the test response.
49    pub fn response(&self) -> &HttpResponse {
50        &self.response
51    }
52}
53
54impl From<(HttpRequest, HttpResponse)> for ReplayEvent {
55    fn from((request, response): (HttpRequest, HttpResponse)) -> Self {
56        Self::new(request, response)
57    }
58}
59
60#[derive(Debug)]
61struct ValidateRequest {
62    expected: HttpRequest,
63    actual: HttpRequest,
64}
65
66impl ValidateRequest {
67    fn assert_matches(&self, index: usize, ignore_headers: &[&str]) {
68        let (actual, expected) = (&self.actual, &self.expected);
69        assert_eq!(
70            expected.uri(),
71            actual.uri(),
72            "request[{index}] - URI doesn't match expected value"
73        );
74        for (name, value) in expected.headers() {
75            if !ignore_headers.contains(&name) {
76                let actual_header = actual
77                    .headers()
78                    .get(name)
79                    .unwrap_or_else(|| panic!("Request #{index} - Header {name:?} is missing"));
80                assert_eq!(
81                    value, actual_header,
82                    "request[{index}] - Header {name:?} doesn't match expected value",
83                );
84            }
85        }
86        let actual_str = std::str::from_utf8(actual.body().bytes().unwrap_or(&[]));
87        let expected_str = std::str::from_utf8(expected.body().bytes().unwrap_or(&[]));
88        let media_type = if actual
89            .headers()
90            .get(CONTENT_TYPE)
91            .map(|v| v.contains("json"))
92            .unwrap_or(false)
93        {
94            MediaType::Json
95        } else {
96            MediaType::Other("unknown".to_string())
97        };
98        match (actual_str, expected_str) {
99            (Ok(actual), Ok(expected)) => assert_ok(validate_body(actual, expected, media_type)),
100            _ => assert_eq!(
101                expected.body().bytes(),
102                actual.body().bytes(),
103                "request[{index}] - Body contents didn't match expected value"
104            ),
105        };
106    }
107}
108
109/// Request/response replaying client for use in tests.
110///
111/// This mock client takes a list of request/response pairs named [`ReplayEvent`]. While the client
112/// is in use, the responses will be given in the order they appear in the list regardless of what
113/// the actual request was. The actual request is recorded, but otherwise not validated against what
114/// is in the [`ReplayEvent`]. Later, after the client is finished being used, the
115/// [`assert_requests_match`] method can be used to validate the requests.
116///
117/// This utility is simpler than [DVR], and thus, is good for tests that don't need
118/// to record and replay real traffic.
119///
120/// # Example
121///
122/// ```no_run
123/// # use http_1x as http;
124/// use aws_smithy_http_client::test_util::{ReplayEvent, StaticReplayClient};
125/// use aws_smithy_types::body::SdkBody;
126///
127/// let http_client = StaticReplayClient::new(vec![
128///     // Event that covers the first request/response
129///     ReplayEvent::new(
130///         // If `assert_requests_match` is called later, then this request will be matched
131///         // against the actual request that was made.
132///         http::Request::builder().uri("http://localhost:1234/foo").body(SdkBody::empty()).unwrap(),
133///         // This response will be given to the first request regardless of whether it matches the request above.
134///         http::Response::builder().status(200).body(SdkBody::empty()).unwrap(),
135///     ),
136///     // The next ReplayEvent covers the second request/response pair...
137/// ]);
138///
139/// # /*
140/// let config = my_generated_client::Config::builder()
141///     .http_client(http_client.clone())
142///     .build();
143/// let client = my_generated_client::Client::from_conf(config);
144/// # */
145///
146/// // Do stuff with client...
147///
148/// // When you're done, assert the requests match what you expected
149/// http_client.assert_requests_match(&[]);
150/// ```
151///
152/// [`assert_requests_match`]: StaticReplayClient::assert_requests_match
153/// [DVR]: crate::test_util::dvr
154#[derive(Clone, Debug)]
155pub struct StaticReplayClient {
156    data: Arc<Mutex<ReplayEvents>>,
157    requests: Arc<Mutex<Vec<ValidateRequest>>>,
158}
159
160impl StaticReplayClient {
161    /// Creates a new event connector.
162    pub fn new(mut data: ReplayEvents) -> Self {
163        data.reverse();
164        StaticReplayClient {
165            data: Arc::new(Mutex::new(data)),
166            requests: Default::default(),
167        }
168    }
169
170    /// Returns an iterator over the actual requests that were made.
171    pub fn actual_requests(&self) -> impl Iterator<Item = &HttpRequest> + '_ {
172        // The iterator trait doesn't allow us to specify a lifetime on `self` in the `next()` method,
173        // so we have to do some unsafe code in order to actually implement this iterator without
174        // angering the borrow checker.
175        struct Iter<'a> {
176            // We store an exclusive lock to the data so that the data is completely immutable
177            _guard: MutexGuard<'a, Vec<ValidateRequest>>,
178            // We store a pointer into the immutable data for accessing it later
179            values: *const ValidateRequest,
180            len: usize,
181            next_index: usize,
182        }
183        impl<'a> Iterator for Iter<'a> {
184            type Item = &'a HttpRequest;
185
186            fn next(&mut self) -> Option<Self::Item> {
187                // Safety: check the next index is in bounds
188                if self.next_index >= self.len {
189                    None
190                } else {
191                    // Safety: It is OK to offset into the pointer and dereference since we did a bounds check.
192                    // It is OK to assign lifetime 'a to the reference since we hold the mutex guard for all of lifetime 'a.
193                    let next = unsafe {
194                        let offset = self.values.add(self.next_index);
195                        &*offset
196                    };
197                    self.next_index += 1;
198                    Some(&next.actual)
199                }
200            }
201        }
202
203        let guard = self.requests.lock().unwrap();
204        Iter {
205            values: guard.as_ptr(),
206            len: guard.len(),
207            _guard: guard,
208            next_index: 0,
209        }
210    }
211
212    fn requests(&self) -> impl Deref<Target = Vec<ValidateRequest>> + '_ {
213        self.requests.lock().unwrap()
214    }
215
216    /// Asserts the expected requests match the actual requests.
217    ///
218    /// The expected requests are given as the connection events when the `EventConnector`
219    /// is created. The `EventConnector` will record the actual requests and assert that
220    /// they match the expected requests.
221    ///
222    /// A list of headers that should be ignored when comparing requests can be passed
223    /// for cases where headers are non-deterministic or are irrelevant to the test.
224    #[track_caller]
225    pub fn assert_requests_match(&self, ignore_headers: &[&str]) {
226        for (i, req) in self.requests().iter().enumerate() {
227            req.assert_matches(i, ignore_headers)
228        }
229        let remaining_requests = self.data.lock().unwrap();
230        assert!(
231            remaining_requests.is_empty(),
232            "Expected {} additional requests (only {} sent)",
233            remaining_requests.len(),
234            self.requests().len()
235        );
236    }
237
238    /// Convenience method for `assert_requests_match` that excludes the pre-defined headers to
239    /// be ignored
240    ///
241    /// The pre-defined headers to be ignored:
242    /// - x-amz-user-agent
243    /// - authorization
244    #[track_caller]
245    pub fn relaxed_requests_match(&self) {
246        self.assert_requests_match(DEFAULT_RELAXED_HEADERS)
247    }
248}
249
250impl HttpConnector for StaticReplayClient {
251    fn call(&self, request: HttpRequest) -> HttpConnectorFuture {
252        let res = if let Some(event) = self.data.lock().unwrap().pop() {
253            self.requests.lock().unwrap().push(ValidateRequest {
254                expected: event.request,
255                actual: request,
256            });
257
258            Ok(event.response)
259        } else {
260            Err(ConnectorError::other(
261                "StaticReplayClient: no more test data available to respond with".into(),
262                None,
263            ))
264        };
265
266        HttpConnectorFuture::new(async move { res })
267    }
268}
269
270impl HttpClient for StaticReplayClient {
271    fn http_connector(
272        &self,
273        _: &HttpConnectorSettings,
274        _: &RuntimeComponents,
275    ) -> SharedHttpConnector {
276        self.clone().into_shared()
277    }
278
279    fn connector_metadata(&self) -> Option<ConnectorMetadata> {
280        Some(ConnectorMetadata::new("static-replay-client", None))
281    }
282}
283
284#[cfg(test)]
285mod test {
286    use crate::test_util::{ReplayEvent, StaticReplayClient};
287    use aws_smithy_types::body::SdkBody;
288
289    #[test]
290    fn create_from_either_http_type() {
291        let _client = StaticReplayClient::new(vec![ReplayEvent::new(
292            http_1x::Request::builder()
293                .uri("test")
294                .body(SdkBody::from("hello"))
295                .unwrap(),
296            http_1x::Response::builder()
297                .status(200)
298                .body(SdkBody::from("hello"))
299                .unwrap(),
300        )]);
301    }
302}