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, Effect, Location, Operation};
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    #[error("{op}: `{name}`: {source}")]
29    BadValue {
30        op: String,
31        name: String,
32        #[source]
33        source: ScalarError,
34    },
35    #[error("{op}: a request body is required")]
36    MissingBody { op: String },
37    #[error("{op}: takes no request body")]
38    UnexpectedBody { op: String },
39    #[error("{op}: expects a {expected} body")]
40    WrongBodyKind { op: String, expected: String },
41}
42
43/// An operation with values that satisfy it.
44#[derive(Debug, Clone)]
45pub struct Invocation<'a> {
46    op: &'a Operation,
47    values: Values,
48}
49
50impl<'a> Invocation<'a> {
51    /// Check `values` against `op`.
52    pub fn new(op: &'a Operation, values: Values) -> Result<Self, ValueError> {
53        let name = || op.id().to_owned();
54        for (wire, raw) in values.params() {
55            let param = op.param(wire).ok_or_else(|| ValueError::UnknownParam {
56                op: name(),
57                name: wire.clone(),
58            })?;
59            param
60                .scalar()
61                .parse(raw)
62                .map_err(|source| ValueError::BadValue {
63                    op: name(),
64                    name: wire.clone(),
65                    source,
66                })?;
67        }
68        for param in op.params() {
69            let given = values.params().iter().any(|(wire, _)| wire == param.name());
70            if param.required() && !given {
71                return Err(ValueError::MissingParam {
72                    op: name(),
73                    name: param.name().to_owned(),
74                });
75            }
76        }
77        check_body(op, values.payload())?;
78        Ok(Self { op, values })
79    }
80
81    #[must_use]
82    pub fn operation(&self) -> &'a Operation {
83        self.op
84    }
85
86    /// `Write` means a CLI must see `--commit` before this is sent.
87    #[must_use]
88    pub fn effect(&self) -> Effect {
89        self.op.effect()
90    }
91
92    /// The request this invocation stands for, against `base`.
93    pub fn request(&self, base: &Uri) -> Result<Request<Vec<u8>>, http::Error> {
94        let mut builder = Request::builder()
95            .method(self.op.method().clone())
96            .uri(self.url(base));
97        for (name, value) in self.located(Location::Header) {
98            builder = builder.header(name, value);
99        }
100        match self.body() {
101            None => builder.body(Vec::new()),
102            Some((content_type, bytes)) => builder
103                .header(header::CONTENT_TYPE, content_type)
104                .body(bytes),
105        }
106    }
107
108    /// The `Content-Type` and the bytes, or `None` for a body-less request.
109    fn body(&self) -> Option<(String, Vec<u8>)> {
110        match self.values.payload()? {
111            Payload::Json(value) => Some((
112                "application/json".to_owned(),
113                value.to_string().into_bytes(),
114            )),
115            Payload::Raw(bytes) => {
116                // The media type is the document's, never the caller's.
117                let media_type = match self.op.body() {
118                    Body::Opaque { media_type, .. } => media_type.clone(),
119                    Body::None
120                    | Body::JsonFields(_)
121                    | Body::JsonWhole { .. }
122                    | Body::Multipart { .. } => "application/octet-stream".to_owned(),
123                };
124                Some((media_type, bytes.clone()))
125            }
126            Payload::Multipart(parts) => {
127                let encoded = multipart::encode(parts);
128                Some((encoded.content_type, encoded.bytes))
129            }
130        }
131    }
132
133    fn url(&self, base: &Uri) -> String {
134        let mut url = String::new();
135        if let Some(scheme) = base.scheme_str() {
136            url.push_str(scheme);
137            url.push_str("://");
138        }
139        if let Some(authority) = base.authority() {
140            url.push_str(authority.as_str());
141        }
142        url.push_str(base.path().trim_end_matches('/'));
143
144        let mut path = self.op.path().to_owned();
145        for (name, value) in self.located(Location::Path) {
146            path = path.replace(&format!("{{{name}}}"), &encode(value));
147        }
148        url.push_str(&path);
149
150        let query: Vec<String> = self
151            .located(Location::Query)
152            .map(|(name, value)| format!("{}={}", encode(name), encode(value)))
153            .collect();
154        if !query.is_empty() {
155            url.push('?');
156            url.push_str(&query.join("&"));
157        }
158        url
159    }
160
161    fn located(&self, location: Location) -> impl Iterator<Item = (&str, &str)> {
162        self.values
163            .params()
164            .iter()
165            .filter_map(move |(name, value)| {
166                let param = self.op.param(name)?;
167                (param.location() == location).then_some((name.as_str(), value.as_str()))
168            })
169    }
170}
171
172/// Does the body the caller brought match the body the operation asks for?
173fn check_body(op: &Operation, body: Option<&Payload>) -> Result<(), ValueError> {
174    let name = || op.id().to_owned();
175    let wrong = |expected: &str| {
176        Err(ValueError::WrongBodyKind {
177            op: name(),
178            expected: expected.to_owned(),
179        })
180    };
181    match (op.body(), body) {
182        (
183            Body::None
184            | Body::JsonFields(_)
185            | Body::JsonWhole { required: false }
186            | Body::Multipart {
187                required: false, ..
188            }
189            | Body::Opaque {
190                required: false, ..
191            },
192            None,
193        )
194        | (Body::JsonFields(_) | Body::JsonWhole { .. }, Some(Payload::Json(_)))
195        | (Body::Multipart { .. }, Some(Payload::Multipart(_)))
196        | (Body::Opaque { .. }, Some(Payload::Raw(_))) => Ok(()),
197        (Body::None, Some(_)) => Err(ValueError::UnexpectedBody { op: name() }),
198        (
199            Body::JsonWhole { required: true }
200            | Body::Multipart { required: true, .. }
201            | Body::Opaque { required: true, .. },
202            None,
203        ) => Err(ValueError::MissingBody { op: name() }),
204        (Body::JsonFields(_) | Body::JsonWhole { .. }, Some(_)) => wrong("JSON"),
205        (Body::Multipart { .. }, Some(_)) => wrong("multipart/form-data"),
206        (Body::Opaque { media_type, .. }, Some(_)) => wrong(media_type),
207    }
208}
209
210/// Percent-encode everything outside RFC 3986's unreserved set. Both path
211/// segments and query values are safe under that rule; nothing this crate puts
212/// in a URL is meant as a delimiter.
213fn encode(raw: &str) -> String {
214    use std::fmt::Write as _;
215
216    let mut out = String::with_capacity(raw.len());
217    for byte in raw.bytes() {
218        if byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'.' | b'_' | b'~') {
219            out.push(char::from(byte));
220        } else {
221            let _ = write!(out, "%{byte:02X}");
222        }
223    }
224    out
225}
226
227/// The request as it goes on the wire, for a dry run.
228///
229/// A binary body is summarised rather than printed: an agent reading a dry run
230/// needs the headers and the length, not the bytes of a PDF.
231#[must_use]
232pub fn render(request: &Request<Vec<u8>>) -> String {
233    use std::fmt::Write as _;
234
235    let mut out = String::new();
236    let _ = writeln!(
237        out,
238        "{} {} HTTP/1.1",
239        request.method(),
240        request
241            .uri()
242            .path_and_query()
243            .map_or("/", http::uri::PathAndQuery::as_str)
244    );
245    if let Some(authority) = request.uri().authority() {
246        let _ = writeln!(out, "host: {authority}");
247    }
248    for (name, value) in request.headers() {
249        let _ = writeln!(out, "{name}: {}", value.to_str().unwrap_or("<non-utf8>"));
250    }
251    if !request.body().is_empty() {
252        out.push('\n');
253        match std::str::from_utf8(request.body()) {
254            Ok(text) => {
255                out.push_str(text);
256                if !text.ends_with('\n') {
257                    out.push('\n');
258                }
259            }
260            Err(_) => {
261                let _ = writeln!(out, "<{} bytes>", request.body().len());
262            }
263        }
264    }
265    out
266}
267
268#[cfg(test)]
269#[expect(
270    clippy::expect_used,
271    reason = "a test that cannot build its fixture should fail loudly and name it"
272)]
273mod tests {
274    use super::*;
275
276    #[test]
277    fn encoding_leaves_the_unreserved_set_alone_and_escapes_the_rest() {
278        assert_eq!(encode("abc-123_x.y~z"), "abc-123_x.y~z");
279        assert_eq!(encode("a b/c?d&e=f"), "a%20b%2Fc%3Fd%26e%3Df");
280        assert_eq!(encode("Grüß"), "Gr%C3%BC%C3%9F");
281    }
282
283    #[test]
284    fn a_binary_body_is_summarised_rather_than_printed() {
285        let request = Request::builder()
286            .uri("http://x/y")
287            .body(vec![0xFF, 0xFE])
288            .expect("a request with a two-byte body");
289        assert!(
290            render(&request).ends_with("<2 bytes>\n"),
291            "{}",
292            render(&request)
293        );
294    }
295}