steam_workshop_api/
lib.rs

1//! # steam_workshop_api
2//!
3//! This library provides access to the steam web apis. Uses reqwest::blocking under the hood
4//! # Getting Started
5//! To access any web api that requires no authentication (file details) you need to create a new instance:
6//! ```rust
7//! use steam_workshop_api::SteamWorkshop;
8//!
9//! let wsclient = SteamWorkshop::new();
10//! wsclient.get_published_file_details(&["fileid1".to_string()]);
11//! ```
12//! 
13//! # Using Authorized Methods 
14//! 
15//! Authorized methods are behind the AuthedWorkshop struct, which can be generated from a Workshop instance:
16//! ```rust
17//! use steam_workshop_api::SteamWorkshop;
18//! 
19//! let mut wsclient = SteamWorkshop::new();
20//! wsclient.set_apikey(Some("MY_API_KEY".to_string()));
21//! wsclient.can_subscribe("blah");
22//! ```
23//! # Using Proxied Methods 
24//! 
25//! Proxied methods are identical to AuthedWorkshop, except can use a third party server to proxy (and keep the appkey private)
26//! ```rust
27//! use steam_workshop_api::{SearchOptions, SteamWorkshop};
28//!
29//! let mut wsclient = SteamWorkshop::new();
30//! wsclient.set_proxy_domain(Some("steamproxy.example.com".to_string()));
31//! // Does not require .set_apikey, as the proxy will handle it
32//! wsclient.search_items(&SearchOptions {
33//!     query: "blah".to_string(),
34//!     count: 10,
35//!     app_id: 550,
36//!     cursor: None,
37//!     required_tags: None,
38//!     excluded_tags: None,
39//! });
40//! ```
41
42mod search;
43
44static USER_AGENT: LazyLock<String> = LazyLock::new(|| format!("{}/v{}", "rs-steamwebapi", env!("CARGO_PKG_VERSION")) );
45
46use serde::{Deserialize, Serialize};
47use std::{fs, path::Path, collections::HashMap, fmt};
48use std::fmt::{Debug, Display, Formatter};
49use std::fs::DirEntry;
50use std::sync::LazyLock;
51use reqwest::blocking::Client;
52use serde_json::Value;
53use crate::search::{WSSearchItem, WSSearchResponse};
54
55#[derive(Serialize, Deserialize, Clone, PartialEq)]
56pub struct WorkshopItem {
57    pub result: i8,
58    pub publishedfileid: String,
59    pub creator: String,
60    #[serde(alias = "creator_appid")]
61    pub creator_app_id: u32,
62    #[serde(alias = "consumer_appid")]
63    pub consumer_app_id: u32,
64    pub filename: String,
65    pub file_size: String,
66    pub file_url: Option<String>,
67    pub preview_url: String,
68    pub hcontent_file: String,
69    pub hcontent_preview: String,
70    pub title: String,
71    #[serde(alias = "file_description")]
72    pub description: String,
73    pub time_created: usize,
74    pub time_updated: usize,
75    pub subscriptions: u32,
76    pub favorited: u32,
77    pub views: u32,
78    pub tags: Vec<WorkshopItemTag>,
79    pub visibility: u8
80}
81
82pub enum PublishedFileQueryType {
83    RankedByVote = 0,
84    RankedByPublicationDate = 1,
85    AcceptedForGameRankedByAcceptanceDate = 2,
86    RankedByTrend = 3,
87    FavoritedByFriendsRankedByPublicationDate = 4,
88    CreatedByFriendsRankedByPublicationDate = 5,
89    RankedByNumTimesReported = 6,
90    CreatedByFollowedUsersRankedByPublicationDate = 7,
91    NotYetRated = 8,
92    RankedByTotalUniqueSubscriptions = 9,
93    RankedByTotalVotesAsc = 10,
94    RankedByVotesUp = 11,
95    RankedByTextSearch = 12,
96    RankedByPlaytimeTrend = 13,
97    RankedByTotalPlaytime = 14,
98    RankedByAveragePlaytimeTrend = 15,
99    RankedByLifetimeAveragePlaytime = 16,
100    RankedByPlaytimeSessionsTrend = 17,
101    RankedByLifetimePlaytimeSessions = 18,
102    RankedByInappropriateContentRating = 19,
103    RankedByBanContentCheck = 20,
104    RankedByLastUpdatedDate = 21,
105}
106
107#[derive(Clone)]
108pub struct SearchTagOptions {
109    pub tags: Vec<String>,
110    /// If true, requires all tags in tags to be set.
111    /// If false, at least one must match
112    pub require_all: bool
113}
114pub enum QueryType {
115    /// Sort by trend.
116    /// Days if set, will only return items within the range provided.
117    /// Range must be [1, 7]
118    RankedByTrend { days: Option<u32> }
119}
120#[derive(Default, Clone)]
121pub struct SearchOptions {
122    pub count: u32,
123    pub app_id: u32,
124    pub query: String,
125    /// If none, will use "*",
126    pub cursor: Option<String>,
127    pub required_tags: Option<SearchTagOptions>,
128    /// Ignore any entries with these tags
129    pub excluded_tags: Option<Vec<String>>
130}
131
132pub struct SearchResult {
133    pub options: SearchOptions,
134
135    pub next_cursor: String,
136    pub items: Vec<WSSearchItem>,
137    pub total_items: u32
138}
139
140impl SearchResult {
141    pub fn next(&self, ws: &SteamWorkshop) -> Result<SearchResult, Error> {
142        ws.search_items(&self.options)
143    }
144}
145
146#[derive(Serialize, Deserialize, Clone, PartialEq)]
147pub struct ItemResponse {
148    pub result: i8,
149    pub publishedfileid: String,
150}
151
152impl fmt::Display for WorkshopItem {
153    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
154        write!(f, "{} - {}", self.title, self.publishedfileid)
155    }
156}
157
158#[derive(Serialize, Deserialize, Clone, PartialEq, Debug)]
159pub struct WorkshopItemTag {
160    pub tag: String,
161    #[serde(rename = "display_name")]
162    pub display_name: Option<String>,
163}
164
165// WORKSHOP ITEMS:
166#[doc(hidden)]
167#[derive(Serialize, Deserialize)]
168struct WSResponse<T> {
169    response: T
170}
171
172// WORKSHOP COLLECTIONS:
173#[doc(hidden)]
174#[derive(Serialize, Deserialize)]
175struct WSCollectionResponse {
176    response: WSCollectionResponseBody
177}
178#[doc(hidden)]
179#[derive(Serialize, Deserialize)]
180struct WSCollectionResponseBody {
181    result: u8,
182    resultcount: u8,
183    collectiondetails: Vec<WSCollectionBody>
184}
185#[doc(hidden)]
186#[derive(Serialize, Deserialize)]
187struct WSCollectionBody {
188    publishedfileid: String,
189    result: u8,
190    children: Vec<WSCollectionChildren>
191}
192#[doc(hidden)]
193#[derive(Serialize, Deserialize)]
194struct WSCollectionChildren {
195    publishedfileid: String,
196    sortorder: u8,
197    filetype: u8
198}
199#[derive(Clone)]
200pub struct SteamWorkshop {
201    client: Client,
202    apikey: Option<String>,
203    request_domain: String
204}
205
206pub enum Error {
207    /// Request requires authorization either via an apikey, or using a domain proxy that uses their own key
208    NotAuthorized,
209    RequestError(reqwest::Error),
210    BadRequest(String)
211}
212
213impl Debug for Error {
214    fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result {
215        match self {
216            Error::NotAuthorized => write!(f, "Request is not authorized, please use .set_apikey, or .set_proxy_domain"),
217            Error::RequestError(e) => write!(f, "request error: {}", e),
218            Error::BadRequest(e) => write!(f, "bad request data: {}", e),
219        }
220    }
221}
222
223impl Display for Error {
224    fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result {
225        match self {
226            Error::NotAuthorized => write!(f, "Not authorized"),
227            Error::RequestError(e) => write!(f, "Request Error: {}", e),
228            Error::BadRequest(e) => write!(f, "Incorrect request: {}", e),
229        }
230    }
231}
232
233impl std::error::Error for Error {}
234
235#[allow(dead_code)]
236impl SteamWorkshop {
237    ///Creates a new workshop instance, client will be auto created if None
238    pub fn new() -> SteamWorkshop {
239        let client= Client::new();
240        SteamWorkshop::new_with_client(client)
241    }
242    pub fn new_with_client(client: Client) -> SteamWorkshop {
243        SteamWorkshop {
244            client,
245            request_domain: "api.steampowered.com".to_string(),
246            apikey: None
247        }
248    }
249
250    ///Gets an authorized workshop, allows access to methods that require api keys.
251    ///Get api keys from https://steamcommunity.com/dev/apikey
252    pub fn set_apikey(&mut self, apikey: Option<String>) {
253        self.apikey = apikey;
254    }
255
256    /// Will change the domain that requests are made to, allowing you to proxy api.steampowered.com
257    pub fn set_proxy_domain(&mut self, proxy_domain: Option<String>) {
258        self.request_domain = proxy_domain.unwrap_or("api.steampowered.com".to_string());
259    }
260
261    /// Returns DirEntry for all *.vpk files in a directory.
262    pub fn get_vpks_in_folder(dir: &Path) -> Result<Vec<DirEntry>, String> {
263        let entries = fs::read_dir(dir).map_err(|e| e.to_string())?;
264        let mut files: Vec<DirEntry> = Vec::new();
265        for entry in entries {
266            let entry = entry.map_err(|e| e.to_string())?;
267            let file_name = entry.file_name();
268            let file_name = file_name.to_string_lossy();
269            if file_name.ends_with(".vpk") {
270                files.push(entry)
271            }
272        }
273        return Ok(files);
274    }
275
276    /// Fetches the latest WorkshopItem per each addon id
277    /// Steam API only allows 100 entries at once, will have an api error if more given
278    pub fn get_published_file_details(&self, fileids: &[String]) -> Result<Vec<WorkshopItem>, Error> {
279        let mut params = HashMap::new();
280        let length = fileids.len().to_string();
281        params.insert("itemcount".to_string(), length);
282        for (i, vpk) in fileids.iter().enumerate() {
283            if !vpk.parse::<u64>().is_ok() {
284                return Err(Error::BadRequest(format!("Item is not valid publishedfileid: {}", vpk)));
285            }
286            let name = format!("publishedfileids[{i}]", i=i);
287            params.insert(name, vpk.to_string());
288        }
289        let mut details = self.client
290            .post(format!("https://{}/ISteamRemoteStorage/GetPublishedFileDetails/v1/", self.request_domain))
291            .header("User-Agent", &USER_AGENT.to_string())
292            .form(&params)
293            .send().map_err(|e| Error::RequestError(e))?
294            .error_for_status().map_err(|e| Error::RequestError(e))?
295            .json::<Value>().map_err(|e| Error::RequestError(e))?;
296
297        Ok(details["response"]["publishedfiledetails"].as_array_mut().unwrap().iter_mut()
298            .filter(|v| v["result"] == 1)
299            .map(|v| serde_json::from_value(v.take()).unwrap())
300            .collect()
301        )
302    }
303
304    /// Gets the collection details (all the children of this item).
305    /// Returns a list of children fileids which can be sent directly to get_published_file_details()
306    /// Will return Ok(None) if the item is not a collection.
307    pub fn get_collection_details(&self, fileid: &str) -> Result<Option<Vec<String>>, Error> {
308        let mut params = HashMap::new();
309        params.insert("collectioncount", "1");
310        params.insert("publishedfileids[0]", &fileid);
311        let details: WSCollectionResponse = self.client
312            .post(format!("https://{}/ISteamRemoteStorage/GetCollectionDetails/v1/", self.request_domain))
313            .header("User-Agent", USER_AGENT.to_string())
314            .form(&params)
315            .send().map_err(|e| Error::RequestError(e))?
316            .error_for_status().map_err(|e| Error::RequestError(e))?
317            .json::<WSCollectionResponse>().map_err(|e| Error::RequestError(e))?;
318
319        if details.response.resultcount > 0 {
320            let mut ids: Vec<String>  = Vec::new();
321            for children in &details.response.collectiondetails[0].children {
322                ids.push(children.publishedfileid.to_string());
323            }
324            Ok(Some(ids))
325        } else {
326            Ok(None)
327        }
328    }
329
330    /// Searches for workshop items, returns their file ids.
331    /// REQUIRES steam apikey or a proxy domain
332    pub fn search_items(&self, options: &SearchOptions) -> Result<SearchResult, Error> {
333        if self.apikey.is_none() || self.request_domain != "api.steampowered.com" {
334            return Err(Error::NotAuthorized)
335        }
336        let apikey: &str = self.apikey.as_deref().unwrap_or("");
337        let appid = options.app_id.to_string();
338        let mut query: Vec<(&str, String)> = vec![
339            ("page", "1".to_string()),
340            ("numperpage", options.count.to_string()),
341            ("cursor", options.cursor.as_deref().unwrap_or("*").to_string()),
342            ("search_text", options.query.to_string()),
343            ("appid", appid.clone()),
344            ("creator_appid", appid),
345            ("return_metadata", "1".to_string()),
346            ("key", apikey.to_string()),
347        ];
348        if let Some(rt) = &options.required_tags {
349            query.push(("requiredtags", rt.tags.join(",")));
350            query.push(("match_all_tags", if rt.require_all { "1".to_string() } else { "0".to_string() }));
351        }
352        if let Some(tags) = &options.excluded_tags {
353            query.push(("excludedtags", tags.join(",")));
354        }
355        let details = self.client
356            .get(format!("https://{}/IPublishedFileService/QueryFiles/v1/?", self.request_domain))
357            .header("User-Agent", USER_AGENT.to_string())
358            .query(&query)
359            .body("")
360            .send()
361            .map_err(|e| Error::RequestError(e))?
362            .json::<WSResponse<WSSearchResponse>>()
363            .map_err(|e| Error::RequestError(e))?;
364        let details = details.response;
365        let mut next_options = options.clone();
366        let next_cursor = details.next_cursor.expect("no cursor found");
367        next_options.cursor = Some(next_cursor.clone());
368
369        Ok(SearchResult {
370            options: next_options,
371            next_cursor,
372            items: details.publishedfiledetails,
373            total_items: details.total
374        })
375    }
376
377    /// Check if the user (of apikey) can subscribe to the published file
378    /// REQUIRES apikey, cannot use proxy.
379    pub fn can_subscribe(&self, publishedfileid: &str) -> Result<bool, Error> {
380        if self.apikey.is_none() {
381            return Err(Error::NotAuthorized)
382        }
383
384        let details: Value = self.client
385            .get(format!("https://{}/IPublishedFileService/CanSubscribe/v1/?", self.request_domain))
386            .header("User-Agent", USER_AGENT.to_string())
387            .query(&[
388                "key", &self.apikey.as_ref().unwrap(),
389                "publishedfileid", publishedfileid
390            ])
391            .send().map_err(|e| Error::RequestError(e))?
392            .error_for_status().map_err(|e| Error::RequestError(e))?
393            .json().map_err(|e| Error::RequestError(e))?;
394        Ok(details["response"]["can_subscribe"].as_bool().unwrap_or(false))
395    }
396
397    /// Makes the user (of apikey) subscribe to item.
398    /// **REQUIRES apikey**, cannot use proxy.
399    ///
400    /// # Arguments
401    ///
402    /// * `include_dependencies` - If true, will automatically subscribe to all dependencies of given id
403    ///
404    pub fn subscribe(&self, publishedfileid: &str, include_dependencies: bool) -> Result<(), Error> {
405        if self.apikey.is_none() {
406            return Err(Error::NotAuthorized)
407        }
408        self.client
409            .post(format!("https://{}/IPublishedFileService/Subscribe/v1/?", self.request_domain))
410            .header("User-Agent", USER_AGENT.to_string())
411            .body("")
412            .query(&[
413                ("list_type", "1"),
414                ("key", &self.apikey.as_deref().unwrap()),
415                ("publishedfileid", publishedfileid),
416                ("include_dependencies", if include_dependencies { "1" } else { "0" })
417            ])
418            .send().map_err(|e| Error::RequestError(e))?
419            .error_for_status().map_err(|e| Error::RequestError(e))?;
420        Ok(())
421    }
422
423    /// Makes the user (of apikey) unsubscribe from an item.
424    /// **REQUIRES apikey**, cannot use proxy.
425    pub fn unsubscribe(&self, publishedfileid: &str) -> Result<(), Error> {
426        if self.apikey.is_none() {
427            return Err(Error::NotAuthorized)
428        }
429        self.client
430            .post(format!("https://{}/IPublishedFileService/Unsubscribe/v1/?", self.request_domain))
431            .header("User-Agent", USER_AGENT.to_string())
432            .body("")
433            .query(&[
434                ("list_type", "1"),
435                ("key", &self.apikey.as_deref().unwrap()),
436                ("publishedfileid", publishedfileid),
437            ])
438            .send().map_err(|e| Error::RequestError(e))?
439            .error_for_status().map_err(|e| Error::RequestError(e))?;
440        Ok(())
441    }
442}