Skip to main content

rig_core/test_utils/
http.rs

1//! HTTP client doubles for provider tests.
2
3use std::{
4    collections::VecDeque,
5    future::{self, Future},
6    sync::{Arc, Mutex, MutexGuard},
7};
8
9use bytes::Bytes;
10
11use crate::{
12    http_client::{
13        self, HttpClientExt, LazyBody, MultipartForm, Request, Response, StreamingResponse,
14    },
15    wasm_compat::WasmCompatSend,
16};
17
18/// Request data captured by [`RecordingHttpClient`].
19#[derive(Debug, Clone, PartialEq, Eq)]
20pub struct CapturedHttpRequest {
21    /// Request URI.
22    pub uri: String,
23    /// Request headers.
24    pub headers: http::HeaderMap,
25    /// Request body bytes.
26    pub body: Bytes,
27}
28
29/// Response scripted for [`RecordingHttpClient`].
30#[derive(Clone, Debug)]
31pub enum MockHttpResponse {
32    /// Return this body with a successful HTTP status.
33    Success(Bytes),
34    /// Return a status-code error with the given body text.
35    Error(http::StatusCode, String),
36    /// Return an HTTP response with the given (typically non-success) status
37    /// and body, instead of a transport-level error.
38    ErrorResponse(http::StatusCode, Bytes),
39}
40
41impl MockHttpResponse {
42    /// Create a successful response from bytes.
43    pub fn success(body: impl Into<Bytes>) -> Self {
44        Self::Success(body.into())
45    }
46
47    /// Create an error response with a status code and message.
48    pub fn error(status: http::StatusCode, message: impl Into<String>) -> Self {
49        Self::Error(status, message.into())
50    }
51}
52
53impl Default for MockHttpResponse {
54    fn default() -> Self {
55        Self::Success(Bytes::new())
56    }
57}
58
59/// An [`HttpClientExt`] implementation that records unary requests and returns
60/// a fixed response.
61#[derive(Clone, Debug, Default)]
62pub struct RecordingHttpClient {
63    requests: Arc<Mutex<Vec<CapturedHttpRequest>>>,
64    response: Arc<Mutex<MockHttpResponse>>,
65}
66
67impl RecordingHttpClient {
68    /// Create a client that returns `response_body` for unary requests.
69    pub fn new(response_body: impl Into<Bytes>) -> Self {
70        Self {
71            requests: Arc::new(Mutex::new(Vec::new())),
72            response: Arc::new(Mutex::new(MockHttpResponse::success(response_body))),
73        }
74    }
75
76    /// Create a client that returns an HTTP status error for unary requests.
77    pub fn with_error(status: http::StatusCode, message: impl Into<String>) -> Self {
78        Self {
79            requests: Arc::new(Mutex::new(Vec::new())),
80            response: Arc::new(Mutex::new(MockHttpResponse::error(status, message))),
81        }
82    }
83
84    /// Create a client that returns a non-success HTTP response (status and body)
85    /// for unary requests, instead of a transport-level error.
86    pub fn with_error_response(status: http::StatusCode, body: impl Into<Bytes>) -> Self {
87        Self {
88            requests: Arc::new(Mutex::new(Vec::new())),
89            response: Arc::new(Mutex::new(MockHttpResponse::ErrorResponse(
90                status,
91                body.into(),
92            ))),
93        }
94    }
95
96    /// Return the requests captured so far.
97    pub fn requests(&self) -> Vec<CapturedHttpRequest> {
98        self.requests_guard().clone()
99    }
100
101    /// Replace the scripted unary response.
102    pub fn set_response(&self, response: MockHttpResponse) {
103        *self.response_guard() = response;
104    }
105
106    fn requests_guard(&self) -> MutexGuard<'_, Vec<CapturedHttpRequest>> {
107        match self.requests.lock() {
108            Ok(guard) => guard,
109            Err(poisoned) => poisoned.into_inner(),
110        }
111    }
112
113    fn response_guard(&self) -> MutexGuard<'_, MockHttpResponse> {
114        match self.response.lock() {
115            Ok(guard) => guard,
116            Err(poisoned) => poisoned.into_inner(),
117        }
118    }
119
120    fn record_request(&self, uri: String, headers: http::HeaderMap, body: Bytes) {
121        self.requests_guard()
122            .push(CapturedHttpRequest { uri, headers, body });
123    }
124
125    fn build_unary_response<U>(
126        response: MockHttpResponse,
127    ) -> http_client::Result<Response<LazyBody<U>>>
128    where
129        U: From<Bytes> + WasmCompatSend + 'static,
130    {
131        let (status, response_body) = match response {
132            MockHttpResponse::Success(response_body) => (http::StatusCode::OK, response_body),
133            MockHttpResponse::Error(status, message) => {
134                return Err(http_client::Error::InvalidStatusCodeWithMessage(
135                    status, message,
136                ));
137            }
138            MockHttpResponse::ErrorResponse(status, response_body) => (status, response_body),
139        };
140        let body: LazyBody<U> = Box::pin(async move { Ok(U::from(response_body)) });
141        Response::builder()
142            .status(status)
143            .body(body)
144            .map_err(http_client::Error::Protocol)
145    }
146}
147
148impl HttpClientExt for RecordingHttpClient {
149    fn send<T, U>(
150        &self,
151        req: Request<T>,
152    ) -> impl Future<Output = http_client::Result<Response<LazyBody<U>>>> + WasmCompatSend + 'static
153    where
154        T: Into<Bytes> + WasmCompatSend,
155        U: From<Bytes> + WasmCompatSend + 'static,
156    {
157        let response = self.response_guard().clone();
158        let (parts, body) = req.into_parts();
159        self.record_request(parts.uri.to_string(), parts.headers, body.into());
160
161        async move { Self::build_unary_response(response) }
162    }
163
164    fn send_multipart<U>(
165        &self,
166        req: Request<MultipartForm>,
167    ) -> impl Future<Output = http_client::Result<Response<LazyBody<U>>>> + WasmCompatSend + 'static
168    where
169        U: From<Bytes> + WasmCompatSend + 'static,
170    {
171        let response = self.response_guard().clone();
172        let (parts, _body) = req.into_parts();
173        self.record_request(parts.uri.to_string(), parts.headers, Bytes::new());
174
175        async move { Self::build_unary_response(response) }
176    }
177
178    fn send_streaming<T>(
179        &self,
180        _req: Request<T>,
181    ) -> impl Future<Output = http_client::Result<StreamingResponse>> + WasmCompatSend
182    where
183        T: Into<Bytes> + WasmCompatSend,
184    {
185        future::ready(Err(http_client::Error::InvalidStatusCode(
186            http::StatusCode::NOT_IMPLEMENTED,
187        )))
188    }
189}
190
191/// An [`HttpClientExt`] implementation that records unary requests and returns
192/// one scripted response per request.
193///
194/// This is useful for testing retry and recovery paths through real provider
195/// request/response conversion without live credentials.
196#[derive(Clone, Debug, Default)]
197pub struct SequencedHttpClient {
198    requests: Arc<Mutex<Vec<CapturedHttpRequest>>>,
199    responses: Arc<Mutex<VecDeque<MockHttpResponse>>>,
200}
201
202impl SequencedHttpClient {
203    /// Create a client that returns the supplied responses in order.
204    pub fn new(responses: impl IntoIterator<Item = MockHttpResponse>) -> Self {
205        Self {
206            requests: Arc::new(Mutex::new(Vec::new())),
207            responses: Arc::new(Mutex::new(responses.into_iter().collect())),
208        }
209    }
210
211    /// Return the requests captured so far.
212    pub fn requests(&self) -> Vec<CapturedHttpRequest> {
213        match self.requests.lock() {
214            Ok(guard) => guard.clone(),
215            Err(poisoned) => poisoned.into_inner().clone(),
216        }
217    }
218
219    /// Return the number of scripted responses that have not been consumed.
220    pub fn remaining_responses(&self) -> usize {
221        match self.responses.lock() {
222            Ok(guard) => guard.len(),
223            Err(poisoned) => poisoned.into_inner().len(),
224        }
225    }
226
227    fn record_request(&self, uri: String, headers: http::HeaderMap, body: Bytes) {
228        let request = CapturedHttpRequest { uri, headers, body };
229        match self.requests.lock() {
230            Ok(mut guard) => guard.push(request),
231            Err(poisoned) => poisoned.into_inner().push(request),
232        }
233    }
234
235    fn next_response(&self) -> Option<MockHttpResponse> {
236        match self.responses.lock() {
237            Ok(mut guard) => guard.pop_front(),
238            Err(poisoned) => poisoned.into_inner().pop_front(),
239        }
240    }
241}
242
243impl HttpClientExt for SequencedHttpClient {
244    fn send<T, U>(
245        &self,
246        req: Request<T>,
247    ) -> impl Future<Output = http_client::Result<Response<LazyBody<U>>>> + WasmCompatSend + 'static
248    where
249        T: Into<Bytes> + WasmCompatSend,
250        U: From<Bytes> + WasmCompatSend + 'static,
251    {
252        let response = self.next_response();
253        let (parts, body) = req.into_parts();
254        self.record_request(parts.uri.to_string(), parts.headers, body.into());
255
256        async move {
257            match response {
258                Some(response) => RecordingHttpClient::build_unary_response(response),
259                None => Err(http_client::Error::InvalidStatusCode(
260                    http::StatusCode::NOT_IMPLEMENTED,
261                )),
262            }
263        }
264    }
265
266    fn send_multipart<U>(
267        &self,
268        req: Request<MultipartForm>,
269    ) -> impl Future<Output = http_client::Result<Response<LazyBody<U>>>> + WasmCompatSend + 'static
270    where
271        U: From<Bytes> + WasmCompatSend + 'static,
272    {
273        let response = self.next_response();
274        let (parts, _body) = req.into_parts();
275        self.record_request(parts.uri.to_string(), parts.headers, Bytes::new());
276
277        async move {
278            match response {
279                Some(response) => RecordingHttpClient::build_unary_response(response),
280                None => Err(http_client::Error::InvalidStatusCode(
281                    http::StatusCode::NOT_IMPLEMENTED,
282                )),
283            }
284        }
285    }
286
287    fn send_streaming<T>(
288        &self,
289        _req: Request<T>,
290    ) -> impl Future<Output = http_client::Result<StreamingResponse>> + WasmCompatSend
291    where
292        T: Into<Bytes> + WasmCompatSend,
293    {
294        future::ready(Err(http_client::Error::InvalidStatusCode(
295            http::StatusCode::NOT_IMPLEMENTED,
296        )))
297    }
298}
299
300/// A mock HTTP client that returns pre-built SSE bytes from `send_streaming`.
301///
302/// `send` and `send_multipart` always return `NOT_IMPLEMENTED`.
303#[derive(Clone, Debug, Default)]
304pub struct MockStreamingClient {
305    /// Bytes returned as a single streaming response chunk.
306    pub sse_bytes: Bytes,
307}
308
309impl HttpClientExt for MockStreamingClient {
310    fn send<T, U>(
311        &self,
312        _req: Request<T>,
313    ) -> impl Future<Output = http_client::Result<Response<LazyBody<U>>>> + WasmCompatSend + 'static
314    where
315        T: Into<Bytes> + WasmCompatSend,
316        U: From<Bytes> + WasmCompatSend + 'static,
317    {
318        future::ready(Err(http_client::Error::InvalidStatusCode(
319            http::StatusCode::NOT_IMPLEMENTED,
320        )))
321    }
322
323    fn send_multipart<U>(
324        &self,
325        _req: Request<MultipartForm>,
326    ) -> impl Future<Output = http_client::Result<Response<LazyBody<U>>>> + WasmCompatSend + 'static
327    where
328        U: From<Bytes> + WasmCompatSend + 'static,
329    {
330        future::ready(Err(http_client::Error::InvalidStatusCode(
331            http::StatusCode::NOT_IMPLEMENTED,
332        )))
333    }
334
335    fn send_streaming<T>(
336        &self,
337        _req: Request<T>,
338    ) -> impl Future<Output = http_client::Result<StreamingResponse>> + WasmCompatSend
339    where
340        T: Into<Bytes> + WasmCompatSend,
341    {
342        let sse_bytes = self.sse_bytes.clone();
343        async move {
344            let byte_stream =
345                futures::stream::iter(vec![Ok::<Bytes, http_client::Error>(sse_bytes)]);
346            let boxed_stream: http_client::sse::BoxedStream = Box::pin(byte_stream);
347
348            Response::builder()
349                .status(http::StatusCode::OK)
350                .header(http::header::CONTENT_TYPE, "text/event-stream")
351                .body(boxed_stream)
352                .map_err(http_client::Error::Protocol)
353        }
354    }
355}
356
357/// An [`HttpClientExt`] implementation whose `send_streaming` fails immediately
358/// with a non-success HTTP status and response body.
359#[derive(Debug, Clone)]
360pub struct HttpErrorStreamingClient {
361    pub status: http::StatusCode,
362    pub body: String,
363}
364
365impl HttpErrorStreamingClient {
366    /// Create a streaming client that fails `send_streaming` with the given status and body.
367    pub fn new(status: http::StatusCode, body: impl Into<String>) -> Self {
368        Self {
369            status,
370            body: body.into(),
371        }
372    }
373}
374
375impl Default for HttpErrorStreamingClient {
376    /// The completion-model client bound requires `H: Default`; this lets the
377    /// streaming error client back a real model in tests.
378    fn default() -> Self {
379        Self::new(http::StatusCode::INTERNAL_SERVER_ERROR, String::new())
380    }
381}
382
383impl HttpClientExt for HttpErrorStreamingClient {
384    fn send<T, U>(
385        &self,
386        _req: Request<T>,
387    ) -> impl Future<Output = http_client::Result<Response<LazyBody<U>>>> + WasmCompatSend + 'static
388    where
389        T: Into<Bytes> + WasmCompatSend,
390        U: From<Bytes> + WasmCompatSend + 'static,
391    {
392        future::ready(Err(http_client::Error::InvalidStatusCode(
393            http::StatusCode::NOT_IMPLEMENTED,
394        )))
395    }
396
397    fn send_multipart<U>(
398        &self,
399        _req: Request<MultipartForm>,
400    ) -> impl Future<Output = http_client::Result<Response<LazyBody<U>>>> + WasmCompatSend + 'static
401    where
402        U: From<Bytes> + WasmCompatSend + 'static,
403    {
404        future::ready(Err(http_client::Error::InvalidStatusCode(
405            http::StatusCode::NOT_IMPLEMENTED,
406        )))
407    }
408
409    fn send_streaming<T>(
410        &self,
411        _req: Request<T>,
412    ) -> impl Future<Output = http_client::Result<StreamingResponse>> + WasmCompatSend
413    where
414        T: Into<Bytes> + WasmCompatSend,
415    {
416        let status = self.status;
417        let body = self.body.clone();
418        async move {
419            Err(http_client::Error::InvalidStatusCodeWithMessage(
420                status, body,
421            ))
422        }
423    }
424}
425
426/// An [`HttpClientExt`] implementation that returns one scripted stream of byte
427/// chunks from `send_streaming`.
428#[derive(Debug, Clone, Default)]
429pub struct SequencedStreamingHttpClient {
430    chunks: Arc<Mutex<Option<Vec<http_client::Result<Bytes>>>>>,
431}
432
433impl SequencedStreamingHttpClient {
434    /// Create a streaming client from the chunks it should yield.
435    pub fn new(chunks: Vec<http_client::Result<Bytes>>) -> Self {
436        Self {
437            chunks: Arc::new(Mutex::new(Some(chunks))),
438        }
439    }
440}
441
442impl HttpClientExt for SequencedStreamingHttpClient {
443    fn send<T, U>(
444        &self,
445        _req: Request<T>,
446    ) -> impl Future<Output = http_client::Result<Response<LazyBody<U>>>> + WasmCompatSend + 'static
447    where
448        T: Into<Bytes> + WasmCompatSend,
449        U: From<Bytes> + WasmCompatSend + 'static,
450    {
451        future::ready(Err(http_client::Error::InvalidStatusCode(
452            http::StatusCode::NOT_IMPLEMENTED,
453        )))
454    }
455
456    fn send_multipart<U>(
457        &self,
458        _req: Request<MultipartForm>,
459    ) -> impl Future<Output = http_client::Result<Response<LazyBody<U>>>> + WasmCompatSend + 'static
460    where
461        U: From<Bytes> + WasmCompatSend + 'static,
462    {
463        future::ready(Err(http_client::Error::InvalidStatusCode(
464            http::StatusCode::NOT_IMPLEMENTED,
465        )))
466    }
467
468    fn send_streaming<T>(
469        &self,
470        _req: Request<T>,
471    ) -> impl Future<Output = http_client::Result<StreamingResponse>> + WasmCompatSend
472    where
473        T: Into<Bytes> + WasmCompatSend,
474    {
475        let chunks = match self.chunks.lock() {
476            Ok(mut guard) => guard.take(),
477            Err(poisoned) => poisoned.into_inner().take(),
478        };
479
480        async move {
481            let Some(chunks) = chunks else {
482                return Err(http_client::Error::InvalidStatusCodeWithMessage(
483                    http::StatusCode::INTERNAL_SERVER_ERROR,
484                    "streaming chunks should only be consumed once".to_string(),
485                ));
486            };
487
488            let byte_stream = futures::stream::iter(chunks);
489            let boxed_stream: http_client::sse::BoxedStream = Box::pin(byte_stream);
490
491            Response::builder()
492                .status(http::StatusCode::OK)
493                .header(http::header::CONTENT_TYPE, "text/event-stream")
494                .body(boxed_stream)
495                .map_err(http_client::Error::Protocol)
496        }
497    }
498}