shopify_sdk/rest/path.rs
1//! Path building infrastructure for REST resources.
2//!
3//! This module provides the path resolution system that enables resources to
4//! support multiple access patterns (e.g., standalone and nested paths).
5//!
6//! # Path Resolution
7//!
8//! Resources can be accessed through multiple paths. For example, a `Variant`
9//! resource might be accessible via:
10//! - `/products/{product_id}/variants/{id}` (nested under product)
11//! - `/variants/{id}` (standalone)
12//!
13//! The path resolution system selects the most specific path that matches
14//! the available IDs. If both `product_id` and `id` are available, the
15//! nested path is preferred.
16//!
17//! # Example
18//!
19//! ```rust
20//! use shopify_sdk::rest::{ResourcePath, ResourceOperation, get_path, build_path};
21//! use shopify_sdk::HttpMethod;
22//! use std::collections::HashMap;
23//!
24//! // Define paths for a resource
25//! const PATHS: &[ResourcePath] = &[
26//! ResourcePath::new(
27//! HttpMethod::Get,
28//! ResourceOperation::Find,
29//! &["product_id", "id"],
30//! "products/{product_id}/variants/{id}",
31//! ),
32//! ResourcePath::new(
33//! HttpMethod::Get,
34//! ResourceOperation::Find,
35//! &["id"],
36//! "variants/{id}",
37//! ),
38//! ];
39//!
40//! // Find the best matching path
41//! let available_ids = vec!["product_id", "id"];
42//! let path = get_path(PATHS, ResourceOperation::Find, &available_ids);
43//! assert!(path.is_some());
44//!
45//! // Build the actual URL
46//! let mut ids = HashMap::new();
47//! ids.insert("product_id", "123");
48//! ids.insert("id", "456");
49//! let url = build_path(path.unwrap().template, &ids);
50//! assert_eq!(url, "products/123/variants/456");
51//! ```
52
53use crate::clients::HttpMethod;
54use std::collections::HashMap;
55use std::fmt::Display;
56
57/// Operations that can be performed on a REST resource.
58///
59/// Each operation corresponds to a specific HTTP method and URL pattern.
60#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
61pub enum ResourceOperation {
62 /// Find a single resource by ID (GET /resources/{id}).
63 Find,
64 /// List all resources (GET /resources).
65 All,
66 /// Create a new resource (POST /resources).
67 Create,
68 /// Update an existing resource (PUT /resources/{id}).
69 Update,
70 /// Delete a resource (DELETE /resources/{id}).
71 Delete,
72 /// Count resources (GET /resources/count).
73 Count,
74}
75
76impl ResourceOperation {
77 /// Returns the default HTTP method for this operation.
78 #[must_use]
79 pub const fn default_http_method(&self) -> HttpMethod {
80 match self {
81 Self::Find | Self::All | Self::Count => HttpMethod::Get,
82 Self::Create => HttpMethod::Post,
83 Self::Update => HttpMethod::Put,
84 Self::Delete => HttpMethod::Delete,
85 }
86 }
87
88 /// Returns the operation name as a string.
89 #[must_use]
90 pub const fn as_str(&self) -> &'static str {
91 match self {
92 Self::Find => "find",
93 Self::All => "all",
94 Self::Create => "create",
95 Self::Update => "update",
96 Self::Delete => "delete",
97 Self::Count => "count",
98 }
99 }
100}
101
102/// A path configuration for a REST resource operation.
103///
104/// Each `ResourcePath` defines how to access a resource for a specific
105/// operation, including the HTTP method, required IDs, and URL template.
106///
107/// # Path Templates
108///
109/// Templates use `{id_name}` placeholders for ID interpolation:
110/// - `products/{id}` - Single ID
111/// - `products/{product_id}/variants/{id}` - Multiple IDs
112///
113/// # Example
114///
115/// ```rust
116/// use shopify_sdk::rest::{ResourcePath, ResourceOperation};
117/// use shopify_sdk::HttpMethod;
118///
119/// const PRODUCT_FIND: ResourcePath = ResourcePath::new(
120/// HttpMethod::Get,
121/// ResourceOperation::Find,
122/// &["id"],
123/// "products/{id}",
124/// );
125/// ```
126#[derive(Debug, Clone, Copy, PartialEq, Eq)]
127pub struct ResourcePath {
128 /// The HTTP method for this path.
129 pub http_method: HttpMethod,
130 /// The operation this path is used for.
131 pub operation: ResourceOperation,
132 /// Required ID parameters in order (e.g., `["product_id", "id"]`).
133 pub ids: &'static [&'static str],
134 /// The URL template with `{id}` placeholders.
135 pub template: &'static str,
136}
137
138impl ResourcePath {
139 /// Creates a new `ResourcePath`.
140 ///
141 /// This is a `const fn` to allow paths to be defined as constants.
142 ///
143 /// # Arguments
144 ///
145 /// * `http_method` - The HTTP method for this path
146 /// * `operation` - The operation this path handles
147 /// * `ids` - Required ID parameter names in order
148 /// * `template` - The URL template with `{id}` placeholders
149 #[must_use]
150 pub const fn new(
151 http_method: HttpMethod,
152 operation: ResourceOperation,
153 ids: &'static [&'static str],
154 template: &'static str,
155 ) -> Self {
156 Self {
157 http_method,
158 operation,
159 ids,
160 template,
161 }
162 }
163
164 /// Returns the number of required IDs for this path.
165 #[must_use]
166 pub const fn id_count(&self) -> usize {
167 self.ids.len()
168 }
169
170 /// Checks if all required IDs are available.
171 ///
172 /// # Arguments
173 ///
174 /// * `available_ids` - Slice of available ID parameter names
175 #[must_use]
176 pub fn matches_ids(&self, available_ids: &[&str]) -> bool {
177 self.ids.iter().all(|id| available_ids.contains(id))
178 }
179}
180
181/// Selects the best matching path for an operation.
182///
183/// The function filters paths by operation type and then selects the
184/// path that:
185/// 1. Has all required IDs available
186/// 2. Has the most required IDs (most specific)
187///
188/// # Arguments
189///
190/// * `paths` - Available paths for the resource
191/// * `operation` - The operation to perform
192/// * `available_ids` - IDs that are available for path building
193///
194/// # Returns
195///
196/// The most specific matching path, or `None` if no path matches.
197///
198/// # Example
199///
200/// ```rust
201/// use shopify_sdk::rest::{ResourcePath, ResourceOperation, get_path};
202/// use shopify_sdk::HttpMethod;
203///
204/// const PATHS: &[ResourcePath] = &[
205/// ResourcePath::new(HttpMethod::Get, ResourceOperation::Find, &["product_id", "id"], "products/{product_id}/variants/{id}"),
206/// ResourcePath::new(HttpMethod::Get, ResourceOperation::Find, &["id"], "variants/{id}"),
207/// ];
208///
209/// // With both IDs, prefers the nested path
210/// let path = get_path(PATHS, ResourceOperation::Find, &["product_id", "id"]);
211/// assert_eq!(path.unwrap().template, "products/{product_id}/variants/{id}");
212///
213/// // With only id, uses the standalone path
214/// let path = get_path(PATHS, ResourceOperation::Find, &["id"]);
215/// assert_eq!(path.unwrap().template, "variants/{id}");
216/// ```
217#[must_use]
218pub fn get_path<'a>(
219 paths: &'a [ResourcePath],
220 operation: ResourceOperation,
221 available_ids: &[&str],
222) -> Option<&'a ResourcePath> {
223 paths
224 .iter()
225 // Filter by operation
226 .filter(|p| p.operation == operation)
227 // Filter by available IDs
228 .filter(|p| p.matches_ids(available_ids))
229 // Select the most specific (most IDs)
230 .max_by_key(|p| p.id_count())
231}
232
233/// Builds a URL from a template by interpolating IDs.
234///
235/// Replaces `{id_name}` placeholders in the template with values from
236/// the provided map.
237///
238/// # Arguments
239///
240/// * `template` - The URL template with placeholders
241/// * `ids` - A map of ID names to values
242///
243/// # Returns
244///
245/// The interpolated URL string.
246///
247/// # Example
248///
249/// ```rust
250/// use shopify_sdk::rest::build_path;
251/// use std::collections::HashMap;
252///
253/// let mut ids = HashMap::new();
254/// ids.insert("product_id", "123");
255/// ids.insert("id", "456");
256///
257/// let url = build_path("products/{product_id}/variants/{id}", &ids);
258/// assert_eq!(url, "products/123/variants/456");
259/// ```
260#[must_use]
261#[allow(clippy::implicit_hasher)]
262pub fn build_path<V: Display>(template: &str, ids: &HashMap<&str, V>) -> String {
263 let mut result = template.to_string();
264
265 for (key, value) in ids {
266 let placeholder = format!("{{{key}}}");
267 result = result.replace(&placeholder, &value.to_string());
268 }
269
270 result
271}
272
273/// Builds a URL from a `ResourcePath` by interpolating IDs.
274///
275/// Convenience function that combines path selection with URL building.
276///
277/// # Arguments
278///
279/// * `path` - The resource path configuration
280/// * `ids` - A map of ID names to values
281///
282/// # Returns
283///
284/// The interpolated URL string.
285#[must_use]
286#[allow(dead_code, clippy::implicit_hasher)]
287pub fn build_path_from_resource_path<V: Display>(
288 path: &ResourcePath,
289 ids: &HashMap<&str, V>,
290) -> String {
291 build_path(path.template, ids)
292}
293
294// Verify types are Send + Sync at compile time
295const _: fn() = || {
296 const fn assert_send_sync<T: Send + Sync>() {}
297 assert_send_sync::<ResourceOperation>();
298 assert_send_sync::<ResourcePath>();
299};
300
301#[cfg(test)]
302mod tests {
303 use super::*;
304
305 #[test]
306 fn test_resource_path_stores_fields_correctly() {
307 let path = ResourcePath::new(
308 HttpMethod::Get,
309 ResourceOperation::Find,
310 &["product_id", "id"],
311 "products/{product_id}/variants/{id}",
312 );
313
314 assert_eq!(path.http_method, HttpMethod::Get);
315 assert_eq!(path.operation, ResourceOperation::Find);
316 assert_eq!(path.ids, &["product_id", "id"]);
317 assert_eq!(path.template, "products/{product_id}/variants/{id}");
318 }
319
320 #[test]
321 fn test_path_template_interpolation_single_id() {
322 let mut ids = HashMap::new();
323 ids.insert("id", "123");
324
325 let result = build_path("products/{id}", &ids);
326 assert_eq!(result, "products/123");
327 }
328
329 #[test]
330 fn test_path_template_interpolation_multiple_ids() {
331 let mut ids = HashMap::new();
332 ids.insert("product_id", "123");
333 ids.insert("id", "456");
334
335 let result = build_path("products/{product_id}/variants/{id}", &ids);
336 assert_eq!(result, "products/123/variants/456");
337 }
338
339 #[test]
340 fn test_get_path_selects_most_specific_path() {
341 const PATHS: &[ResourcePath] = &[
342 ResourcePath::new(
343 HttpMethod::Get,
344 ResourceOperation::Find,
345 &["product_id", "id"],
346 "products/{product_id}/variants/{id}",
347 ),
348 ResourcePath::new(
349 HttpMethod::Get,
350 ResourceOperation::Find,
351 &["id"],
352 "variants/{id}",
353 ),
354 ];
355
356 // With both IDs available, should select the nested path
357 let path = get_path(PATHS, ResourceOperation::Find, &["product_id", "id"]);
358 assert!(path.is_some());
359 assert_eq!(
360 path.unwrap().template,
361 "products/{product_id}/variants/{id}"
362 );
363 }
364
365 #[test]
366 fn test_get_path_falls_back_to_less_specific() {
367 const PATHS: &[ResourcePath] = &[
368 ResourcePath::new(
369 HttpMethod::Get,
370 ResourceOperation::Find,
371 &["product_id", "id"],
372 "products/{product_id}/variants/{id}",
373 ),
374 ResourcePath::new(
375 HttpMethod::Get,
376 ResourceOperation::Find,
377 &["id"],
378 "variants/{id}",
379 ),
380 ];
381
382 // With only id available, should select standalone path
383 let path = get_path(PATHS, ResourceOperation::Find, &["id"]);
384 assert!(path.is_some());
385 assert_eq!(path.unwrap().template, "variants/{id}");
386 }
387
388 #[test]
389 fn test_get_path_returns_none_when_no_match() {
390 const PATHS: &[ResourcePath] = &[ResourcePath::new(
391 HttpMethod::Get,
392 ResourceOperation::Find,
393 &["id"],
394 "products/{id}",
395 )];
396
397 // Wrong operation
398 let path = get_path(PATHS, ResourceOperation::Delete, &["id"]);
399 assert!(path.is_none());
400
401 // Missing required ID
402 let path = get_path(PATHS, ResourceOperation::Find, &[]);
403 assert!(path.is_none());
404 }
405
406 #[test]
407 fn test_get_path_filters_by_operation() {
408 const PATHS: &[ResourcePath] = &[
409 ResourcePath::new(
410 HttpMethod::Get,
411 ResourceOperation::Find,
412 &["id"],
413 "products/{id}",
414 ),
415 ResourcePath::new(
416 HttpMethod::Delete,
417 ResourceOperation::Delete,
418 &["id"],
419 "products/{id}",
420 ),
421 ResourcePath::new(HttpMethod::Get, ResourceOperation::All, &[], "products"),
422 ];
423
424 let find_path = get_path(PATHS, ResourceOperation::Find, &["id"]);
425 assert_eq!(find_path.unwrap().http_method, HttpMethod::Get);
426 assert_eq!(find_path.unwrap().operation, ResourceOperation::Find);
427
428 let delete_path = get_path(PATHS, ResourceOperation::Delete, &["id"]);
429 assert_eq!(delete_path.unwrap().http_method, HttpMethod::Delete);
430 assert_eq!(delete_path.unwrap().operation, ResourceOperation::Delete);
431
432 let all_path = get_path(PATHS, ResourceOperation::All, &[]);
433 assert_eq!(all_path.unwrap().template, "products");
434 }
435
436 #[test]
437 fn test_path_building_with_optional_prefix() {
438 // Simulate a resource with a prefix
439 let prefix = "admin/api/2024-10";
440 let template = "products/{id}";
441
442 let mut ids = HashMap::new();
443 ids.insert("id", "123");
444
445 let path = build_path(template, &ids);
446 let full_path = format!("{prefix}/{path}");
447
448 assert_eq!(full_path, "admin/api/2024-10/products/123");
449 }
450
451 #[test]
452 fn test_resource_operation_default_http_method() {
453 assert_eq!(
454 ResourceOperation::Find.default_http_method(),
455 HttpMethod::Get
456 );
457 assert_eq!(
458 ResourceOperation::All.default_http_method(),
459 HttpMethod::Get
460 );
461 assert_eq!(
462 ResourceOperation::Create.default_http_method(),
463 HttpMethod::Post
464 );
465 assert_eq!(
466 ResourceOperation::Update.default_http_method(),
467 HttpMethod::Put
468 );
469 assert_eq!(
470 ResourceOperation::Delete.default_http_method(),
471 HttpMethod::Delete
472 );
473 assert_eq!(
474 ResourceOperation::Count.default_http_method(),
475 HttpMethod::Get
476 );
477 }
478
479 #[test]
480 fn test_resource_path_matches_ids() {
481 let path = ResourcePath::new(
482 HttpMethod::Get,
483 ResourceOperation::Find,
484 &["product_id", "id"],
485 "products/{product_id}/variants/{id}",
486 );
487
488 assert!(path.matches_ids(&["product_id", "id"]));
489 assert!(path.matches_ids(&["product_id", "id", "extra"]));
490 assert!(!path.matches_ids(&["id"]));
491 assert!(!path.matches_ids(&["product_id"]));
492 assert!(!path.matches_ids(&[]));
493 }
494
495 #[test]
496 fn test_resource_path_id_count() {
497 let path_two = ResourcePath::new(
498 HttpMethod::Get,
499 ResourceOperation::Find,
500 &["product_id", "id"],
501 "products/{product_id}/variants/{id}",
502 );
503 assert_eq!(path_two.id_count(), 2);
504
505 let path_one = ResourcePath::new(
506 HttpMethod::Get,
507 ResourceOperation::Find,
508 &["id"],
509 "variants/{id}",
510 );
511 assert_eq!(path_one.id_count(), 1);
512
513 let path_zero = ResourcePath::new(HttpMethod::Get, ResourceOperation::All, &[], "products");
514 assert_eq!(path_zero.id_count(), 0);
515 }
516
517 #[test]
518 fn test_build_path_handles_numeric_ids() {
519 let mut ids: HashMap<&str, u64> = HashMap::new();
520 ids.insert("id", 123u64);
521
522 let result = build_path("products/{id}", &ids);
523 assert_eq!(result, "products/123");
524 }
525
526 #[test]
527 fn test_build_path_handles_missing_ids() {
528 let ids: HashMap<&str, &str> = HashMap::new();
529
530 // Placeholders that aren't in the map remain unchanged
531 let result = build_path("products/{id}", &ids);
532 assert_eq!(result, "products/{id}");
533 }
534}