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;
12
13use crate::ir::binding::{HttpFieldBinding, HttpMethod, HttpMethodBindingIr};
14use crate::runtime::transport_error::TransportError;
15
16const 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
47pub 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
69pub 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 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#[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#[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#[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 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 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¤cy=USD"),
273 )
274 .unwrap();
275 assert_eq!(url, "https://x.example/api/list?status=paid¤cy=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 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 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 let body = format!("{}€", "a".repeat(255)); assert_eq!(body.len(), 258);
341 let out = truncate(body, 256);
342 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 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}