Skip to main content

cratefield_testing/
request.rs

1//! The no-network request helper (issue #9): `tower::ServiceExt::oneshot`
2//! straight into the router.
3
4use axum::body::Body;
5use axum::http::{HeaderValue, Method, Request, StatusCode, header};
6use axum::response::Response;
7use bytes::Bytes;
8use futures_core::Stream;
9use serde_json::Value;
10use std::pin::Pin;
11use std::task::{Context, Poll};
12
13/// The same, carrying `Authorization: Bearer <token>`.
14///
15/// Every venture with a table whose access is not `public-read` needs
16/// this, and so does every admin-guarded route — and the kit did not have
17/// it, so three crates in this workspace each hand-rolled the same
18/// builder over `tower::ServiceExt`. A venture cannot: `tower` is not one
19/// of its dependencies, and telling an author to add one to test the
20/// routes the harness generated is telling them to work around the kit.
21///
22/// # Panics
23///
24/// Panics when the router itself fails (never for ordinary responses).
25pub async fn request_as(
26    router: &axum::Router,
27    method: Method,
28    path: &str,
29    bearer: &str,
30    json: Option<&str>,
31) -> TestResponse {
32    use tower::ServiceExt;
33    let mut builder = Request::builder()
34        .method(method)
35        .uri(path)
36        .header(header::AUTHORIZATION, format!("Bearer {bearer}"));
37    let body = match json {
38        Some(payload) => {
39            builder = builder.header(
40                header::CONTENT_TYPE,
41                HeaderValue::from_static("application/json"),
42            );
43            Body::from(payload.to_owned())
44        }
45        None => Body::empty(),
46    };
47    let request = builder.body(body).expect("request builds");
48    let response = router
49        .clone()
50        .oneshot(request)
51        .await
52        .expect("router answers");
53    TestResponse::from(response).await
54}
55
56/// Sends a request through the router without a network. `json` (when
57/// `Some`) becomes a JSON body with `content-type: application/json`.
58///
59/// # Panics
60///
61/// Panics when the router itself fails (never for ordinary responses).
62pub async fn request(
63    router: &axum::Router,
64    method: Method,
65    path: &str,
66    json: Option<&str>,
67) -> TestResponse {
68    use tower::ServiceExt;
69    let mut builder = Request::builder().method(method).uri(path);
70    let body = match json {
71        Some(payload) => {
72            builder = builder.header(
73                header::CONTENT_TYPE,
74                HeaderValue::from_static("application/json"),
75            );
76            Body::from(payload.to_owned())
77        }
78        None => Body::empty(),
79    };
80    let request = builder.body(body).expect("request builds");
81    let response = router
82        .clone()
83        .oneshot(request)
84        .await
85        .expect("router answers");
86    TestResponse::from(response).await
87}
88
89/// Sends a request whose body is the given chunks, as a chunked
90/// (no `content-length`) transfer — what a client uploading a stream
91/// produces, and the only shape that exercises a route's
92/// [`cratefield_core::RequestStream`] mid-stream ceiling rather than the
93/// declared-length pre-check. `content-type` is set to
94/// `application/octet-stream`.
95///
96/// # Panics
97///
98/// Panics when the router itself fails (never for ordinary responses).
99pub async fn request_chunks(
100    router: &axum::Router,
101    method: Method,
102    path: &str,
103    chunks: Vec<Bytes>,
104) -> TestResponse {
105    use tower::ServiceExt;
106    let body = Body::from_stream(Chunks {
107        items: chunks.into_iter(),
108    });
109    let request = Request::builder()
110        .method(method)
111        .uri(path)
112        .header(
113            header::CONTENT_TYPE,
114            HeaderValue::from_static("application/octet-stream"),
115        )
116        .body(body)
117        .expect("request builds");
118    let response = router
119        .clone()
120        .oneshot(request)
121        .await
122        .expect("router answers");
123    TestResponse::from(response).await
124}
125
126/// A chunked request body: a `Vec<Bytes>` drained one chunk per poll. Written
127/// out because the kit depends on `futures-core` only, not `futures-util`.
128struct Chunks {
129    items: std::vec::IntoIter<Bytes>,
130}
131
132impl Stream for Chunks {
133    type Item = Result<Bytes, std::convert::Infallible>;
134
135    fn poll_next(self: Pin<&mut Self>, _cx: &mut Context<'_>) -> Poll<Option<Self::Item>> {
136        Poll::Ready(self.get_mut().items.next().map(Ok))
137    }
138}
139
140/// A fully-buffered test response.
141pub struct TestResponse {
142    pub status: StatusCode,
143    pub headers: axum::http::HeaderMap,
144    body: Bytes,
145}
146
147impl TestResponse {
148    /// Reads a response the caller drove itself (the conformance kit's
149    /// parity probes build their own requests).
150    pub(crate) async fn of(response: Response) -> Self {
151        Self::from(response).await
152    }
153
154    async fn from(response: Response) -> Self {
155        let (parts, body) = response.into_parts();
156        let body = axum::body::to_bytes(body, 1024 * 1024)
157            .await
158            .expect("test body reads");
159        Self {
160            status: parts.status,
161            headers: parts.headers,
162            body,
163        }
164    }
165
166    /// The body parsed as JSON.
167    ///
168    /// # Panics
169    ///
170    /// Panics when the body is not valid JSON.
171    #[must_use]
172    pub fn json(&self) -> Value {
173        serde_json::from_slice(&self.body).expect("body is JSON")
174    }
175
176    /// The raw body.
177    #[must_use]
178    pub fn body(&self) -> &Bytes {
179        &self.body
180    }
181}