1use std::collections::HashMap;
7
8#[derive(Debug, Clone, PartialEq, Eq)]
27pub struct ApiDeprecationInfo {
28 pub reason: String,
30 pub path: Option<String>,
32}
33
34#[derive(Clone, Copy, Debug, PartialEq, Eq)]
49pub struct ApiCallLimit {
50 pub request_count: u32,
52 pub bucket_size: u32,
54}
55
56impl ApiCallLimit {
57 #[must_use]
67 pub fn parse(header_value: &str) -> Option<Self> {
68 let parts: Vec<&str> = header_value.split('/').collect();
69 if parts.len() != 2 {
70 return None;
71 }
72
73 let request_count = parts[0].parse().ok()?;
74 let bucket_size = parts[1].parse().ok()?;
75
76 Some(Self {
77 request_count,
78 bucket_size,
79 })
80 }
81}
82
83#[derive(Clone, Debug, Default, PartialEq, Eq)]
88pub struct PaginationInfo {
89 pub prev_page_info: Option<String>,
91 pub next_page_info: Option<String>,
93}
94
95impl PaginationInfo {
96 #[must_use]
105 pub fn parse_link_header(header_value: &str) -> Self {
106 let mut result = Self::default();
107
108 for link in header_value.split(',') {
109 let link = link.trim();
110
111 let rel = link.split(';').find_map(|part| {
113 let part = part.trim();
114 if part.starts_with("rel=") {
115 Some(part.trim_start_matches("rel=").trim_matches('"'))
117 } else {
118 None
119 }
120 });
121
122 let url = link
124 .split(';')
125 .next()
126 .map(|s| s.trim().trim_start_matches('<').trim_end_matches('>'));
127
128 if let (Some(rel), Some(url)) = (rel, url) {
129 if let Some(page_info) = Self::extract_page_info(url) {
131 match rel {
132 "previous" => result.prev_page_info = Some(page_info),
133 "next" => result.next_page_info = Some(page_info),
134 _ => {}
135 }
136 }
137 }
138 }
139
140 result
141 }
142
143 fn extract_page_info(url: &str) -> Option<String> {
145 let query_start = url.find('?')?;
147 let query = &url[query_start + 1..];
148
149 for param in query.split('&') {
151 let mut parts = param.splitn(2, '=');
152 if let (Some(key), Some(value)) = (parts.next(), parts.next()) {
153 if key == "page_info" {
154 return Some(value.to_string());
155 }
156 }
157 }
158
159 None
160 }
161}
162
163#[derive(Clone, Debug)]
168pub struct HttpResponse {
169 pub code: u16,
171 pub headers: HashMap<String, Vec<String>>,
173 pub body: serde_json::Value,
175 pub prev_page_info: Option<String>,
177 pub next_page_info: Option<String>,
179 pub api_call_limit: Option<ApiCallLimit>,
181 pub retry_request_after: Option<f64>,
183}
184
185impl HttpResponse {
186 #[must_use]
193 pub fn new(code: u16, headers: HashMap<String, Vec<String>>, body: serde_json::Value) -> Self {
194 let (prev_page_info, next_page_info) = headers
196 .get("link")
197 .and_then(|values| values.first())
198 .map_or((None, None), |link| {
199 let info = PaginationInfo::parse_link_header(link);
200 (info.prev_page_info, info.next_page_info)
201 });
202
203 let api_call_limit = headers
205 .get("x-shopify-shop-api-call-limit")
206 .and_then(|values| values.first())
207 .and_then(|value| ApiCallLimit::parse(value));
208
209 let retry_request_after = headers
211 .get("retry-after")
212 .and_then(|values| values.first())
213 .and_then(|value| value.parse::<f64>().ok());
214
215 Self {
216 code,
217 headers,
218 body,
219 prev_page_info,
220 next_page_info,
221 api_call_limit,
222 retry_request_after,
223 }
224 }
225
226 #[must_use]
228 pub const fn is_ok(&self) -> bool {
229 self.code >= 200 && self.code <= 299
230 }
231
232 #[must_use]
236 pub fn request_id(&self) -> Option<&str> {
237 self.headers
238 .get("x-request-id")
239 .and_then(|values| values.first())
240 .map(String::as_str)
241 }
242
243 #[must_use]
248 pub fn deprecation_reason(&self) -> Option<&str> {
249 self.headers
250 .get("x-shopify-api-deprecated-reason")
251 .and_then(|values| values.first())
252 .map(String::as_str)
253 }
254
255 #[must_use]
280 pub fn deprecation_info(&self) -> Option<ApiDeprecationInfo> {
281 self.deprecation_reason().map(|reason| ApiDeprecationInfo {
282 reason: reason.to_string(),
283 path: None, })
285 }
286
287 #[must_use]
308 pub fn is_deprecated(&self) -> bool {
309 self.deprecation_reason().is_some()
310 }
311}
312
313#[cfg(test)]
314mod tests {
315 use super::*;
316 use serde_json::json;
317
318 #[test]
319 fn test_is_ok_returns_true_for_2xx() {
320 for code in 200..=299 {
321 let response = HttpResponse::new(code, HashMap::new(), json!({}));
322 assert!(
323 response.is_ok(),
324 "Expected is_ok() to be true for code {code}"
325 );
326 }
327 }
328
329 #[test]
330 fn test_is_ok_returns_false_for_4xx_and_5xx() {
331 let response_400 = HttpResponse::new(400, HashMap::new(), json!({}));
332 assert!(!response_400.is_ok());
333
334 let response_404 = HttpResponse::new(404, HashMap::new(), json!({}));
335 assert!(!response_404.is_ok());
336
337 let response_429 = HttpResponse::new(429, HashMap::new(), json!({}));
338 assert!(!response_429.is_ok());
339
340 let response_500 = HttpResponse::new(500, HashMap::new(), json!({}));
341 assert!(!response_500.is_ok());
342 }
343
344 #[test]
345 fn test_api_call_limit_parsing() {
346 let limit = ApiCallLimit::parse("40/80").unwrap();
347 assert_eq!(limit.request_count, 40);
348 assert_eq!(limit.bucket_size, 80);
349
350 let limit = ApiCallLimit::parse("1/40").unwrap();
351 assert_eq!(limit.request_count, 1);
352 assert_eq!(limit.bucket_size, 40);
353
354 assert!(ApiCallLimit::parse("invalid").is_none());
356 assert!(ApiCallLimit::parse("40").is_none());
357 assert!(ApiCallLimit::parse("40/").is_none());
358 assert!(ApiCallLimit::parse("/80").is_none());
359 assert!(ApiCallLimit::parse("abc/def").is_none());
360 }
361
362 #[test]
363 fn test_link_header_parsing() {
364 let link = r#"<https://shop.myshopify.com/admin/api/2024-10/products.json?page_info=abc123>; rel="next", <https://shop.myshopify.com/admin/api/2024-10/products.json?page_info=xyz789>; rel="previous""#;
366 let info = PaginationInfo::parse_link_header(link);
367 assert_eq!(info.next_page_info, Some("abc123".to_string()));
368 assert_eq!(info.prev_page_info, Some("xyz789".to_string()));
369
370 let link = r#"<https://shop.myshopify.com/admin/api/2024-10/products.json?page_info=abc123>; rel="next""#;
372 let info = PaginationInfo::parse_link_header(link);
373 assert_eq!(info.next_page_info, Some("abc123".to_string()));
374 assert!(info.prev_page_info.is_none());
375
376 let link = r#"<https://shop.myshopify.com/admin/api/2024-10/products.json?page_info=xyz789>; rel="previous""#;
378 let info = PaginationInfo::parse_link_header(link);
379 assert!(info.next_page_info.is_none());
380 assert_eq!(info.prev_page_info, Some("xyz789".to_string()));
381 }
382
383 #[test]
384 fn test_retry_after_parsing() {
385 let mut headers = HashMap::new();
386 headers.insert("retry-after".to_string(), vec!["2.5".to_string()]);
387
388 let response = HttpResponse::new(429, headers, json!({}));
389 assert!((response.retry_request_after.unwrap() - 2.5).abs() < f64::EPSILON);
390 }
391
392 #[test]
393 fn test_empty_body_returns_empty_json() {
394 let response = HttpResponse::new(200, HashMap::new(), json!({}));
395 assert_eq!(response.body, json!({}));
396 }
397
398 #[test]
399 fn test_request_id_extraction() {
400 let mut headers = HashMap::new();
401 headers.insert("x-request-id".to_string(), vec!["abc-123-xyz".to_string()]);
402
403 let response = HttpResponse::new(200, headers, json!({}));
404 assert_eq!(response.request_id(), Some("abc-123-xyz"));
405 }
406
407 #[test]
408 fn test_deprecation_reason_extraction() {
409 let mut headers = HashMap::new();
410 headers.insert(
411 "x-shopify-api-deprecated-reason".to_string(),
412 vec!["This endpoint is deprecated".to_string()],
413 );
414
415 let response = HttpResponse::new(200, headers, json!({}));
416 assert_eq!(
417 response.deprecation_reason(),
418 Some("This endpoint is deprecated")
419 );
420 }
421
422 #[test]
423 fn test_deprecation_info_parses_header() {
424 let mut headers = HashMap::new();
425 headers.insert(
426 "x-shopify-api-deprecated-reason".to_string(),
427 vec!["This endpoint will be removed in 2025-07".to_string()],
428 );
429
430 let response = HttpResponse::new(200, headers, json!({}));
431 let info = response.deprecation_info().unwrap();
432
433 assert_eq!(info.reason, "This endpoint will be removed in 2025-07");
434 assert!(info.path.is_none()); }
436
437 #[test]
438 fn test_deprecation_info_returns_none_when_not_deprecated() {
439 let response = HttpResponse::new(200, HashMap::new(), json!({}));
440 assert!(response.deprecation_info().is_none());
441 }
442
443 #[test]
444 fn test_is_deprecated_true_when_header_present() {
445 let mut headers = HashMap::new();
446 headers.insert(
447 "x-shopify-api-deprecated-reason".to_string(),
448 vec!["Deprecated".to_string()],
449 );
450
451 let response = HttpResponse::new(200, headers, json!({}));
452 assert!(response.is_deprecated());
453 }
454
455 #[test]
456 fn test_is_deprecated_false_when_no_header() {
457 let response = HttpResponse::new(200, HashMap::new(), json!({}));
458 assert!(!response.is_deprecated());
459 }
460}