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}