Skip to main content

alux_http_conformance/
scenario.rs

1//! What answering the declared surface must produce, whoever answers it.
2//!
3//! The scenario names nothing of the domain and nothing of any framework. It states requests and
4//! what they must be answered with, so anything exposing the surface can be held to it.
5
6use alux_http::{HttpMethod, HttpStatus};
7use alux_http_parts::{DirectRequest, DirectResponse};
8use core::future::Future;
9
10/// Answers a stated request, however the interpretation under test answers one.
11///
12/// An executing interpretation answers with its framework's response; the adapter that states this
13/// is the only place a framework is named.
14pub trait AnswerAlg {
15    /// Answers one request.
16    fn answer(&self, request: DirectRequest) -> impl Future<Output = DirectResponse> + Send;
17}
18
19/// One exchange: a request, and what answering it must produce.
20#[derive(Debug, Clone)]
21pub struct Exchange {
22    /// What this exchange is checking.
23    pub what: &'static str,
24    /// The request a caller makes.
25    pub request: DirectRequest,
26    /// The status the answer must carry.
27    pub status: HttpStatus,
28    /// The body the answer must carry, where the exchange states one.
29    pub body: Option<&'static str>,
30    /// A header the answer must carry, where the exchange states one.
31    pub header: Option<(&'static str, &'static str)>,
32}
33
34impl Exchange {
35    /// States an exchange asking for something, with no body sent.
36    fn asked(what: &'static str, method: HttpMethod, path: &str, status: HttpStatus) -> Self {
37        Self { what, request: DirectRequest::new(method, path), status, body: None, header: None }
38    }
39
40    /// States one header the caller sends.
41    #[must_use]
42    fn sending(mut self, name: &str, value: &str) -> Self {
43        self.request = self.request.with_header(name, value);
44        self
45    }
46
47    /// States an exchange sending something of a stated media type.
48    fn sent(
49        what: &'static str,
50        method: HttpMethod,
51        path: &str,
52        content_type: &str,
53        body: &'static str,
54        status: HttpStatus,
55    ) -> Self {
56        let request = DirectRequest::new(method, path).with_header("content-type", content_type).with_body(body);
57
58        Self { what, request, status, body: None, header: None }
59    }
60
61    /// States the body this exchange must be answered with.
62    #[must_use]
63    fn answering(mut self, body: &'static str) -> Self {
64        self.body = Some(body);
65        self
66    }
67
68    /// States a header this exchange must be answered with.
69    #[must_use]
70    fn carrying(mut self, name: &'static str, value: &'static str) -> Self {
71        self.header = Some((name, value));
72        self
73    }
74
75    /// Returns where an answer disagreed with what this exchange states.
76    fn disagreements(&self, answer: &DirectResponse) -> Vec<String> {
77        let mut found = Vec::new();
78        let what = self.what;
79        if answer.status() != self.status {
80            found.push(format!("{what}: answered {} where {} was stated", answer.status().code(), self.status.code()));
81        }
82        if let Some(body) = self.body
83            && answer.text() != body
84        {
85            found.push(format!("{what}: answered `{}` where `{body}` was stated", answer.text()));
86        }
87        if let Some((name, value)) = self.header
88            && answer.header(name) != Some(value)
89        {
90            found.push(format!(
91                "{what}: answered `{name}: {}` where `{name}: {value}` was stated",
92                answer.header(name).unwrap_or("nothing")
93            ));
94        }
95
96        found
97    }
98}
99
100/// Every exchange the declared surface must satisfy.
101///
102/// Each names one thing the specification states: a method, a path binding, an input role, an
103/// output kind, a declared status, or what a failure means.
104pub fn exchanges() -> Vec<Exchange> {
105    vec![
106        Exchange::asked("a reading as it stands", HttpMethod::Get, "/items", HttpStatus::OK).answering("[7]"),
107        Exchange::asked("a path binding one segment", HttpMethod::Get, "/item/1", HttpStatus::OK).answering("7"),
108        Exchange::asked("what a failure means", HttpMethod::Get, "/item/9", HttpStatus::NOT_FOUND)
109            .answering("no such reading"),
110        Exchange::sent(
111            "a body read as a document, under a declared status",
112            HttpMethod::Post,
113            "/items",
114            "application/json",
115            "7",
116            HttpStatus::new(201),
117        )
118        .answering("7"),
119        Exchange::sent(
120            "a body read as a form",
121            HttpMethod::Put,
122            "/items",
123            "application/x-www-form-urlencoded",
124            "value=7",
125            HttpStatus::OK,
126        )
127        .answering("7"),
128        Exchange::sent(
129            "a body taken as it arrived",
130            HttpMethod::Patch,
131            "/items",
132            "text/plain",
133            "seven",
134            HttpStatus::OK,
135        )
136        .answering("noted seven"),
137        Exchange::asked("an answer with no body", HttpMethod::Delete, "/items", HttpStatus::NO_CONTENT).answering(""),
138        Exchange::asked("a redirect", HttpMethod::Get, "/home", HttpStatus::SEE_OTHER).carrying("location", "/items"),
139        Exchange::asked("a page", HttpMethod::Get, "/page", HttpStatus::OK)
140            .answering("<p>7</p>")
141            .carrying("content-type", "text/html; charset=utf-8"),
142        Exchange::asked("the readings as stored", HttpMethod::Get, "/stored", HttpStatus::OK).answering("seven"),
143        Exchange::asked("what the cookies a caller sent state", HttpMethod::Get, "/session", HttpStatus::OK)
144            .sending("cookie", "session=abc; theme=dark")
145            .answering("known as abc"),
146        // An answer carries what the handler stated beside its body.
147        Exchange::asked("a header an answer carries", HttpMethod::Get, "/cached", HttpStatus::OK)
148            .answering("[7]")
149            .carrying("cache-control", "max-age=60"),
150        // A header name is words the wire spells with `-` and an argument spells with `_`.
151        Exchange::asked("what the headers a caller sent state", HttpMethod::Get, "/agent", HttpStatus::OK)
152            .sending("user-agent", "probe")
153            .answering("sent by probe"),
154    ]
155}
156
157/// Holds one interpretation to every exchange the surface states.
158///
159/// Every disagreement is collected rather than the first one raised, because what is worth knowing
160/// is how two interpretations differ, not that they do.
161///
162/// # Errors
163///
164/// Answers with every disagreement found, in the order the exchanges state them.
165pub async fn expect<Answers>(answers: &Answers) -> Result<(), Vec<String>>
166where
167    Answers: AnswerAlg,
168{
169    let mut found = Vec::new();
170    for exchange in exchanges() {
171        // A body produced over time is read to its end, because what is compared is what a caller
172        // would have received.
173        let answer = answers.answer(exchange.request.clone()).await.collected().await;
174        found.extend(exchange.disagreements(&answer));
175    }
176
177    if found.is_empty() { Ok(()) } else { Err(found) }
178}
179
180/// Every exchange the streamed surface must satisfy.
181pub fn stream_exchanges() -> Vec<Exchange> {
182    vec![
183        Exchange::asked("a body produced a piece at a time", HttpMethod::Get, "/ticks", HttpStatus::OK)
184            .answering("onetwothree"),
185    ]
186}
187
188/// Holds one interpretation to every exchange the streamed surface states.
189///
190/// # Errors
191///
192/// Answers with every disagreement found, in the order the exchanges state them.
193pub async fn expect_streaming<Answers>(answers: &Answers) -> Result<(), Vec<String>>
194where
195    Answers: AnswerAlg,
196{
197    let mut found = Vec::new();
198    for exchange in stream_exchanges() {
199        let answer = answers.answer(exchange.request.clone()).await.collected().await;
200        found.extend(exchange.disagreements(&answer));
201    }
202
203    if found.is_empty() { Ok(()) } else { Err(found) }
204}
205
206/// The body a caller sends as parts, and the boundary it states between them.
207const BOUNDARY: &str = "alux";
208const PARTS: &str = "--alux\r\nContent-Disposition: form-data; name=\"one\"\r\n\r\n1\r\n\
209--alux\r\nContent-Disposition: form-data; name=\"two\"\r\n\r\n2\r\n--alux--\r\n";
210
211/// Every exchange the parts surface must satisfy.
212pub fn multipart_exchanges() -> Vec<Exchange> {
213    vec![
214        Exchange::sent(
215            "a body arriving as parts",
216            HttpMethod::Post,
217            "/upload",
218            &format!("multipart/form-data; boundary={BOUNDARY}"),
219            PARTS,
220            HttpStatus::OK,
221        )
222        .answering("one=1,two=2"),
223    ]
224}
225
226/// Holds one interpretation to every exchange the parts surface states.
227///
228/// # Errors
229///
230/// Answers with every disagreement found, in the order the exchanges state them.
231pub async fn expect_multipart<Answers>(answers: &Answers) -> Result<(), Vec<String>>
232where
233    Answers: AnswerAlg,
234{
235    let mut found = Vec::new();
236    for exchange in multipart_exchanges() {
237        let answer = answers.answer(exchange.request.clone()).await.collected().await;
238        found.extend(exchange.disagreements(&answer));
239    }
240
241    if found.is_empty() { Ok(()) } else { Err(found) }
242}