roboat/catalog/
mod.rs

1use crate::{Client, RoboatError};
2use request_types::AvatarSearchQueryResponse;
3
4use catalog_types::QueryLimit;
5
6// Re-export all types so that they are easily accessible from the crate root.
7pub use catalog_types::{
8    AssetType, AvatarSearchQuery, AvatarSearchQueryBuilder, BundleType, CatalogQueryLimit,
9    Category, CreatorType, Genre, Item, ItemDetails, ItemRestriction, ItemStatus, ItemType,
10    PriceStatus, QueryGenre, SalesTypeFilter, SortAggregation, SortType, Subcategory,
11};
12
13/// Types related to the avatar catalog.
14/// They are in this module because there are so many.
15pub mod catalog_types;
16mod request_types;
17
18// A useful link for the encodings for item types: https://create.roblox.com/docs/studio/catalog-api#avatar-catalog-api
19
20const ITEM_DETAILS_API: &str = "https://catalog.roblox.com/v1/catalog/items/details";
21
22/// We set this to thirty because it's unlikely to be anything else.
23const QUERY_LIMIT: QueryLimit = QueryLimit::Thirty;
24
25impl Client {
26    /// Grabs details of one or more items from <https://catalog.roblox.com/v1/catalog/items/details>.
27    /// This now supports "new" limiteds (which include ugc limiteds). Note that this is a messy,
28    /// all-encompassing endpoint that should only be used directly when necessary.
29    ///
30    /// Specialized endpoints that use this internally include: [`Client::product_id`], [`Client::product_id_bulk`],
31    /// [`Client::collectible_item_id`], and [`Client::collectible_item_id_bulk`].
32    ///
33    /// # Notes
34    /// * Does not require a valid roblosecurity.
35    /// * This endpoint will accept up to 120 items at a time.
36    /// * Will repeat once if the x-csrf-token is invalid.
37    ///
38    /// # Argument Notes
39    /// * The `id` parameter is that acts differently for this endpoint than others.
40    /// If the `item_type` is [`ItemType::Asset`], then `id` is the item ID.
41    /// Otherwise, if the `item_type` is [`ItemType::Bundle`], then `id` is the bundle ID.
42    ///
43    /// # Errors
44    /// * All errors under [Standard Errors](#standard-errors).
45    /// * All errors under [X-CSRF-TOKEN Required Errors](#x-csrf-token-required-errors).
46    ///
47    /// # Examples
48    ///
49    /// ```no_run
50    /// use roboat::catalog::{ItemType, Item};
51    /// use roboat::ClientBuilder;
52    ///
53    /// # #[tokio::main]
54    /// # async fn main() -> Result<(), Box<dyn std::error::Error>> {
55    /// let client = ClientBuilder::new().build();
56    ///
57    /// let asset = Item {
58    ///     item_type: ItemType::Asset,
59    ///     id: 1365767,
60    /// };
61    ///
62    /// let bundle = Item {
63    ///    item_type: ItemType::Bundle,
64    ///    id: 39,
65    /// };
66    ///
67    /// let ugc_limited = Item {
68    ///    item_type: ItemType::Asset,
69    ///    id: 13032232281,
70    /// };
71    ///
72    /// let items = vec![asset, bundle];
73    /// let details = client.item_details(items).await?;
74    ///
75    /// println!("Item Name: {}", details[0].name);
76    /// println!("Bundle Name: {}", details[1].name);
77    /// println!("UGC Limited Name: {} / UGC Limited Collectible ID: {}", details[2].name,
78    ///     details[2].collectible_item_id.as_ref().ok_or("No collectible ID")?);
79    ///
80    /// # Ok(())
81    /// # }
82    /// ```
83    pub async fn item_details(&self, items: Vec<Item>) -> Result<Vec<ItemDetails>, RoboatError> {
84        match self.item_details_internal(items.clone()).await {
85            Ok(x) => Ok(x),
86            Err(e) => match e {
87                RoboatError::InvalidXcsrf(new_xcsrf) => {
88                    self.set_xcsrf(new_xcsrf).await;
89
90                    self.item_details_internal(items).await
91                }
92                _ => Err(e),
93            },
94        }
95    }
96
97    /// Fetches the product ID of an item (must be an asset). Uses [`Client::item_details`] internally
98    /// (which fetches from <https://catalog.roblox.com/v1/catalog/items/details>)
99    ///
100    /// # Notes
101    /// * Does not require a valid roblosecurity.
102    /// * Will repeat once if the x-csrf-token is invalid.
103    ///
104    /// # Errors
105    /// * All errors under [Standard Errors](#standard-errors).
106    /// * All errors under [X-CSRF-TOKEN Required Errors](#x-csrf-token-required-errors).
107    ///
108    /// # Example
109    /// ```no_run
110    /// use roboat::ClientBuilder;
111    ///
112    /// const ROBLOSECURITY: &str = "roblosecurity";
113    ///
114    /// # #[tokio::main]
115    /// # async fn main() -> Result<(), Box<dyn std::error::Error>> {
116    /// let client = ClientBuilder::new().build();
117    ///
118    /// let item_id = 12345679;
119    ///
120    /// let product_id = client.product_id(item_id).await?;
121    /// # Ok(())
122    /// # }
123    /// ```
124    pub async fn product_id(&self, item_id: u64) -> Result<u64, RoboatError> {
125        let item = Item {
126            item_type: ItemType::Asset,
127            id: item_id,
128        };
129
130        let details = self.item_details(vec![item]).await?;
131
132        details
133            .first()
134            .ok_or(RoboatError::MalformedResponse)?
135            .product_id
136            .ok_or(RoboatError::MalformedResponse)
137    }
138
139    /// Fetches the product ID of multiple items (must be an asset). More efficient than calling [`Client::product_id`] repeatedly.
140    /// Uses [`Client::item_details`] internally
141    /// (which fetches from <https://catalog.roblox.com/v1/catalog/items/details>).
142    ///
143    /// # Notes
144    /// * Does not require a valid roblosecurity.
145    /// * This endpoint will accept up to 120 items at a time.
146    /// * Will repeat once if the x-csrf-token is invalid.
147    ///
148    /// # Errors
149    /// * All errors under [Standard Errors](#standard-errors).
150    /// * All errors under [X-CSRF-TOKEN Required Errors](#x-csrf-token-required-errors).
151    ///
152    /// # Example
153    /// ```no_run
154    /// use roboat::ClientBuilder;
155    ///
156    /// const ROBLOSECURITY: &str = "roblosecurity";
157    ///
158    /// # #[tokio::main]
159    /// # async fn main() -> Result<(), Box<dyn std::error::Error>> {
160    /// let client = ClientBuilder::new().build();
161    ///
162    /// let item_id_1 = 12345679;
163    /// let item_id_2 = 987654321;
164    ///
165    /// let product_ids = client.product_id_bulk(vec![item_id_1, item_id_2]).await?;
166    ///
167    /// let product_id_1 = product_ids.get(0).ok_or("No product ID 1")?;
168    /// let product_id_2 = product_ids.get(1).ok_or("No product ID 2")?;
169    ///
170    /// # Ok(())
171    /// # }
172    /// ```
173    pub async fn product_id_bulk(&self, item_ids: Vec<u64>) -> Result<Vec<u64>, RoboatError> {
174        let item_ids_len = item_ids.len();
175
176        let mut items = Vec::new();
177
178        for item_id in item_ids {
179            let item = Item {
180                item_type: ItemType::Asset,
181                id: item_id,
182            };
183
184            items.push(item);
185        }
186
187        let details = self.item_details(items).await?;
188
189        let product_ids = details
190            .iter()
191            .filter_map(|x| x.product_id)
192            .collect::<Vec<u64>>();
193
194        if product_ids.len() != item_ids_len {
195            return Err(RoboatError::MalformedResponse);
196        }
197
198        Ok(product_ids)
199    }
200
201    /// Fetches the collectible item id of a multiple non-tradeable limited (including ugc limiteds).
202    /// More efficient than calling [`Client::product_id`] repeatedly.
203    /// Uses [`Client::item_details`] internally
204    /// (which fetches from <https://catalog.roblox.com/v1/catalog/items/details>).
205    ///
206    /// # Notes
207    /// * Does not require a valid roblosecurity.
208    /// * Will repeat once if the x-csrf-token is invalid.
209    ///
210    /// # Errors
211    /// * All errors under [Standard Errors](#standard-errors).
212    /// * All errors under [X-CSRF-TOKEN Required Errors](#x-csrf-token-required-errors).
213    ///
214    /// # Example
215    /// ```no_run
216    /// use roboat::ClientBuilder;
217    ///
218    /// const ROBLOSECURITY: &str = "roblosecurity";
219    ///
220    /// # #[tokio::main]
221    /// # async fn main() -> Result<(), Box<dyn std::error::Error>> {
222    /// let client = ClientBuilder::new().build();
223    ///
224    /// let item_id = 12345679;
225    ///
226    /// let collectible_item_id = client.collectible_item_id(item_id).await?;
227    /// # Ok(())
228    /// # }
229    /// ```
230    pub async fn collectible_item_id(&self, item_id: u64) -> Result<String, RoboatError> {
231        let item = Item {
232            item_type: ItemType::Asset,
233            id: item_id,
234        };
235
236        let details = self.item_details(vec![item]).await?;
237
238        details
239            .first()
240            .ok_or(RoboatError::MalformedResponse)?
241            .collectible_item_id
242            .clone()
243            .ok_or(RoboatError::MalformedResponse)
244    }
245
246    /// Fetches the collectible item ids of multiple non-tradeable limiteds (including ugc limiteds).
247    /// More efficient than calling [`Client::collectible_item_id`] repeatedly.
248    /// Uses [`Client::item_details`] internally
249    /// (which fetches from <https://catalog.roblox.com/v1/catalog/items/details>).
250    ///
251    /// # Notes
252    /// * Does not require a valid roblosecurity.
253    /// * This endpoint will accept up to 120 items at a time.
254    /// * Will repeat once if the x-csrf-token is invalid.
255    ///
256    /// # Errors
257    /// * All errors under [Standard Errors](#standard-errors).
258    /// * All errors under [X-CSRF-TOKEN Required Errors](#x-csrf-token-required-errors).
259    ///
260    /// # Example
261    /// ```no_run
262    /// use roboat::ClientBuilder;
263    ///
264    /// const ROBLOSECURITY: &str = "roblosecurity";
265    ///
266    /// # #[tokio::main]
267    /// # async fn main() -> Result<(), Box<dyn std::error::Error>> {
268    /// let client = ClientBuilder::new().build();
269    ///
270    /// let item_id_1 = 12345679;
271    /// let item_id_2 = 987654321;
272    ///
273    /// let collectible_item_ids = client.collectible_item_id_bulk(vec![item_id_1, item_id_2]).await?;
274    ///
275    /// let collectible_item_id_1 = collectible_item_ids.get(0).ok_or("No collectible item ID 1")?;
276    /// let collectible_item_id_2 = collectible_item_ids.get(1).ok_or("No collectible item ID 2")?;
277    ///
278    /// # Ok(())
279    /// # }
280    /// ```
281    pub async fn collectible_item_id_bulk(
282        &self,
283        item_ids: Vec<u64>,
284    ) -> Result<Vec<String>, RoboatError> {
285        let item_ids_len = item_ids.len();
286
287        let mut items = Vec::new();
288
289        for item_id in item_ids {
290            let item = Item {
291                item_type: ItemType::Asset,
292                id: item_id,
293            };
294
295            items.push(item);
296        }
297
298        let details = self.item_details(items).await?;
299
300        let collectible_item_ids = details
301            .iter()
302            .filter_map(|x| x.collectible_item_id.clone())
303            .collect::<Vec<String>>();
304
305        if collectible_item_ids.len() != item_ids_len {
306            return Err(RoboatError::MalformedResponse);
307        }
308
309        Ok(collectible_item_ids)
310    }
311
312    /// Performs a search query using <https://catalog.roblox.com/v1/search/items>.
313    /// Query parameters are specified using the [`AvatarSearchQuery`] struct, which can be built using the [`AvatarSearchQueryBuilder`].
314    ///
315    /// # Notes
316    /// * Does not require a valid roblosecurity.
317    ///
318    /// # Argument Notes
319    /// * Query parameters are specified using the [`AvatarSearchQuery`] struct, which can be built using the [`AvatarSearchQueryBuilder`].
320    /// * If the Query is empty, no next page cursor will be returned.
321    ///
322    /// # Errors
323    /// * All errors under [Standard Errors](#standard-errors).
324    ///
325    /// # Examples
326    ///
327    /// ```no_run
328    /// use roboat::catalog::{Item, Category, AvatarSearchQueryBuilder};
329    /// use roboat::ClientBuilder;
330    ///
331    /// # #[tokio::main]
332    /// # async fn main() -> Result<(), Box<dyn std::error::Error>> {
333    /// let client = ClientBuilder::new().build();
334    ///
335    /// let query = AvatarSearchQueryBuilder::new()
336    ///     .keyword("cute".to_owned())
337    ///     .category(Category::Accessories)
338    ///     .build();
339    ///
340    /// let next_cursor = None;
341    ///
342    /// // Fetch the first page of results.
343    /// let (items, next_cursor) = client.avatar_catalog_search(&query, next_cursor).await?;
344    /// println!("Found {} items.", items.len());
345    /// println!("Next cursor: {}", next_cursor.clone().unwrap_or_default());
346    ///
347    /// // Fetch the next page of results.
348    /// let (items, next_cursor) = client.avatar_catalog_search(&query, next_cursor).await?;
349    /// println!("Found {} items.", items.len());
350    /// println!("Next cursor: {}", &next_cursor.clone().unwrap_or_default());
351    ///
352    /// # Ok(())
353    /// # }
354    /// ```
355    pub async fn avatar_catalog_search(
356        &self,
357        query: &AvatarSearchQuery,
358        cursor: Option<String>,
359    ) -> Result<(Vec<Item>, Option<String>), RoboatError> {
360        let formatted_url = format!(
361            "{}&limit={}&cursor={}",
362            query.to_url(),
363            QUERY_LIMIT.as_u8(),
364            cursor.unwrap_or_default()
365        );
366
367        let request_result = self.reqwest_client.get(formatted_url).send().await;
368
369        let response = Self::validate_request_result(request_result).await?;
370        let raw = Self::parse_to_raw::<AvatarSearchQueryResponse>(response).await?;
371
372        let items = raw.items;
373        let next_cursor = raw.next_page_cursor;
374
375        Ok((items, next_cursor))
376    }
377}
378
379mod internal {
380    use super::{request_types, sort_items_by_argument_order, Item, ItemDetails, ITEM_DETAILS_API};
381    use crate::XCSRF_HEADER;
382    use crate::{Client, RoboatError};
383
384    impl Client {
385        /// Used internally to fetch the details of one or more items from <https://catalog.roblox.com/v1/catalog/items/details>.
386        pub(super) async fn item_details_internal(
387            &self,
388            items: Vec<Item>,
389        ) -> Result<Vec<ItemDetails>, RoboatError> {
390            let request_body = request_types::ItemDetailsReqBody {
391                // Convert the ItemParameters to te reqwest ItemParametersReq
392                items: items
393                    .iter()
394                    .map(|x| request_types::ItemReq::from(*x))
395                    .collect(),
396            };
397
398            let request_result = self
399                .reqwest_client
400                .post(ITEM_DETAILS_API)
401                .header(XCSRF_HEADER, self.xcsrf().await)
402                .json(&request_body)
403                .send()
404                .await;
405
406            let response = Self::validate_request_result(request_result).await?;
407            let raw = Self::parse_to_raw::<request_types::ItemDetailsResponse>(response).await?;
408
409            let mut item_details = Vec::new();
410
411            for raw_details in raw.data {
412                let details = ItemDetails::try_from(raw_details)?;
413                item_details.push(details);
414            }
415
416            sort_items_by_argument_order(&mut item_details, &items);
417
418            Ok(item_details)
419        }
420    }
421}
422
423/// Makes sure that the items are in the same order as the arguments.
424///
425/// For example, if the arguments are `[1, 2, 3]` and the resulting items are `[2, 1, 3]`,
426/// then the resulting items will be `[1, 2, 3]`.
427fn sort_items_by_argument_order(items: &mut [ItemDetails], arguments: &[Item]) {
428    items.sort_by(|a, b| {
429        let a_index = arguments
430            .iter()
431            .position(|item_args| item_args.id == a.id)
432            .unwrap_or(usize::MAX);
433
434        let b_index = arguments
435            .iter()
436            .position(|item_args| item_args.id == b.id)
437            .unwrap_or(usize::MAX);
438
439        a_index.cmp(&b_index)
440    });
441}