Skip to main content

shopify_sdk/rest/resources/v2025_10/
location.rs

1//! Location resource implementation.
2//!
3//! This module provides the [`Location`] resource for accessing store locations
4//! in a Shopify store. Locations represent physical places where a merchant
5//! stores, sells, or ships inventory.
6//!
7//! # Read-Only Resource
8//!
9//! Location is a read-only resource that implements the [`ReadOnlyResource`] marker trait.
10//! It only supports GET operations (find, all, count) and does not have Create, Update,
11//! or Delete capabilities through the REST API.
12//!
13//! # Example
14//!
15//! ```rust,ignore
16//! use shopify_sdk::rest::{RestResource, ResourceResponse, ReadOnlyResource};
17//! use shopify_sdk::rest::resources::v2025_10::{Location, LocationListParams};
18//!
19//! // Find a single location
20//! let location = Location::find(&client, 123, None).await?;
21//! println!("Location: {}", location.name.as_deref().unwrap_or(""));
22//!
23//! // List all locations
24//! let locations = Location::all(&client, None).await?;
25//! for location in locations.iter() {
26//!     println!("Location: {} - {}", location.name.as_deref().unwrap_or(""), location.city.as_deref().unwrap_or(""));
27//! }
28//!
29//! // Count locations
30//! let count = Location::count(&client, None).await?;
31//! println!("Total locations: {}", count);
32//!
33//! // Get inventory levels at a location
34//! let levels = location.inventory_levels(&client, None).await?;
35//! ```
36
37use std::collections::HashMap;
38
39use chrono::{DateTime, Utc};
40use serde::{Deserialize, Serialize};
41
42use crate::clients::RestClient;
43use crate::rest::{ReadOnlyResource, ResourceError, ResourceOperation, ResourcePath, RestResource};
44use crate::HttpMethod;
45
46/// A location in a Shopify store.
47///
48/// Locations represent physical places where a merchant stores, sells, or ships
49/// inventory. This includes retail stores, warehouses, and fulfillment centers.
50///
51/// # Read-Only Resource
52///
53/// This resource implements the [`ReadOnlyResource`] marker trait, indicating
54/// that it only supports read operations. Locations cannot be created, updated,
55/// or deleted through the REST API.
56///
57/// # Fields
58///
59/// All fields are read-only:
60/// - `id` - The unique identifier of the location
61/// - `name` - The name of the location
62/// - `address1`, `address2` - Street address lines
63/// - `city`, `province`, `province_code` - City and province/state
64/// - `country`, `country_code` - Country information
65/// - `zip` - Postal/ZIP code
66/// - `phone` - Phone number
67/// - `active` - Whether the location is active
68/// - `legacy` - Whether the location is a legacy location
69/// - `localized_country_name`, `localized_province_name` - Localized names
70/// - `created_at`, `updated_at` - Timestamps
71/// - `admin_graphql_api_id` - GraphQL API ID
72#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq, Eq)]
73pub struct Location {
74    /// The unique identifier of the location.
75    #[serde(skip_serializing)]
76    pub id: Option<u64>,
77
78    /// The name of the location.
79    #[serde(skip_serializing)]
80    pub name: Option<String>,
81
82    /// The first line of the address.
83    #[serde(skip_serializing)]
84    pub address1: Option<String>,
85
86    /// The second line of the address.
87    #[serde(skip_serializing)]
88    pub address2: Option<String>,
89
90    /// The city.
91    #[serde(skip_serializing)]
92    pub city: Option<String>,
93
94    /// The province or state name.
95    #[serde(skip_serializing)]
96    pub province: Option<String>,
97
98    /// The two-letter province/state code.
99    #[serde(skip_serializing)]
100    pub province_code: Option<String>,
101
102    /// The country name.
103    #[serde(skip_serializing)]
104    pub country: Option<String>,
105
106    /// The two-letter country code (ISO 3166-1 alpha-2).
107    #[serde(skip_serializing)]
108    pub country_code: Option<String>,
109
110    /// The localized country name.
111    #[serde(skip_serializing)]
112    pub localized_country_name: Option<String>,
113
114    /// The localized province name.
115    #[serde(skip_serializing)]
116    pub localized_province_name: Option<String>,
117
118    /// The ZIP or postal code.
119    #[serde(skip_serializing)]
120    pub zip: Option<String>,
121
122    /// The phone number.
123    #[serde(skip_serializing)]
124    pub phone: Option<String>,
125
126    /// Whether the location is active.
127    #[serde(skip_serializing)]
128    pub active: Option<bool>,
129
130    /// Whether this is a legacy location.
131    #[serde(skip_serializing)]
132    pub legacy: Option<bool>,
133
134    /// When the location was created.
135    #[serde(skip_serializing)]
136    pub created_at: Option<DateTime<Utc>>,
137
138    /// When the location was last updated.
139    #[serde(skip_serializing)]
140    pub updated_at: Option<DateTime<Utc>>,
141
142    /// The admin GraphQL API ID for this location.
143    #[serde(skip_serializing)]
144    pub admin_graphql_api_id: Option<String>,
145}
146
147impl Location {
148    /// Retrieves inventory levels at this location.
149    ///
150    /// Sends a GET request to `/admin/api/{version}/locations/{id}/inventory_levels.json`.
151    ///
152    /// # Arguments
153    ///
154    /// * `client` - The REST client to use for the request
155    /// * `params` - Optional parameters for the request
156    ///
157    /// # Returns
158    ///
159    /// A vector of inventory levels at this location.
160    ///
161    /// # Errors
162    ///
163    /// Returns [`ResourceError::PathResolutionFailed`] if the location has no ID.
164    ///
165    /// # Example
166    ///
167    /// ```rust,ignore
168    /// let location = Location::find(&client, 123, None).await?.into_inner();
169    /// let levels = location.inventory_levels(&client, None).await?;
170    /// for level in &levels {
171    ///     println!("Item {} has {} available", level.inventory_item_id.unwrap_or(0), level.available.unwrap_or(0));
172    /// }
173    /// ```
174    pub async fn inventory_levels(
175        &self,
176        client: &RestClient,
177        params: Option<LocationInventoryLevelsParams>,
178    ) -> Result<Vec<super::InventoryLevel>, ResourceError> {
179        let id = self.get_id().ok_or(ResourceError::PathResolutionFailed {
180            resource: Self::NAME,
181            operation: "inventory_levels",
182        })?;
183
184        let path = format!("locations/{id}/inventory_levels");
185
186        // Build query params
187        let query = params
188            .map(|p| {
189                let value = serde_json::to_value(&p).map_err(|e| {
190                    ResourceError::Http(crate::clients::HttpError::Response(
191                        crate::clients::HttpResponseError {
192                            code: 400,
193                            message: format!("Failed to serialize params: {e}"),
194                            error_reference: None,
195                        },
196                    ))
197                })?;
198
199                let mut query = HashMap::new();
200                if let serde_json::Value::Object(map) = value {
201                    for (key, val) in map {
202                        match val {
203                            serde_json::Value::String(s) => {
204                                query.insert(key, s);
205                            }
206                            serde_json::Value::Number(n) => {
207                                query.insert(key, n.to_string());
208                            }
209                            serde_json::Value::Bool(b) => {
210                                query.insert(key, b.to_string());
211                            }
212                            _ => {}
213                        }
214                    }
215                }
216                Ok::<_, ResourceError>(query)
217            })
218            .transpose()?
219            .filter(|q| !q.is_empty());
220
221        let response = client.get(&path, query).await?;
222
223        if !response.is_ok() {
224            return Err(ResourceError::from_http_response(
225                response.code,
226                &response.body,
227                Self::NAME,
228                Some(&id.to_string()),
229                response.request_id(),
230            ));
231        }
232
233        // Parse the response - inventory levels are wrapped in "inventory_levels" key
234        let levels: Vec<super::InventoryLevel> = response
235            .body
236            .get("inventory_levels")
237            .ok_or_else(|| {
238                ResourceError::Http(crate::clients::HttpError::Response(
239                    crate::clients::HttpResponseError {
240                        code: response.code,
241                        message: "Missing 'inventory_levels' in response".to_string(),
242                        error_reference: response.request_id().map(ToString::to_string),
243                    },
244                ))
245            })
246            .and_then(|v| {
247                serde_json::from_value(v.clone()).map_err(|e| {
248                    ResourceError::Http(crate::clients::HttpError::Response(
249                        crate::clients::HttpResponseError {
250                            code: response.code,
251                            message: format!("Failed to deserialize inventory levels: {e}"),
252                            error_reference: response.request_id().map(ToString::to_string),
253                        },
254                    ))
255                })
256            })?;
257
258        Ok(levels)
259    }
260}
261
262impl RestResource for Location {
263    type Id = u64;
264    type FindParams = LocationFindParams;
265    type AllParams = LocationListParams;
266    type CountParams = LocationCountParams;
267
268    const NAME: &'static str = "Location";
269    const PLURAL: &'static str = "locations";
270
271    /// Paths for the Location resource.
272    ///
273    /// Location is a READ-ONLY resource. Only GET operations are available:
274    /// - Find: GET `/locations/{id}`
275    /// - All: GET `/locations`
276    /// - Count: GET `/locations/count`
277    ///
278    /// No Create, Update, or Delete paths are defined.
279    const PATHS: &'static [ResourcePath] = &[
280        ResourcePath::new(
281            HttpMethod::Get,
282            ResourceOperation::Find,
283            &["id"],
284            "locations/{id}",
285        ),
286        ResourcePath::new(HttpMethod::Get, ResourceOperation::All, &[], "locations"),
287        ResourcePath::new(
288            HttpMethod::Get,
289            ResourceOperation::Count,
290            &[],
291            "locations/count",
292        ),
293        // No Create, Update, or Delete paths - read-only resource
294    ];
295
296    fn get_id(&self) -> Option<Self::Id> {
297        self.id
298    }
299}
300
301/// Marker trait implementation indicating Location is read-only.
302impl ReadOnlyResource for Location {}
303
304/// Parameters for finding a single location.
305#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq, Eq)]
306pub struct LocationFindParams {
307    // No specific find params for locations
308}
309
310/// Parameters for listing locations.
311#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq, Eq)]
312pub struct LocationListParams {
313    // No specific list params for locations
314}
315
316/// Parameters for counting locations.
317#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq, Eq)]
318pub struct LocationCountParams {
319    // No specific count params for locations
320}
321
322/// Parameters for getting inventory levels at a location.
323#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq, Eq)]
324pub struct LocationInventoryLevelsParams {
325    /// Maximum number of results to return (default: 50, max: 250).
326    #[serde(skip_serializing_if = "Option::is_none")]
327    pub limit: Option<u32>,
328
329    /// Cursor for pagination.
330    #[serde(skip_serializing_if = "Option::is_none")]
331    pub page_info: Option<String>,
332}
333
334#[cfg(test)]
335mod tests {
336    use super::*;
337    use crate::rest::{get_path, ResourceOperation};
338
339    #[test]
340    fn test_location_implements_read_only_resource() {
341        // Test that Location implements ReadOnlyResource marker trait
342        fn assert_read_only<T: ReadOnlyResource>() {}
343        assert_read_only::<Location>();
344    }
345
346    #[test]
347    fn test_location_has_only_get_paths() {
348        // Should have Find, All, Count paths
349        assert!(get_path(Location::PATHS, ResourceOperation::Find, &["id"]).is_some());
350        assert!(get_path(Location::PATHS, ResourceOperation::All, &[]).is_some());
351        assert!(get_path(Location::PATHS, ResourceOperation::Count, &[]).is_some());
352
353        // Should NOT have Create, Update, Delete paths
354        assert!(get_path(Location::PATHS, ResourceOperation::Create, &[]).is_none());
355        assert!(get_path(Location::PATHS, ResourceOperation::Update, &["id"]).is_none());
356        assert!(get_path(Location::PATHS, ResourceOperation::Delete, &["id"]).is_none());
357    }
358
359    #[test]
360    fn test_location_deserialization() {
361        let json = r#"{
362            "id": 655441491,
363            "name": "Main Warehouse",
364            "address1": "123 Main St",
365            "address2": "Suite 100",
366            "city": "New York",
367            "province": "New York",
368            "province_code": "NY",
369            "country": "United States",
370            "country_code": "US",
371            "localized_country_name": "United States",
372            "localized_province_name": "New York",
373            "zip": "10001",
374            "phone": "555-555-5555",
375            "active": true,
376            "legacy": false,
377            "created_at": "2024-01-15T10:30:00Z",
378            "updated_at": "2024-06-20T15:45:00Z",
379            "admin_graphql_api_id": "gid://shopify/Location/655441491"
380        }"#;
381
382        let location: Location = serde_json::from_str(json).unwrap();
383
384        assert_eq!(location.id, Some(655441491));
385        assert_eq!(location.name, Some("Main Warehouse".to_string()));
386        assert_eq!(location.address1, Some("123 Main St".to_string()));
387        assert_eq!(location.address2, Some("Suite 100".to_string()));
388        assert_eq!(location.city, Some("New York".to_string()));
389        assert_eq!(location.province, Some("New York".to_string()));
390        assert_eq!(location.province_code, Some("NY".to_string()));
391        assert_eq!(location.country, Some("United States".to_string()));
392        assert_eq!(location.country_code, Some("US".to_string()));
393        assert_eq!(
394            location.localized_country_name,
395            Some("United States".to_string())
396        );
397        assert_eq!(
398            location.localized_province_name,
399            Some("New York".to_string())
400        );
401        assert_eq!(location.zip, Some("10001".to_string()));
402        assert_eq!(location.phone, Some("555-555-5555".to_string()));
403        assert_eq!(location.active, Some(true));
404        assert_eq!(location.legacy, Some(false));
405        assert!(location.created_at.is_some());
406        assert!(location.updated_at.is_some());
407        assert_eq!(
408            location.admin_graphql_api_id,
409            Some("gid://shopify/Location/655441491".to_string())
410        );
411    }
412
413    #[test]
414    fn test_location_serialization_is_empty() {
415        // Since all fields are read-only (skip_serializing), serialization should produce empty object
416        let location = Location {
417            id: Some(655441491),
418            name: Some("Main Warehouse".to_string()),
419            city: Some("New York".to_string()),
420            active: Some(true),
421            ..Default::default()
422        };
423
424        let json = serde_json::to_string(&location).unwrap();
425        let parsed: serde_json::Value = serde_json::from_str(&json).unwrap();
426
427        // All fields should be omitted since they are read-only
428        assert_eq!(parsed, serde_json::json!({}));
429    }
430
431    #[test]
432    fn test_location_get_id_returns_correct_value() {
433        let location_with_id = Location {
434            id: Some(655441491),
435            name: Some("Warehouse".to_string()),
436            ..Default::default()
437        };
438        assert_eq!(location_with_id.get_id(), Some(655441491));
439
440        let location_without_id = Location {
441            id: None,
442            name: Some("New Location".to_string()),
443            ..Default::default()
444        };
445        assert_eq!(location_without_id.get_id(), None);
446    }
447
448    #[test]
449    fn test_location_path_constants() {
450        let find_path = get_path(Location::PATHS, ResourceOperation::Find, &["id"]);
451        assert!(find_path.is_some());
452        assert_eq!(find_path.unwrap().template, "locations/{id}");
453        assert_eq!(find_path.unwrap().http_method, HttpMethod::Get);
454
455        let all_path = get_path(Location::PATHS, ResourceOperation::All, &[]);
456        assert!(all_path.is_some());
457        assert_eq!(all_path.unwrap().template, "locations");
458
459        let count_path = get_path(Location::PATHS, ResourceOperation::Count, &[]);
460        assert!(count_path.is_some());
461        assert_eq!(count_path.unwrap().template, "locations/count");
462    }
463
464    #[test]
465    fn test_location_constants() {
466        assert_eq!(Location::NAME, "Location");
467        assert_eq!(Location::PLURAL, "locations");
468    }
469}