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(¶ms).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(¶ms).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(¶ms).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}