Skip to main content

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}