toolkit_contract/runtime/
http.rs1use 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
18const 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
49pub 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
71pub 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 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
131pub 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#[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#[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 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#[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 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 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¤cy=USD"),
296 )
297 .unwrap();
298 assert_eq!(url, "https://x.example/api/list?status=paid¤cy=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 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 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 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 let body = format!("{}€", "a".repeat(255)); assert_eq!(body.len(), 258);
391 let out = truncate(body, 256);
392 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 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}