Skip to main content

toolkit_contract/runtime/
http.rs

1//! HTTP helpers shared by the generated REST client codegen.
2//!
3//! These are intentionally low-level and provider-agnostic so that the macro
4//! output stays small and the helpers can be unit-tested in isolation.
5
6use bytes::Bytes;
7use futures_core::Stream;
8use http_body::Body;
9use http_body_util::BodyStream;
10use percent_encoding::{AsciiSet, CONTROLS, utf8_percent_encode};
11use toolkit_canonical_errors::Problem;
12
13use crate::ir::binding::{HttpFieldBinding, HttpMethod, HttpMethodBindingIr};
14use crate::runtime::transport_error::TransportError;
15
16// RFC 3986 path-segment encode set: encode everything except unreserved
17// characters (`A-Z a-z 0-9 - . _ ~`).
18const PATH_SEGMENT: &AsciiSet = &CONTROLS
19    .add(b' ')
20    .add(b'"')
21    .add(b'#')
22    .add(b'%')
23    .add(b'&')
24    .add(b'\'')
25    .add(b'(')
26    .add(b')')
27    .add(b'*')
28    .add(b'+')
29    .add(b',')
30    .add(b'/')
31    .add(b':')
32    .add(b';')
33    .add(b'<')
34    .add(b'=')
35    .add(b'>')
36    .add(b'?')
37    .add(b'@')
38    .add(b'[')
39    .add(b'\\')
40    .add(b']')
41    .add(b'^')
42    .add(b'`')
43    .add(b'{')
44    .add(b'|')
45    .add(b'}');
46
47/// Adapt any `http_body::Body` into a `Stream<Item = Result<Bytes, E>>` of
48/// data frames, dropping trailers.
49///
50/// `toolkit_http::HttpResponse::into_body()` returns a `ResponseBody` that
51/// implements [`http_body::Body`] but the SSE parser
52/// ([`crate::runtime::sse::parse_sse_stream_with_id`]) expects a flat
53/// `Stream` of byte chunks. SSE has no use for trailers, so non-data frames
54/// are simply skipped.
55pub fn body_to_byte_stream<B>(body: B) -> impl Stream<Item = Result<Bytes, B::Error>> + Send
56where
57    B: Body<Data = Bytes> + Send + 'static,
58    B::Error: Send + 'static,
59{
60    use futures_util::StreamExt as _;
61    BodyStream::new(body).filter_map(|frame_res| async move {
62        match frame_res {
63            Ok(frame) => frame.into_data().ok().map(Ok),
64            Err(e) => Some(Err(e)),
65        }
66    })
67}
68
69/// Build a fully-qualified URL by substituting path parameters and appending a
70/// pre-encoded query string, returning [`TransportError`] on failure.
71///
72/// `fields` is expected to be a JSON object whose keys correspond to the
73/// `field` names referenced by `method_binding.field_bindings`. Missing path
74/// parameters yield [`TransportError::UrlBuild`].
75///
76/// `query` is the already-encoded query string (no leading `?`), produced by
77/// [`crate::query::to_query_string`]. It arrives pre-encoded rather than as
78/// structured data on purpose: the server decodes it with the same
79/// `serde_html_form` codec, and routing it through this function's own
80/// serializer is what previously let the two ends disagree on `Vec` and nested
81/// shapes.
82///
83/// # Errors
84/// Returns [`TransportError::UrlBuild`] when a required path parameter is missing,
85/// null, or empty, or when a referenced field is not convertible to a string.
86pub fn build_request_url(
87    base_url: &str,
88    base_path: &str,
89    method_binding: &HttpMethodBindingIr,
90    fields: &serde_json::Value,
91    query: Option<&str>,
92) -> Result<String, TransportError> {
93    let mut path = method_binding.path_template.clone();
94
95    for binding in &method_binding.field_bindings {
96        match binding {
97            HttpFieldBinding::Path { field, param } => {
98                let value = field_as_string(fields, field)?.ok_or_else(|| {
99                    TransportError::UrlBuild(format!(
100                        "required path parameter '{field}' is missing or null"
101                    ))
102                })?;
103                if value.is_empty() {
104                    return Err(TransportError::UrlBuild(format!(
105                        "required path parameter '{field}' is empty"
106                    )));
107                }
108                let encoded = utf8_percent_encode(&value, PATH_SEGMENT).to_string();
109                path = path.replace(&format!("{{{param}}}"), &encoded);
110            }
111            // Query values are encoded by the caller; the binding entry stays in
112            // the IR for validation and spec generation.
113            HttpFieldBinding::Query { .. } | HttpFieldBinding::Body => {}
114        }
115    }
116
117    let base = base_url.trim_end_matches('/');
118    let base_p = base_path.trim_end_matches('/');
119    let mut url = format!("{base}{base_p}{path}");
120
121    if let Some(q) = query.filter(|q| !q.is_empty()) {
122        url.push('?');
123        url.push_str(q);
124    }
125
126    Ok(url)
127}
128
129/// Map an HTTP method enum to [`http::Method`].
130#[must_use]
131pub fn to_http_method(method: HttpMethod) -> http::Method {
132    match method {
133        HttpMethod::Get => http::Method::GET,
134        HttpMethod::Post => http::Method::POST,
135        HttpMethod::Put => http::Method::PUT,
136        HttpMethod::Patch => http::Method::PATCH,
137        HttpMethod::Delete => http::Method::DELETE,
138    }
139}
140
141/// Map a non-success HTTP response into a [`TransportError`].
142///
143/// Tries to parse the body as an RFC 9457 [`Problem`] envelope first,
144/// falling back to [`TransportError::HttpStatus`] with a truncated body
145/// excerpt for peers that don't speak the canonical-errors envelope.
146///
147/// `retry_after` is the parsed `Retry-After` header (see [`parse_retry_after`]);
148/// it is attached to both the [`TransportError::Problem`] and the
149/// [`TransportError::HttpStatus`] fallback so the retry loop honors a
150/// server-advised backoff regardless of whether the peer speaks canonical
151/// errors.
152#[must_use]
153pub fn map_http_error(
154    status: u16,
155    body: String,
156    retry_after: Option<std::time::Duration>,
157) -> TransportError {
158    if let Ok(problem) = serde_json::from_str::<Problem>(&body) {
159        return TransportError::Problem {
160            problem: Box::new(problem),
161            retry_after,
162        };
163    }
164    TransportError::HttpStatus {
165        status,
166        body: truncate(body, 256),
167        retry_after,
168    }
169}
170
171/// Parse a `Retry-After` response header as a delta-seconds [`Duration`].
172///
173/// Only the numeric delta-seconds form is supported (the common case for
174/// `429`/`503`); the HTTP-date form is ignored (returns `None`), as is a
175/// missing or malformed header.
176#[must_use]
177pub fn parse_retry_after(headers: &http::HeaderMap) -> Option<std::time::Duration> {
178    let raw = headers.get(http::header::RETRY_AFTER)?;
179    let secs: u64 = raw.to_str().ok()?.trim().parse().ok()?;
180    Some(std::time::Duration::from_secs(secs))
181}
182
183fn field_as_string(
184    fields: &serde_json::Value,
185    field_name: &str,
186) -> Result<Option<String>, TransportError> {
187    let Some(value) = fields.get(field_name) else {
188        return Ok(None);
189    };
190    match value {
191        serde_json::Value::String(s) => Ok(Some(s.clone())),
192        serde_json::Value::Number(n) => Ok(Some(n.to_string())),
193        serde_json::Value::Bool(b) => Ok(Some(b.to_string())),
194        serde_json::Value::Null => Ok(None),
195        _ => Err(TransportError::UrlBuild(format!(
196            "field '{field_name}' has non-scalar type and cannot be embedded into the URL"
197        ))),
198    }
199}
200
201fn truncate(mut s: String, max: usize) -> String {
202    if s.len() > max {
203        // `s` is a peer-controlled response body (arbitrary UTF-8); truncating
204        // at a raw byte offset panics if `max` lands inside a multi-byte
205        // character. Floor to the nearest char boundary at or below `max`.
206        let cut = (0..=max)
207            .rev()
208            .find(|&i| s.is_char_boundary(i))
209            .unwrap_or(0);
210        s.truncate(cut);
211        s.push('\u{2026}');
212    }
213    s
214}
215
216#[cfg(test)]
217#[cfg_attr(coverage_nightly, coverage(off))]
218#[allow(clippy::unwrap_used)]
219mod tests {
220    use super::*;
221    use crate::ir::binding::{HttpFieldBinding, HttpMethodBindingIr};
222
223    fn binding(template: &str, fields: Vec<HttpFieldBinding>) -> HttpMethodBindingIr {
224        HttpMethodBindingIr {
225            method_name: "x".to_owned(),
226            http_method: HttpMethod::Get,
227            path_template: template.to_owned(),
228            field_bindings: fields,
229            retryable: false,
230            streaming: false,
231            optional: false,
232        }
233    }
234
235    #[test]
236    fn substitutes_path_param() {
237        let b = binding(
238            "/items/{id}",
239            vec![HttpFieldBinding::Path {
240                field: "id".into(),
241                param: "id".into(),
242            }],
243        );
244        let url = build_request_url(
245            "https://x.example",
246            "/api",
247            &b,
248            &serde_json::json!({ "id": "42" }),
249            None,
250        )
251        .unwrap();
252        assert_eq!(url, "https://x.example/api/items/42");
253    }
254
255    #[test]
256    fn appends_the_encoded_query_string() {
257        // The query arrives already encoded (by `crate::query::to_query_string`,
258        // the same codec the server decodes with); this function only joins it
259        // onto the URL.
260        let b = binding(
261            "/list",
262            vec![HttpFieldBinding::Query {
263                field: "filter".into(),
264                param: "filter".into(),
265            }],
266        );
267        let url = build_request_url(
268            "https://x.example",
269            "/api",
270            &b,
271            &serde_json::json!({}),
272            Some("status=paid&currency=USD"),
273        )
274        .unwrap();
275        assert_eq!(url, "https://x.example/api/list?status=paid&currency=USD");
276    }
277
278    #[test]
279    fn omits_the_separator_for_an_empty_query() {
280        let b = binding("/list", vec![]);
281        for query in [None, Some("")] {
282            let url = build_request_url(
283                "https://x.example",
284                "/api",
285                &b,
286                &serde_json::json!({}),
287                query,
288            )
289            .unwrap();
290            assert_eq!(url, "https://x.example/api/list", "query = {query:?}");
291        }
292    }
293
294    #[test]
295    fn maps_problem_envelope() {
296        // Canonical RFC 9457 Problem (per docs/arch/errors/DESIGN.md §3.3).
297        let body = serde_json::json!({
298            "type": "gts://gts.cf.core.errors.err.v1~cf.core.err.internal.v1~",
299            "title": "Internal",
300            "status": 500,
301            "detail": "broke",
302            "context": {}
303        })
304        .to_string();
305        let err = map_http_error(500, body, None);
306        match err {
307            TransportError::Problem { problem: p, .. } => {
308                assert_eq!(p.status, 500);
309                assert_eq!(p.detail, "broke");
310                assert!(p.problem_type.contains("internal"));
311            }
312            other => panic!("unexpected {other:?}"),
313        }
314    }
315
316    #[test]
317    fn problem_envelope_carries_retry_after() {
318        // A canonical `Problem` peer that also sends `Retry-After` must have the
319        // advised delay threaded onto the `Problem` variant so the retry loop
320        // honors it (M-16) — not just the non-canonical `HttpStatus` fallback.
321        let body = serde_json::json!({
322            "type": "gts://gts.cf.core.errors.err.v1~cf.core.err.service_unavailable.v1~",
323            "title": "Service unavailable",
324            "status": 503,
325            "detail": "draining",
326            "context": {}
327        })
328        .to_string();
329        let err = map_http_error(503, body, Some(std::time::Duration::from_secs(2)));
330        assert_eq!(err.retry_after(), Some(std::time::Duration::from_secs(2)));
331        assert!(matches!(err, TransportError::Problem { .. }));
332    }
333
334    #[test]
335    fn truncate_does_not_panic_on_multibyte_char_at_boundary() {
336        // 255 ASCII bytes + a 3-byte UTF-8 char (é is 2 bytes; use a 3-byte
337        // char to straddle byte 256 exactly) — a raw `s.truncate(256)` would
338        // panic because byte 256 falls inside the multi-byte character.
339        let body = format!("{}€", "a".repeat(255)); // '€' is 3 bytes (U+20AC)
340        assert_eq!(body.len(), 258);
341        let out = truncate(body, 256);
342        // Truncated to the last valid boundary at or below 256 (255, since the
343        // '€' starts at byte 255), with the ellipsis marker appended.
344        assert_eq!(out, format!("{}\u{2026}", "a".repeat(255)));
345    }
346
347    #[test]
348    fn falls_back_to_http_status_for_non_problem_body() {
349        let err = map_http_error(503, "service unavailable".into(), None);
350        match err {
351            TransportError::HttpStatus { status, body, .. } => {
352                assert_eq!(status, 503);
353                assert!(body.contains("service unavailable"));
354            }
355            other => panic!("unexpected {other:?}"),
356        }
357    }
358
359    #[test]
360    fn parses_retry_after_delta_seconds() {
361        let mut headers = http::HeaderMap::new();
362        headers.insert(http::header::RETRY_AFTER, "2".parse().unwrap());
363        assert_eq!(
364            parse_retry_after(&headers),
365            Some(std::time::Duration::from_secs(2))
366        );
367
368        // HTTP-date form is ignored (unsupported), as is a missing header.
369        let mut date = http::HeaderMap::new();
370        date.insert(
371            http::header::RETRY_AFTER,
372            "Wed, 21 Oct 2026 07:28:00 GMT".parse().unwrap(),
373        );
374        assert_eq!(parse_retry_after(&date), None);
375        assert_eq!(parse_retry_after(&http::HeaderMap::new()), None);
376    }
377
378    #[test]
379    fn missing_path_param_returns_url_build_error() {
380        let b = binding(
381            "/items/{id}",
382            vec![HttpFieldBinding::Path {
383                field: "id".into(),
384                param: "id".into(),
385            }],
386        );
387        let err = build_request_url(
388            "https://x.example",
389            "/api",
390            &b,
391            &serde_json::json!({}),
392            None,
393        )
394        .unwrap_err();
395        assert!(matches!(err, TransportError::UrlBuild(_)));
396    }
397}