Skip to main content

typed_openapi/
request.rs

1//! One operation plus values that satisfy it, and the request that falls out.
2//!
3//! [`Invocation::new`] is the only way to make one, and it validates: an
4//! `Invocation` that exists names an operation, carries every required
5//! parameter, carries nothing the operation does not declare, and holds a body
6//! of the kind the operation asks for. [`Invocation::request`] is then a
7//! rendering, not a decision — it can only fail on a base URL that is not a URL.
8//!
9//! This is the only request builder in the workspace. A CLI reaches it through
10//! the `tree` module (feature `clap`); a generated Rust wrapper reaches it by building a
11//! [`Values`] directly. Neither spells a path template twice.
12
13use http::{Request, Uri, header};
14use thiserror::Error;
15
16use crate::model::{Body, Join, Location, Operation, Param, Shape, Unsupported};
17use crate::multipart;
18use crate::scalar::ScalarError;
19use crate::values::{Payload, Values};
20
21/// Values the document rejects for this operation.
22#[derive(Debug, Clone, Error, PartialEq, Eq)]
23pub enum ValueError {
24    #[error("{op}: `{name}` is required")]
25    MissingParam { op: String, name: String },
26    #[error("{op}: there is no `{name}` parameter")]
27    UnknownParam { op: String, name: String },
28    /// A value for a parameter no flag and no wrapper argument can carry.
29    /// Dropping it silently would send a request the caller did not ask for.
30    #[error("{op}: `{name}` is {why}, so there is nowhere in the request to put a value for it")]
31    UnsupportedParam {
32        op: String,
33        name: String,
34        why: Unsupported,
35    },
36    /// Several values for a parameter the document declares one value for. The
37    /// list the caller meant is not a list the document describes.
38    #[error("{op}: `{name}` takes one value, and was given {given}")]
39    RepeatedParam {
40        op: String,
41        name: String,
42        given: usize,
43    },
44    #[error("{op}: `{name}`: {source}")]
45    BadValue {
46        op: String,
47        name: String,
48        #[source]
49        source: ScalarError,
50    },
51    #[error("{op}: a request body is required")]
52    MissingBody { op: String },
53    #[error("{op}: takes no request body")]
54    UnexpectedBody { op: String },
55    #[error("{op}: expects a {expected} body")]
56    WrongBodyKind { op: String, expected: String },
57}
58
59/// An operation with values that satisfy it.
60#[derive(Debug, Clone)]
61pub struct Invocation<'a> {
62    op: &'a Operation,
63    values: Values,
64}
65
66impl<'a> Invocation<'a> {
67    /// Check `values` against `op`.
68    pub fn new(op: &'a Operation, values: Values) -> Result<Self, ValueError> {
69        let name = || op.id().to_owned();
70        for (wire, raw) in values.params() {
71            let param = op.param(wire).ok_or_else(|| ValueError::UnknownParam {
72                op: name(),
73                name: wire.clone(),
74            })?;
75            match param.shape() {
76                Shape::Flag { scalar, .. } => {
77                    scalar.parse(raw).map_err(|source| ValueError::BadValue {
78                        op: name(),
79                        name: wire.clone(),
80                        source,
81                    })?;
82                }
83                Shape::Unreachable(why) => {
84                    return Err(ValueError::UnsupportedParam {
85                        op: name(),
86                        name: wire.clone(),
87                        why: why.clone(),
88                    });
89                }
90            }
91        }
92        for param in op.params() {
93            let given = values
94                .params()
95                .iter()
96                .filter(|(wire, _)| wire == param.name())
97                .count();
98            if param.required() && given == 0 {
99                return Err(ValueError::MissingParam {
100                    op: name(),
101                    name: param.name().to_owned(),
102                });
103            }
104            if given > 1 && !param.shape().repeatable() {
105                return Err(ValueError::RepeatedParam {
106                    op: name(),
107                    name: param.name().to_owned(),
108                    given,
109                });
110            }
111        }
112        check_body(op, values.payload())?;
113        Ok(Self { op, values })
114    }
115
116    #[must_use]
117    pub fn operation(&self) -> &'a Operation {
118        self.op
119    }
120
121    /// The request this invocation stands for, against `base`.
122    pub fn request(&self, base: &Uri) -> Result<Request<Vec<u8>>, http::Error> {
123        let mut builder = Request::builder()
124            .method(self.op.method().clone())
125            .uri(self.url(base));
126        for sent in self.placed(Location::Header) {
127            // A header's style is `simple`, which comma-separates a list. The
128            // values are not percent-encoded on the way in: a header is not a
129            // URL, and nothing here is a delimiter in it but the comma.
130            builder = builder.header(sent.name, sent.values.join(","));
131        }
132        match self.body() {
133            None => builder.body(Vec::new()),
134            Some((content_type, bytes)) => builder
135                .header(header::CONTENT_TYPE, content_type)
136                .body(bytes),
137        }
138    }
139
140    /// The `Content-Type` and the bytes, or `None` for a body-less request.
141    fn body(&self) -> Option<(String, Vec<u8>)> {
142        match self.values.payload()? {
143            Payload::Json(value) => Some((
144                "application/json".to_owned(),
145                value.to_string().into_bytes(),
146            )),
147            Payload::Raw(bytes) => {
148                // The media type is the document's, never the caller's.
149                let media_type = match self.op.body() {
150                    Body::Opaque { media_type, .. } => media_type.clone(),
151                    Body::None
152                    | Body::JsonFields(_)
153                    | Body::JsonWhole { .. }
154                    | Body::Multipart { .. } => "application/octet-stream".to_owned(),
155                };
156                Some((media_type, bytes.clone()))
157            }
158            Payload::Multipart(parts) => {
159                let encoded = multipart::encode(parts);
160                Some((encoded.content_type, encoded.bytes))
161            }
162        }
163    }
164
165    fn url(&self, base: &Uri) -> String {
166        let mut url = String::new();
167        if let Some(scheme) = base.scheme_str() {
168            url.push_str(scheme);
169            url.push_str("://");
170        }
171        if let Some(authority) = base.authority() {
172            url.push_str(authority.as_str());
173        }
174        url.push_str(base.path().trim_end_matches('/'));
175
176        let mut path = self.op.path().to_owned();
177        for sent in self.placed(Location::Path) {
178            // A path parameter's style is `simple`, which comma-separates a list
179            // however it explodes, so there is one rendering here and no branch.
180            let placeholder = format!("{{{}}}", sent.name);
181            path = path.replace(&placeholder, &commas(&sent.values));
182        }
183        url.push_str(&path);
184
185        let query = self.query();
186        if !query.is_empty() {
187            url.push('?');
188            url.push_str(&query.join("&"));
189        }
190        url
191    }
192
193    /// The query string's fields, in the order the caller first named each
194    /// parameter.
195    ///
196    /// A repeated flag is one parameter holding several values, so the values
197    /// are grouped before they are rendered: `?embed=a&embed=b` and `?embed=a,b`
198    /// are one list spelled two ways, and which one it is, is the document's to
199    /// say.
200    fn query(&self) -> Vec<String> {
201        let mut fields: Vec<String> = Vec::new();
202        for sent in self.placed(Location::Query) {
203            let name = encode(sent.name);
204            match sent.join {
205                Some(Join::Pairs) => fields.extend(
206                    sent.values
207                        .iter()
208                        .map(|value| format!("{name}={}", encode(value))),
209                ),
210                Some(Join::Commas) | None => {
211                    fields.push(format!("{name}={}", commas(&sent.values)));
212                }
213            }
214        }
215        fields
216    }
217
218    /// Every value the caller gave for a parameter that goes in `location`,
219    /// grouped under the parameter it belongs to, with the join the document
220    /// declared for it.
221    ///
222    /// A parameter this CLI cannot supply never reaches here — `Invocation::new`
223    /// refuses a value for one — so grouping is over the parameters that have a
224    /// place in the request and nothing else.
225    fn placed(&self, location: Location) -> Vec<Sent<'_>> {
226        let mut out: Vec<Sent<'_>> = Vec::new();
227        for (name, value) in self.values.params() {
228            let Some(Shape::Flag {
229                location: at, join, ..
230            }) = self.op.param(name).map(Param::shape)
231            else {
232                continue;
233            };
234            if *at != location {
235                continue;
236            }
237            match out.iter_mut().find(|sent| sent.name == name.as_str()) {
238                Some(sent) => sent.values.push(value),
239                None => out.push(Sent {
240                    name,
241                    join: *join,
242                    values: vec![value],
243                }),
244            }
245        }
246        out
247    }
248}
249
250/// One parameter on its way into the request: the wire name, how the document
251/// joins repeats of it, and the values in the order the caller gave them.
252struct Sent<'v> {
253    name: &'v str,
254    join: Option<Join>,
255    values: Vec<&'v str>,
256}
257
258/// Does the body the caller brought match the body the operation asks for?
259fn check_body(op: &Operation, body: Option<&Payload>) -> Result<(), ValueError> {
260    let name = || op.id().to_owned();
261    let wrong = |expected: &str| {
262        Err(ValueError::WrongBodyKind {
263            op: name(),
264            expected: expected.to_owned(),
265        })
266    };
267    match (op.body(), body) {
268        (
269            Body::None
270            | Body::JsonFields(_)
271            | Body::JsonWhole { required: false }
272            | Body::Multipart {
273                required: false, ..
274            }
275            | Body::Opaque {
276                required: false, ..
277            },
278            None,
279        )
280        | (Body::JsonFields(_) | Body::JsonWhole { .. }, Some(Payload::Json(_)))
281        | (Body::Multipart { .. }, Some(Payload::Multipart(_)))
282        | (Body::Opaque { .. }, Some(Payload::Raw(_))) => Ok(()),
283        (Body::None, Some(_)) => Err(ValueError::UnexpectedBody { op: name() }),
284        (
285            Body::JsonWhole { required: true }
286            | Body::Multipart { required: true, .. }
287            | Body::Opaque { required: true, .. },
288            None,
289        ) => Err(ValueError::MissingBody { op: name() }),
290        (Body::JsonFields(_) | Body::JsonWhole { .. }, Some(_)) => wrong("JSON"),
291        (Body::Multipart { .. }, Some(_)) => wrong("multipart/form-data"),
292        (Body::Opaque { media_type, .. }, Some(_)) => wrong(media_type),
293    }
294}
295
296/// One parameter's values as a single field: each percent-encoded, joined by
297/// commas.
298///
299/// This is what RFC 6570's `simple` gives a list, and what OpenAPI's `form` with
300/// `explode: false` gives one. Encoding runs first, so a comma *inside* a value
301/// is `%2C` and the comma *between* two values is the delimiter the document
302/// asked for — whoever reads the request can tell them apart.
303fn commas(values: &[&str]) -> String {
304    values
305        .iter()
306        .copied()
307        .map(encode)
308        .collect::<Vec<_>>()
309        .join(",")
310}
311
312/// Percent-encode everything outside RFC 3986's unreserved set. Both path
313/// segments and query values are safe under that rule; nothing this crate puts
314/// in a URL is meant as a delimiter.
315fn encode(raw: &str) -> String {
316    use std::fmt::Write as _;
317
318    let mut out = String::with_capacity(raw.len());
319    for byte in raw.bytes() {
320        if byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'.' | b'_' | b'~') {
321            out.push(char::from(byte));
322        } else {
323            let _ = write!(out, "%{byte:02X}");
324        }
325    }
326    out
327}
328
329/// The request as it goes on the wire, for a dry run.
330///
331/// A binary body is summarised rather than printed: an agent reading a dry run
332/// needs the headers and the length, not the bytes of a PDF.
333#[must_use]
334pub fn render(request: &Request<Vec<u8>>) -> String {
335    use std::fmt::Write as _;
336
337    let mut out = String::new();
338    let _ = writeln!(
339        out,
340        "{} {} HTTP/1.1",
341        request.method(),
342        request
343            .uri()
344            .path_and_query()
345            .map_or("/", http::uri::PathAndQuery::as_str)
346    );
347    if let Some(authority) = request.uri().authority() {
348        let _ = writeln!(out, "host: {authority}");
349    }
350    for (name, value) in request.headers() {
351        let _ = writeln!(out, "{name}: {}", value.to_str().unwrap_or("<non-utf8>"));
352    }
353    if !request.body().is_empty() {
354        out.push('\n');
355        match std::str::from_utf8(request.body()) {
356            Ok(text) => {
357                out.push_str(text);
358                if !text.ends_with('\n') {
359                    out.push('\n');
360                }
361            }
362            Err(_) => {
363                let _ = writeln!(out, "<{} bytes>", request.body().len());
364            }
365        }
366    }
367    out
368}
369
370#[cfg(test)]
371#[expect(
372    clippy::expect_used,
373    reason = "a test that cannot build its fixture should fail loudly and name it"
374)]
375mod tests {
376    use super::*;
377
378    #[test]
379    fn encoding_leaves_the_unreserved_set_alone_and_escapes_the_rest() {
380        assert_eq!(encode("abc-123_x.y~z"), "abc-123_x.y~z");
381        assert_eq!(encode("a b/c?d&e=f"), "a%20b%2Fc%3Fd%26e%3Df");
382        assert_eq!(encode("Grüß"), "Gr%C3%BC%C3%9F");
383    }
384
385    #[test]
386    fn a_binary_body_is_summarised_rather_than_printed() {
387        let request = Request::builder()
388            .uri("http://x/y")
389            .body(vec![0xFF, 0xFE])
390            .expect("a request with a two-byte body");
391        assert!(
392            render(&request).ends_with("<2 bytes>\n"),
393            "{}",
394            render(&request)
395        );
396    }
397}