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