Skip to main content

shopify_sdk/rest/
resource.rs

1//! REST Resource trait for CRUD operations.
2//!
3//! This module defines the [`RestResource`] trait, which provides a standardized
4//! interface for interacting with Shopify REST API resources. Resources that
5//! implement this trait gain `find()`, `all()`, `save()`, `delete()`, and
6//! `count()` methods.
7//!
8//! It also defines the [`ReadOnlyResource`] marker trait for resources that
9//! only support read operations (GET requests).
10//!
11//! # Implementing a Resource
12//!
13//! To implement a REST resource:
14//!
15//! 1. Define a struct with serde derives
16//! 2. Implement the `RestResource` trait with associated types and constants
17//! 3. The trait provides default implementations for CRUD operations
18//!
19//! # Example
20//!
21//! ```rust,ignore
22//! use shopify_sdk::rest::{RestResource, ResourcePath, ResourceOperation, ResourceResponse, ResourceError};
23//! use shopify_sdk::{HttpMethod, RestClient};
24//! use serde::{Serialize, Deserialize};
25//!
26//! #[derive(Debug, Clone, Serialize, Deserialize)]
27//! pub struct Product {
28//!     pub id: Option<u64>,
29//!     pub title: String,
30//!     #[serde(skip_serializing_if = "Option::is_none")]
31//!     pub vendor: Option<String>,
32//! }
33//!
34//! impl RestResource for Product {
35//!     type Id = u64;
36//!     type FindParams = ();
37//!     type AllParams = ProductListParams;
38//!     type CountParams = ();
39//!
40//!     const NAME: &'static str = "Product";
41//!     const PLURAL: &'static str = "products";
42//!     const PATHS: &'static [ResourcePath] = &[
43//!         ResourcePath::new(HttpMethod::Get, ResourceOperation::Find, &["id"], "products/{id}"),
44//!         ResourcePath::new(HttpMethod::Get, ResourceOperation::All, &[], "products"),
45//!         ResourcePath::new(HttpMethod::Get, ResourceOperation::Count, &[], "products/count"),
46//!         ResourcePath::new(HttpMethod::Post, ResourceOperation::Create, &[], "products"),
47//!         ResourcePath::new(HttpMethod::Put, ResourceOperation::Update, &["id"], "products/{id}"),
48//!         ResourcePath::new(HttpMethod::Delete, ResourceOperation::Delete, &["id"], "products/{id}"),
49//!     ];
50//!
51//!     fn get_id(&self) -> Option<Self::Id> {
52//!         self.id
53//!     }
54//! }
55//!
56//! // Usage:
57//! let product = Product::find(&client, 123, None).await?;
58//! let products = Product::all(&client, None).await?;
59//! ```
60//!
61//! # Read-Only Resources
62//!
63//! For resources that only support read operations (like Event, Policy, Location),
64//! implement the [`ReadOnlyResource`] marker trait in addition to `RestResource`.
65//! This provides compile-time documentation of the resource's capabilities.
66//!
67//! ```rust,ignore
68//! // Read-only resources implement both traits
69//! impl RestResource for Location { /* ... only GET paths ... */ }
70//! impl ReadOnlyResource for Location {}
71//! ```
72
73use std::collections::HashMap;
74use std::fmt::Display;
75
76use serde::{de::DeserializeOwned, Serialize};
77use serde_json::Value;
78
79use crate::clients::RestClient;
80use crate::rest::{
81    build_path, get_path, ResourceError, ResourceOperation, ResourcePath, ResourceResponse,
82};
83
84/// A marker trait for REST resources that only support read operations.
85///
86/// Resources implementing this trait only support GET requests (find, all, count)
87/// and do not have Create, Update, or Delete operations. This serves as compile-time
88/// documentation of the resource's capabilities and can be used for blanket
89/// implementations that restrict certain behaviors.
90///
91/// # Resources with this trait
92///
93/// The following Shopify resources are read-only:
94/// - `Event` - Store events (read-only audit log)
95/// - `Policy` - Store legal policies
96/// - `Location` - Store locations
97/// - `Currency` - Enabled currencies
98/// - `User` - Store staff users
99/// - `AccessScope` - App access scopes
100///
101/// # Example
102///
103/// ```rust,ignore
104/// use shopify_sdk::rest::{RestResource, ReadOnlyResource};
105///
106/// // Location only supports GET operations
107/// impl RestResource for Location {
108///     const PATHS: &'static [ResourcePath] = &[
109///         ResourcePath::new(HttpMethod::Get, ResourceOperation::Find, &["id"], "locations/{id}"),
110///         ResourcePath::new(HttpMethod::Get, ResourceOperation::All, &[], "locations"),
111///         ResourcePath::new(HttpMethod::Get, ResourceOperation::Count, &[], "locations/count"),
112///         // No Create, Update, or Delete paths
113///     ];
114///     // ...
115/// }
116///
117/// // Mark as read-only
118/// impl ReadOnlyResource for Location {}
119/// ```
120pub trait ReadOnlyResource: RestResource {}
121
122/// A REST resource that can be fetched, created, updated, and deleted.
123///
124/// This trait provides a standardized interface for CRUD operations on
125/// Shopify REST API resources. Implementors define the resource's paths,
126/// name, and parameter types, and get default implementations for all
127/// CRUD methods.
128///
129/// # Associated Types
130///
131/// - `Id`: The type of the resource's identifier (usually `u64` or `String`)
132/// - `FindParams`: Parameters for `find()` operations (use `()` if none)
133/// - `AllParams`: Parameters for `all()` operations (pagination, filters, etc.)
134/// - `CountParams`: Parameters for `count()` operations
135///
136/// # Associated Constants
137///
138/// - `NAME`: The singular resource name (e.g., "Product")
139/// - `PLURAL`: The plural form used in URLs (e.g., "products")
140/// - `PATHS`: Available paths for different operations
141/// - `PREFIX`: Optional path prefix for nested resources
142///
143/// # Required Bounds
144///
145/// Resources must be serializable, deserializable, cloneable, and thread-safe.
146#[allow(async_fn_in_trait)]
147pub trait RestResource: Serialize + DeserializeOwned + Clone + Send + Sync + Sized {
148    /// The type of the resource's identifier.
149    type Id: Display + Clone + Send + Sync;
150
151    /// Parameters for `find()` operations.
152    ///
153    /// Use `()` if no parameters are needed.
154    type FindParams: Serialize + Default + Send + Sync;
155
156    /// Parameters for `all()` operations (filtering, pagination, etc.).
157    ///
158    /// Use `()` if no parameters are needed.
159    type AllParams: Serialize + Default + Send + Sync;
160
161    /// Parameters for `count()` operations.
162    ///
163    /// Use `()` if no parameters are needed.
164    type CountParams: Serialize + Default + Send + Sync;
165
166    /// The singular name of the resource (e.g., "Product").
167    ///
168    /// Used in error messages and as the response body key for single resources.
169    const NAME: &'static str;
170
171    /// The plural name used in URL paths (e.g., "products").
172    ///
173    /// Used as the response body key for collection operations.
174    const PLURAL: &'static str;
175
176    /// Available paths for this resource.
177    ///
178    /// Define paths for each operation the resource supports. The path
179    /// selection logic will choose the most specific path that matches
180    /// the available IDs.
181    const PATHS: &'static [ResourcePath];
182
183    /// Optional path prefix for nested resources.
184    ///
185    /// Override this for resources that always appear under a parent path.
186    const PREFIX: Option<&'static str> = None;
187
188    /// Returns the resource's ID if it exists.
189    ///
190    /// Returns `None` for new resources that haven't been saved yet.
191    fn get_id(&self) -> Option<Self::Id>;
192
193    /// Returns the lowercase key used in JSON request/response bodies.
194    #[must_use]
195    fn resource_key() -> String {
196        Self::NAME.to_lowercase()
197    }
198
199    /// Finds a single resource by ID.
200    ///
201    /// # Arguments
202    ///
203    /// * `client` - The REST client to use for the request
204    /// * `id` - The resource ID to find
205    /// * `params` - Optional parameters for the request
206    ///
207    /// # Errors
208    ///
209    /// Returns [`ResourceError::NotFound`] if the resource doesn't exist.
210    /// Returns [`ResourceError::PathResolutionFailed`] if no valid path matches.
211    ///
212    /// # Example
213    ///
214    /// ```rust,ignore
215    /// let product = Product::find(&client, 123, None).await?;
216    /// println!("Found: {}", product.title);
217    /// ```
218    async fn find(
219        client: &RestClient,
220        id: Self::Id,
221        params: Option<Self::FindParams>,
222    ) -> Result<ResourceResponse<Self>, ResourceError> {
223        // Build the path
224        let mut ids: HashMap<&str, String> = HashMap::new();
225        ids.insert("id", id.to_string());
226
227        let available_ids: Vec<&str> = ids.keys().copied().collect();
228        let path = get_path(Self::PATHS, ResourceOperation::Find, &available_ids).ok_or(
229            ResourceError::PathResolutionFailed {
230                resource: Self::NAME,
231                operation: "find",
232            },
233        )?;
234
235        let url = build_path(path.template, &ids);
236        let full_path = Self::build_full_path(&url);
237
238        // Build query params from FindParams
239        let query = params
240            .map(|p| serialize_to_query(&p))
241            .transpose()?
242            .filter(|q| !q.is_empty());
243
244        // Make the request
245        let response = client.get(&full_path, query).await?;
246
247        // Check for error status codes
248        if !response.is_ok() {
249            return Err(ResourceError::from_http_response(
250                response.code,
251                &response.body,
252                Self::NAME,
253                Some(&id.to_string()),
254                response.request_id(),
255            ));
256        }
257
258        // Parse the response
259        let key = Self::resource_key();
260        ResourceResponse::from_http_response(response, &key)
261    }
262
263    /// Lists all resources matching the given parameters.
264    ///
265    /// Returns a paginated response. Use `has_next_page()` and `next_page_info()`
266    /// to navigate through pages.
267    ///
268    /// # Arguments
269    ///
270    /// * `client` - The REST client to use for the request
271    /// * `params` - Optional parameters for filtering/pagination
272    ///
273    /// # Errors
274    ///
275    /// Returns [`ResourceError::PathResolutionFailed`] if no valid path matches.
276    ///
277    /// # Example
278    ///
279    /// ```rust,ignore
280    /// let response = Product::all(&client, None).await?;
281    /// for product in response.iter() {
282    ///     println!("Product: {}", product.title);
283    /// }
284    ///
285    /// if response.has_next_page() {
286    ///     // Fetch next page...
287    /// }
288    /// ```
289    async fn all(
290        client: &RestClient,
291        params: Option<Self::AllParams>,
292    ) -> Result<ResourceResponse<Vec<Self>>, ResourceError> {
293        let path = get_path(Self::PATHS, ResourceOperation::All, &[]).ok_or(
294            ResourceError::PathResolutionFailed {
295                resource: Self::NAME,
296                operation: "all",
297            },
298        )?;
299
300        let url = path.template;
301        let full_path = Self::build_full_path(url);
302
303        // Build query params from AllParams
304        let query = params
305            .map(|p| serialize_to_query(&p))
306            .transpose()?
307            .filter(|q| !q.is_empty());
308
309        // Make the request
310        let response = client.get(&full_path, query).await?;
311
312        // Check for error status codes
313        if !response.is_ok() {
314            return Err(ResourceError::from_http_response(
315                response.code,
316                &response.body,
317                Self::NAME,
318                None,
319                response.request_id(),
320            ));
321        }
322
323        // Parse the response
324        ResourceResponse::from_http_response(response, Self::PLURAL)
325    }
326
327    /// Lists resources with a specific parent resource ID.
328    ///
329    /// For nested resources that require a parent ID (e.g., variants under products).
330    ///
331    /// # Arguments
332    ///
333    /// * `client` - The REST client to use
334    /// * `parent_id_name` - The name of the parent ID parameter (e.g., `product_id`)
335    /// * `parent_id` - The parent resource ID
336    /// * `params` - Optional parameters for filtering/pagination
337    ///
338    /// # Errors
339    ///
340    /// Returns [`ResourceError::PathResolutionFailed`] if no valid path matches.
341    async fn all_with_parent<ParentId: Display + Send>(
342        client: &RestClient,
343        parent_id_name: &str,
344        parent_id: ParentId,
345        params: Option<Self::AllParams>,
346    ) -> Result<ResourceResponse<Vec<Self>>, ResourceError> {
347        let mut ids: HashMap<&str, String> = HashMap::new();
348        ids.insert(parent_id_name, parent_id.to_string());
349
350        let available_ids: Vec<&str> = ids.keys().copied().collect();
351        let path = get_path(Self::PATHS, ResourceOperation::All, &available_ids).ok_or(
352            ResourceError::PathResolutionFailed {
353                resource: Self::NAME,
354                operation: "all",
355            },
356        )?;
357
358        let url = build_path(path.template, &ids);
359        let full_path = Self::build_full_path(&url);
360
361        let query = params
362            .map(|p| serialize_to_query(&p))
363            .transpose()?
364            .filter(|q| !q.is_empty());
365
366        let response = client.get(&full_path, query).await?;
367
368        if !response.is_ok() {
369            return Err(ResourceError::from_http_response(
370                response.code,
371                &response.body,
372                Self::NAME,
373                None,
374                response.request_id(),
375            ));
376        }
377
378        ResourceResponse::from_http_response(response, Self::PLURAL)
379    }
380
381    /// Saves the resource (create or update).
382    ///
383    /// For new resources (no ID), sends a POST request to create.
384    /// For existing resources (has ID), sends a PUT request to update.
385    ///
386    /// When updating, only changed fields are sent if dirty tracking is used.
387    ///
388    /// # Arguments
389    ///
390    /// * `client` - The REST client to use
391    ///
392    /// # Returns
393    ///
394    /// The saved resource with any server-generated fields populated.
395    ///
396    /// # Errors
397    ///
398    /// Returns [`ResourceError::ValidationFailed`] if validation fails (422).
399    /// Returns [`ResourceError::NotFound`] if updating a non-existent resource.
400    ///
401    /// # Example
402    ///
403    /// ```rust,ignore
404    /// // Create new
405    /// let mut product = Product { id: None, title: "New".to_string(), vendor: None };
406    /// let saved = product.save(&client).await?;
407    ///
408    /// // Update existing
409    /// let mut product = Product::find(&client, 123, None).await?.into_inner();
410    /// product.title = "Updated".to_string();
411    /// let saved = product.save(&client).await?;
412    /// ```
413    async fn save(&self, client: &RestClient) -> Result<Self, ResourceError> {
414        let is_new = self.get_id().is_none();
415        let key = Self::resource_key();
416
417        if is_new {
418            // Create (POST)
419            let path = get_path(Self::PATHS, ResourceOperation::Create, &[]).ok_or(
420                ResourceError::PathResolutionFailed {
421                    resource: Self::NAME,
422                    operation: "create",
423                },
424            )?;
425
426            let url = path.template;
427            let full_path = Self::build_full_path(url);
428
429            // Wrap in resource key - use a map to avoid move issues
430            let mut body_map = serde_json::Map::new();
431            body_map.insert(
432                key.clone(),
433                serde_json::to_value(self).map_err(|e| {
434                    ResourceError::Http(crate::clients::HttpError::Response(
435                        crate::clients::HttpResponseError {
436                            code: 400,
437                            message: format!("Failed to serialize resource: {e}"),
438                            error_reference: None,
439                        },
440                    ))
441                })?,
442            );
443            let body = Value::Object(body_map);
444
445            let response = client.post(&full_path, body, None).await?;
446
447            if !response.is_ok() {
448                return Err(ResourceError::from_http_response(
449                    response.code,
450                    &response.body,
451                    Self::NAME,
452                    None,
453                    response.request_id(),
454                ));
455            }
456
457            let result: ResourceResponse<Self> =
458                ResourceResponse::from_http_response(response, &key)?;
459            Ok(result.into_inner())
460        } else {
461            // Update (PUT)
462            let id = self.get_id().unwrap();
463
464            let mut ids: HashMap<&str, String> = HashMap::new();
465            ids.insert("id", id.to_string());
466
467            let available_ids: Vec<&str> = ids.keys().copied().collect();
468            let path = get_path(Self::PATHS, ResourceOperation::Update, &available_ids).ok_or(
469                ResourceError::PathResolutionFailed {
470                    resource: Self::NAME,
471                    operation: "update",
472                },
473            )?;
474
475            let url = build_path(path.template, &ids);
476            let full_path = Self::build_full_path(&url);
477
478            // Wrap in resource key - use a map to avoid move issues
479            let mut body_map = serde_json::Map::new();
480            body_map.insert(
481                key.clone(),
482                serde_json::to_value(self).map_err(|e| {
483                    ResourceError::Http(crate::clients::HttpError::Response(
484                        crate::clients::HttpResponseError {
485                            code: 400,
486                            message: format!("Failed to serialize resource: {e}"),
487                            error_reference: None,
488                        },
489                    ))
490                })?,
491            );
492            let body = Value::Object(body_map);
493
494            let response = client.put(&full_path, body, None).await?;
495
496            if !response.is_ok() {
497                return Err(ResourceError::from_http_response(
498                    response.code,
499                    &response.body,
500                    Self::NAME,
501                    Some(&id.to_string()),
502                    response.request_id(),
503                ));
504            }
505
506            let result: ResourceResponse<Self> =
507                ResourceResponse::from_http_response(response, &key)?;
508            Ok(result.into_inner())
509        }
510    }
511
512    /// Saves the resource with dirty tracking for partial updates.
513    ///
514    /// For existing resources, only sends changed fields in the PUT request.
515    /// This is more efficient than `save()` for large resources.
516    ///
517    /// # Arguments
518    ///
519    /// * `client` - The REST client to use
520    /// * `changed_fields` - JSON value containing only the changed fields
521    ///
522    /// # Returns
523    ///
524    /// The saved resource with server-generated fields populated.
525    async fn save_partial(
526        &self,
527        client: &RestClient,
528        changed_fields: Value,
529    ) -> Result<Self, ResourceError> {
530        let id = self.get_id().ok_or(ResourceError::PathResolutionFailed {
531            resource: Self::NAME,
532            operation: "update",
533        })?;
534
535        let key = Self::resource_key();
536
537        let mut ids: HashMap<&str, String> = HashMap::new();
538        ids.insert("id", id.to_string());
539
540        let available_ids: Vec<&str> = ids.keys().copied().collect();
541        let path = get_path(Self::PATHS, ResourceOperation::Update, &available_ids).ok_or(
542            ResourceError::PathResolutionFailed {
543                resource: Self::NAME,
544                operation: "update",
545            },
546        )?;
547
548        let url = build_path(path.template, &ids);
549        let full_path = Self::build_full_path(&url);
550
551        // Wrap changed fields in resource key - use a map to avoid move issues
552        let mut body_map = serde_json::Map::new();
553        body_map.insert(key.clone(), changed_fields);
554        let body = Value::Object(body_map);
555
556        let response = client.put(&full_path, body, None).await?;
557
558        if !response.is_ok() {
559            return Err(ResourceError::from_http_response(
560                response.code,
561                &response.body,
562                Self::NAME,
563                Some(&id.to_string()),
564                response.request_id(),
565            ));
566        }
567
568        let result: ResourceResponse<Self> = ResourceResponse::from_http_response(response, &key)?;
569        Ok(result.into_inner())
570    }
571
572    /// Deletes the resource.
573    ///
574    /// # Arguments
575    ///
576    /// * `client` - The REST client to use
577    ///
578    /// # Errors
579    ///
580    /// Returns [`ResourceError::NotFound`] if the resource doesn't exist.
581    /// Returns [`ResourceError::PathResolutionFailed`] if no delete path exists.
582    ///
583    /// # Example
584    ///
585    /// ```rust,ignore
586    /// let product = Product::find(&client, 123, None).await?;
587    /// product.delete(&client).await?;
588    /// ```
589    async fn delete(&self, client: &RestClient) -> Result<(), ResourceError> {
590        let id = self.get_id().ok_or(ResourceError::PathResolutionFailed {
591            resource: Self::NAME,
592            operation: "delete",
593        })?;
594
595        let mut ids: HashMap<&str, String> = HashMap::new();
596        ids.insert("id", id.to_string());
597
598        let available_ids: Vec<&str> = ids.keys().copied().collect();
599        let path = get_path(Self::PATHS, ResourceOperation::Delete, &available_ids).ok_or(
600            ResourceError::PathResolutionFailed {
601                resource: Self::NAME,
602                operation: "delete",
603            },
604        )?;
605
606        let url = build_path(path.template, &ids);
607        let full_path = Self::build_full_path(&url);
608
609        let response = client.delete(&full_path, None).await?;
610
611        if !response.is_ok() {
612            return Err(ResourceError::from_http_response(
613                response.code,
614                &response.body,
615                Self::NAME,
616                Some(&id.to_string()),
617                response.request_id(),
618            ));
619        }
620
621        Ok(())
622    }
623
624    /// Counts resources matching the given parameters.
625    ///
626    /// # Arguments
627    ///
628    /// * `client` - The REST client to use
629    /// * `params` - Optional parameters for filtering
630    ///
631    /// # Returns
632    ///
633    /// The count of matching resources as a `u64`.
634    ///
635    /// # Errors
636    ///
637    /// Returns [`ResourceError::PathResolutionFailed`] if no count path exists.
638    ///
639    /// # Example
640    ///
641    /// ```rust,ignore
642    /// let count = Product::count(&client, None).await?;
643    /// println!("Total products: {}", count);
644    /// ```
645    async fn count(
646        client: &RestClient,
647        params: Option<Self::CountParams>,
648    ) -> Result<u64, ResourceError> {
649        let path = get_path(Self::PATHS, ResourceOperation::Count, &[]).ok_or(
650            ResourceError::PathResolutionFailed {
651                resource: Self::NAME,
652                operation: "count",
653            },
654        )?;
655
656        let url = path.template;
657        let full_path = Self::build_full_path(url);
658
659        let query = params
660            .map(|p| serialize_to_query(&p))
661            .transpose()?
662            .filter(|q| !q.is_empty());
663
664        let response = client.get(&full_path, query).await?;
665
666        if !response.is_ok() {
667            return Err(ResourceError::from_http_response(
668                response.code,
669                &response.body,
670                Self::NAME,
671                None,
672                response.request_id(),
673            ));
674        }
675
676        // Extract count from response
677        let count = response
678            .body
679            .get("count")
680            .and_then(serde_json::Value::as_u64)
681            .ok_or_else(|| {
682                ResourceError::Http(crate::clients::HttpError::Response(
683                    crate::clients::HttpResponseError {
684                        code: response.code,
685                        message: "Missing 'count' in response".to_string(),
686                        error_reference: response.request_id().map(ToString::to_string),
687                    },
688                ))
689            })?;
690
691        Ok(count)
692    }
693
694    /// Builds the full path including any prefix.
695    #[must_use]
696    fn build_full_path(path: &str) -> String {
697        Self::PREFIX.map_or_else(|| path.to_string(), |prefix| format!("{prefix}/{path}"))
698    }
699}
700
701/// Serializes a params struct to a query parameter map.
702fn serialize_to_query<T: Serialize>(params: &T) -> Result<HashMap<String, String>, ResourceError> {
703    let value = serde_json::to_value(params).map_err(|e| {
704        ResourceError::Http(crate::clients::HttpError::Response(
705            crate::clients::HttpResponseError {
706                code: 400,
707                message: format!("Failed to serialize params: {e}"),
708                error_reference: None,
709            },
710        ))
711    })?;
712
713    let mut query = HashMap::new();
714
715    if let Value::Object(map) = value {
716        for (key, val) in map {
717            match val {
718                Value::Null => {} // Skip null values
719                Value::String(s) => {
720                    query.insert(key, s);
721                }
722                Value::Number(n) => {
723                    query.insert(key, n.to_string());
724                }
725                Value::Bool(b) => {
726                    query.insert(key, b.to_string());
727                }
728                Value::Array(arr) => {
729                    // Convert arrays to comma-separated values
730                    let values: Vec<String> = arr
731                        .iter()
732                        .filter_map(|v| match v {
733                            Value::String(s) => Some(s.clone()),
734                            Value::Number(n) => Some(n.to_string()),
735                            _ => None,
736                        })
737                        .collect();
738                    if !values.is_empty() {
739                        query.insert(key, values.join(","));
740                    }
741                }
742                Value::Object(_) => {
743                    // For complex objects, serialize as JSON string
744                    query.insert(key, val.to_string());
745                }
746            }
747        }
748    }
749
750    Ok(query)
751}
752
753#[cfg(test)]
754mod tests {
755    use super::*;
756    use crate::rest::{ResourceOperation, ResourcePath};
757    use crate::HttpMethod;
758    use serde::{Deserialize, Serialize};
759
760    // Test resource implementation
761    #[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
762    struct MockProduct {
763        #[serde(skip_serializing_if = "Option::is_none")]
764        id: Option<u64>,
765        title: String,
766        #[serde(skip_serializing_if = "Option::is_none")]
767        vendor: Option<String>,
768    }
769
770    #[derive(Debug, Clone, Serialize, Deserialize, Default)]
771    struct MockProductParams {
772        #[serde(skip_serializing_if = "Option::is_none")]
773        limit: Option<u32>,
774        #[serde(skip_serializing_if = "Option::is_none")]
775        page_info: Option<String>,
776    }
777
778    impl RestResource for MockProduct {
779        type Id = u64;
780        type FindParams = ();
781        type AllParams = MockProductParams;
782        type CountParams = ();
783
784        const NAME: &'static str = "Product";
785        const PLURAL: &'static str = "products";
786        const PATHS: &'static [ResourcePath] = &[
787            ResourcePath::new(
788                HttpMethod::Get,
789                ResourceOperation::Find,
790                &["id"],
791                "products/{id}",
792            ),
793            ResourcePath::new(HttpMethod::Get, ResourceOperation::All, &[], "products"),
794            ResourcePath::new(
795                HttpMethod::Get,
796                ResourceOperation::Count,
797                &[],
798                "products/count",
799            ),
800            ResourcePath::new(HttpMethod::Post, ResourceOperation::Create, &[], "products"),
801            ResourcePath::new(
802                HttpMethod::Put,
803                ResourceOperation::Update,
804                &["id"],
805                "products/{id}",
806            ),
807            ResourcePath::new(
808                HttpMethod::Delete,
809                ResourceOperation::Delete,
810                &["id"],
811                "products/{id}",
812            ),
813        ];
814
815        fn get_id(&self) -> Option<Self::Id> {
816            self.id
817        }
818    }
819
820    // Nested resource for testing
821    #[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
822    struct MockVariant {
823        #[serde(skip_serializing_if = "Option::is_none")]
824        id: Option<u64>,
825        #[serde(skip_serializing_if = "Option::is_none")]
826        product_id: Option<u64>,
827        title: String,
828    }
829
830    impl RestResource for MockVariant {
831        type Id = u64;
832        type FindParams = ();
833        type AllParams = ();
834        type CountParams = ();
835
836        const NAME: &'static str = "Variant";
837        const PLURAL: &'static str = "variants";
838        const PATHS: &'static [ResourcePath] = &[
839            // Nested paths (more specific)
840            ResourcePath::new(
841                HttpMethod::Get,
842                ResourceOperation::Find,
843                &["product_id", "id"],
844                "products/{product_id}/variants/{id}",
845            ),
846            ResourcePath::new(
847                HttpMethod::Get,
848                ResourceOperation::All,
849                &["product_id"],
850                "products/{product_id}/variants",
851            ),
852            // Standalone paths (less specific)
853            ResourcePath::new(
854                HttpMethod::Get,
855                ResourceOperation::Find,
856                &["id"],
857                "variants/{id}",
858            ),
859        ];
860
861        fn get_id(&self) -> Option<Self::Id> {
862            self.id
863        }
864    }
865
866    // Mock read-only resource for testing ReadOnlyResource trait
867    #[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
868    struct MockLocation {
869        #[serde(skip_serializing_if = "Option::is_none")]
870        id: Option<u64>,
871        name: String,
872        active: bool,
873    }
874
875    impl RestResource for MockLocation {
876        type Id = u64;
877        type FindParams = ();
878        type AllParams = ();
879        type CountParams = ();
880
881        const NAME: &'static str = "Location";
882        const PLURAL: &'static str = "locations";
883        const PATHS: &'static [ResourcePath] = &[
884            ResourcePath::new(
885                HttpMethod::Get,
886                ResourceOperation::Find,
887                &["id"],
888                "locations/{id}",
889            ),
890            ResourcePath::new(HttpMethod::Get, ResourceOperation::All, &[], "locations"),
891            ResourcePath::new(
892                HttpMethod::Get,
893                ResourceOperation::Count,
894                &[],
895                "locations/count",
896            ),
897            // No Create, Update, or Delete paths - read-only resource
898        ];
899
900        fn get_id(&self) -> Option<Self::Id> {
901            self.id
902        }
903    }
904
905    // Implement ReadOnlyResource for MockLocation
906    impl ReadOnlyResource for MockLocation {}
907
908    #[test]
909    fn test_resource_defines_name_and_paths() {
910        assert_eq!(MockProduct::NAME, "Product");
911        assert_eq!(MockProduct::PLURAL, "products");
912        assert!(!MockProduct::PATHS.is_empty());
913    }
914
915    #[test]
916    fn test_get_id_returns_none_for_new_resource() {
917        let product = MockProduct {
918            id: None,
919            title: "New".to_string(),
920            vendor: None,
921        };
922        assert!(product.get_id().is_none());
923    }
924
925    #[test]
926    fn test_get_id_returns_some_for_existing_resource() {
927        let product = MockProduct {
928            id: Some(123),
929            title: "Existing".to_string(),
930            vendor: None,
931        };
932        assert_eq!(product.get_id(), Some(123));
933    }
934
935    #[test]
936    fn test_build_full_path_without_prefix() {
937        let path = MockProduct::build_full_path("products/123");
938        assert_eq!(path, "products/123");
939    }
940
941    #[test]
942    fn test_serialize_to_query_handles_basic_types() {
943        #[derive(Serialize)]
944        struct Params {
945            limit: u32,
946            title: String,
947            active: bool,
948        }
949
950        let params = Params {
951            limit: 50,
952            title: "Test".to_string(),
953            active: true,
954        };
955
956        let query = serialize_to_query(&params).unwrap();
957        assert_eq!(query.get("limit"), Some(&"50".to_string()));
958        assert_eq!(query.get("title"), Some(&"Test".to_string()));
959        assert_eq!(query.get("active"), Some(&"true".to_string()));
960    }
961
962    #[test]
963    fn test_serialize_to_query_skips_none() {
964        #[derive(Serialize)]
965        struct Params {
966            #[serde(skip_serializing_if = "Option::is_none")]
967            limit: Option<u32>,
968            #[serde(skip_serializing_if = "Option::is_none")]
969            page_info: Option<String>,
970        }
971
972        let params = Params {
973            limit: Some(50),
974            page_info: None,
975        };
976
977        let query = serialize_to_query(&params).unwrap();
978        assert_eq!(query.get("limit"), Some(&"50".to_string()));
979        assert!(!query.contains_key("page_info"));
980    }
981
982    #[test]
983    fn test_serialize_to_query_handles_arrays() {
984        #[derive(Serialize)]
985        struct Params {
986            ids: Vec<u64>,
987        }
988
989        let params = Params { ids: vec![1, 2, 3] };
990
991        let query = serialize_to_query(&params).unwrap();
992        assert_eq!(query.get("ids"), Some(&"1,2,3".to_string()));
993    }
994
995    #[test]
996    fn test_nested_resource_path_selection() {
997        // With product_id available, should select nested path for All
998        let path = get_path(MockVariant::PATHS, ResourceOperation::All, &["product_id"]);
999        assert!(path.is_some());
1000        assert_eq!(path.unwrap().template, "products/{product_id}/variants");
1001
1002        // With both product_id and id, should select nested Find path
1003        let path = get_path(
1004            MockVariant::PATHS,
1005            ResourceOperation::Find,
1006            &["product_id", "id"],
1007        );
1008        assert!(path.is_some());
1009        assert_eq!(
1010            path.unwrap().template,
1011            "products/{product_id}/variants/{id}"
1012        );
1013
1014        // With only id, should select standalone Find path
1015        let path = get_path(MockVariant::PATHS, ResourceOperation::Find, &["id"]);
1016        assert!(path.is_some());
1017        assert_eq!(path.unwrap().template, "variants/{id}");
1018    }
1019
1020    #[test]
1021    fn test_resource_trait_bounds() {
1022        fn assert_trait_bounds<T: RestResource>() {}
1023        assert_trait_bounds::<MockProduct>();
1024        assert_trait_bounds::<MockVariant>();
1025    }
1026
1027    #[test]
1028    fn test_resource_key_lowercase() {
1029        assert_eq!(MockProduct::resource_key(), "product");
1030        assert_eq!(MockVariant::resource_key(), "variant");
1031    }
1032
1033    #[test]
1034    fn test_read_only_resource_marker_trait_compiles() {
1035        // Test that ReadOnlyResource trait compiles correctly as a marker trait
1036        fn assert_read_only<T: ReadOnlyResource>() {}
1037        assert_read_only::<MockLocation>();
1038    }
1039
1040    #[test]
1041    fn test_read_only_resource_trait_bounds_with_rest_resource() {
1042        // Test that ReadOnlyResource requires RestResource as a supertrait
1043        fn assert_both_traits<T: ReadOnlyResource + RestResource>() {}
1044        assert_both_traits::<MockLocation>();
1045    }
1046
1047    #[test]
1048    fn test_read_only_resource_has_only_get_paths() {
1049        // Verify that read-only resources only have GET operations defined
1050        let paths = MockLocation::PATHS;
1051
1052        // Should have Find, All, Count paths
1053        assert!(get_path(paths, ResourceOperation::Find, &["id"]).is_some());
1054        assert!(get_path(paths, ResourceOperation::All, &[]).is_some());
1055        assert!(get_path(paths, ResourceOperation::Count, &[]).is_some());
1056
1057        // Should NOT have Create, Update, Delete paths
1058        assert!(get_path(paths, ResourceOperation::Create, &[]).is_none());
1059        assert!(get_path(paths, ResourceOperation::Update, &["id"]).is_none());
1060        assert!(get_path(paths, ResourceOperation::Delete, &["id"]).is_none());
1061    }
1062
1063    #[test]
1064    fn test_read_only_resource_implements_rest_resource() {
1065        // Verify that implementing ReadOnlyResource still allows RestResource methods
1066        let location = MockLocation {
1067            id: Some(123),
1068            name: "Main Warehouse".to_string(),
1069            active: true,
1070        };
1071
1072        assert_eq!(location.get_id(), Some(123));
1073        assert_eq!(MockLocation::NAME, "Location");
1074        assert_eq!(MockLocation::PLURAL, "locations");
1075        assert_eq!(MockLocation::resource_key(), "location");
1076    }
1077}