Skip to main content

steamkit/steam/webapi/
web_api.rs

1//! Calling the Steam Web API.
2//!
3//! Interfaces have explicit methods ([`AsyncInterface::call`],
4//! [`AsyncInterface::call_protobuf`], [`AsyncInterface::call_protobuf_with_request`])
5//! rather than SteamKit2's dynamic `api.ISteamDirectory.GetCMListForConnect(...)`
6//! calls. [`Interface`] is async as well.
7//!
8//! Arguments are an ordered [`WebApiArgs`] list, so the query string comes out
9//! in a stable order.
10
11use std::io::Cursor;
12use std::sync::atomic::{AtomicU64, Ordering};
13use std::sync::{Arc, OnceLock};
14use std::time::Duration;
15
16use base64::prelude::BASE64_STANDARD;
17use base64::Engine;
18use percent_encoding::{utf8_percent_encode, AsciiSet, NON_ALPHANUMERIC};
19
20use crate::base::generated::steam_language::EResult;
21use crate::error::SteamError;
22use crate::types::key_value::KeyValue;
23
24use super::web_api_request_exception::WebAPIRequestException;
25
26/// The default base address used for the Steam Web API.
27pub const DEFAULT_BASE_ADDRESS: &str = "https://api.steampowered.com/";
28
29/// The default timeout used for Web API requests.
30pub const DEFAULT_TIMEOUT: Duration = Duration::from_secs(100);
31
32/// The characters `Uri.EscapeDataString` leaves alone: `A-Z`, `a-z`, `0-9`,
33/// `-`, `_`, `.` and `~`. `percent_encoding` has no constant for exactly this
34/// set.
35fn uri_unreserved() -> &'static AsciiSet {
36    static SET: OnceLock<AsciiSet> = OnceLock::new();
37
38    SET.get_or_init(|| {
39        NON_ALPHANUMERIC
40            .remove(b'-')
41            .remove(b'_')
42            .remove(b'.')
43            .remove(b'~')
44    })
45}
46
47/// The value of a Web API argument.
48#[derive(Clone, Debug, PartialEq, Eq)]
49pub enum WebApiValue {
50    /// Text; escaped like `Uri.EscapeDataString`.
51    Text(String),
52    /// Binary; every byte is escaped as `%XX`.
53    Bytes(Vec<u8>),
54    /// Left out of the request.
55    Null,
56}
57
58impl From<String> for WebApiValue {
59    /// A text value.
60    fn from(value: String) -> Self {
61        WebApiValue::Text(value)
62    }
63}
64
65impl From<&str> for WebApiValue {
66    /// A text value.
67    fn from(value: &str) -> Self {
68        WebApiValue::Text(value.to_string())
69    }
70}
71
72impl From<Vec<u8>> for WebApiValue {
73    /// A binary value.
74    fn from(value: Vec<u8>) -> Self {
75        WebApiValue::Bytes(value)
76    }
77}
78
79impl From<i32> for WebApiValue {
80    /// The number as text.
81    fn from(value: i32) -> Self {
82        WebApiValue::Text(value.to_string())
83    }
84}
85
86impl From<u32> for WebApiValue {
87    /// The number as text.
88    fn from(value: u32) -> Self {
89        WebApiValue::Text(value.to_string())
90    }
91}
92
93impl From<i64> for WebApiValue {
94    /// The number as text.
95    fn from(value: i64) -> Self {
96        WebApiValue::Text(value.to_string())
97    }
98}
99
100impl From<u64> for WebApiValue {
101    /// The number as text.
102    fn from(value: u64) -> Self {
103        WebApiValue::Text(value.to_string())
104    }
105}
106
107impl From<bool> for WebApiValue {
108    /// The boolean as text.
109    fn from(value: bool) -> Self {
110        WebApiValue::Text(value.to_string())
111    }
112}
113
114/// A single argument of a Web API call.
115#[derive(Clone, Debug, PartialEq, Eq)]
116pub struct WebApiArgument {
117    /// The name of the argument.
118    pub name: String,
119    /// The value of the argument.
120    pub value: WebApiValue,
121}
122
123impl WebApiArgument {
124    /// Creates an argument with the given name and value.
125    pub fn new(name: impl Into<String>, value: impl Into<WebApiValue>) -> Self {
126        WebApiArgument {
127            name: name.into(),
128            value: value.into(),
129        }
130    }
131}
132
133/// The arguments of a Web API call.
134pub type WebApiArgs = Vec<WebApiArgument>;
135
136/// Builds the argument list of a Web API call. The arguments keep their order
137/// in the query string.
138pub fn web_api_args<const N: usize>(entries: [(&str, WebApiValue); N]) -> WebApiArgs {
139    entries
140        .into_iter()
141        .map(|(name, value)| WebApiArgument::new(name, value))
142        .collect()
143}
144
145/// A response to a Web API request.
146#[derive(Clone, Debug, PartialEq, Eq)]
147pub struct WebAPIResponse<TResponse> {
148    /// The result of the response.
149    pub result: EResult,
150    /// The body of the response.
151    pub body: TResponse,
152}
153
154impl<TResponse> WebAPIResponse<TResponse> {
155    /// Creates a response.
156    pub fn new(result: EResult, body: TResponse) -> Self {
157        WebAPIResponse { result, body }
158    }
159}
160
161/// Calling the Steam Web API.
162pub struct WebAPI;
163
164impl WebAPI {
165    /// The default base address of the Steam Web API. Use a `SteamConfiguration`
166    /// or the `_with_base_address` functions for a different one.
167    pub fn default_base_address() -> reqwest::Url {
168        // A valid constant, so this can't fail.
169        reqwest::Url::parse(DEFAULT_BASE_ADDRESS)
170            .expect("DEFAULT_BASE_ADDRESS is a valid absolute URL")
171    }
172
173    /// The default timeout used for Web API requests.
174    pub fn default_timeout() -> Duration {
175        DEFAULT_TIMEOUT
176    }
177
178    /// An interface for calling the given Web API interface.
179    pub fn get_interface(iface: &str, api_key: &str) -> Interface {
180        Interface::new(
181            create_default_http_client(),
182            Self::default_base_address(),
183            iface,
184            api_key,
185        )
186    }
187
188    /// An interface for calling the given Web API interface at `base_address`.
189    pub fn get_interface_with_base_address(
190        base_address: reqwest::Url,
191        iface: &str,
192        api_key: &str,
193    ) -> Interface {
194        Interface::new(
195            create_default_http_client(),
196            base_address,
197            iface,
198            api_key,
199        )
200    }
201
202    /// An async interface for calling the given Web API interface.
203    pub fn get_async_interface(iface: &str, api_key: &str) -> AsyncInterface {
204        AsyncInterface::new(
205            create_default_http_client(),
206            Self::default_base_address(),
207            iface,
208            api_key,
209        )
210    }
211
212    /// An async interface for calling the given Web API interface at `base_address`.
213    pub fn get_async_interface_with_base_address(
214        base_address: reqwest::Url,
215        iface: &str,
216        api_key: &str,
217    ) -> AsyncInterface {
218        AsyncInterface::new(
219            create_default_http_client(),
220            base_address,
221            iface,
222            api_key,
223        )
224    }
225}
226
227/// The default HTTP client for Web API interfaces.
228fn create_default_http_client() -> reqwest::Client {
229    reqwest::Client::builder()
230        .timeout(DEFAULT_TIMEOUT)
231        .build()
232        .unwrap_or_else(|_| reqwest::Client::new())
233}
234
235/// One interface of the Web API. Wraps an [`AsyncInterface`].
236pub struct Interface {
237    inner: Arc<AsyncInterface>,
238}
239
240impl Interface {
241    /// Creates an interface for the given Web API interface.
242    pub fn new(
243        http_client: reqwest::Client,
244        base_address: reqwest::Url,
245        iface: impl Into<String>,
246        api_key: impl Into<String>,
247    ) -> Self {
248        Interface {
249            inner: Arc::new(AsyncInterface::new(http_client, base_address, iface, api_key)),
250        }
251    }
252
253    /// The async interface this wraps.
254    pub fn async_interface(&self) -> &AsyncInterface {
255        &self.inner
256    }
257
258    /// The name of the Web API interface, for example `ISteamDirectory`.
259    pub fn iface(&self) -> &str {
260        self.inner.iface()
261    }
262
263    /// The timeout for Web API requests.
264    pub fn timeout(&self) -> Duration {
265        self.inner.timeout()
266    }
267
268    /// Sets the timeout for Web API requests.
269    pub fn set_timeout(&self, timeout: Duration) {
270        self.inner.set_timeout(timeout);
271    }
272
273    /// Calls a Web API function.
274    pub async fn call(
275        &self,
276        func: &str,
277        version: i32,
278        args: &[WebApiArgument],
279    ) -> Result<KeyValue, SteamError> {
280        self.inner.call(func, version, args).await
281    }
282
283    /// Calls a Web API function with the given HTTP method.
284    pub async fn call_with_method(
285        &self,
286        method: reqwest::Method,
287        func: &str,
288        version: i32,
289        args: &[WebApiArgument],
290    ) -> Result<KeyValue, SteamError> {
291        self.inner
292            .call_with_method(method, func, version, args)
293            .await
294    }
295}
296
297/// One interface of the Web API.
298pub struct AsyncInterface {
299    http_client: reqwest::Client,
300    base_address: reqwest::Url,
301    iface: String,
302    api_key: String,
303    timeout_millis: AtomicU64,
304}
305
306impl AsyncInterface {
307    /// Creates an interface for the given Web API interface.
308    pub fn new(
309        http_client: reqwest::Client,
310        base_address: reqwest::Url,
311        iface: impl Into<String>,
312        api_key: impl Into<String>,
313    ) -> Self {
314        AsyncInterface {
315            http_client,
316            base_address,
317            iface: iface.into(),
318            api_key: api_key.into(),
319            timeout_millis: AtomicU64::new(DEFAULT_TIMEOUT.as_millis() as u64),
320        }
321    }
322
323    /// The name of the Web API interface, for example `ISteamDirectory`.
324    pub fn iface(&self) -> &str {
325        &self.iface
326    }
327
328    /// The API key used for authorized requests.
329    pub fn api_key(&self) -> &str {
330        &self.api_key
331    }
332
333    /// The base address of the Web API.
334    pub fn base_address(&self) -> &reqwest::Url {
335        &self.base_address
336    }
337
338    /// The timeout for Web API requests.
339    pub fn timeout(&self) -> Duration {
340        Duration::from_millis(self.timeout_millis.load(Ordering::Relaxed))
341    }
342
343    /// Sets the timeout for Web API requests.
344    pub fn set_timeout(&self, timeout: Duration) {
345        self.timeout_millis
346            .store(timeout.as_millis() as u64, Ordering::Relaxed);
347    }
348
349    /// Calls a Web API function. The response is requested as VDF and parsed
350    /// into a [`KeyValue`].
351    pub async fn call(
352        &self,
353        func: &str,
354        version: i32,
355        args: &[WebApiArgument],
356    ) -> Result<KeyValue, SteamError> {
357        self.call_with_method(reqwest::Method::GET, func, version, args)
358            .await
359    }
360
361    /// Calls a Web API function with the given HTTP method. The response is
362    /// requested as VDF and parsed into a [`KeyValue`].
363    pub async fn call_with_method(
364        &self,
365        method: reqwest::Method,
366        func: &str,
367        version: i32,
368        args: &[WebApiArgument],
369    ) -> Result<KeyValue, SteamError> {
370        let response = self.call_internal(method, func, version, args, "vdf").await?;
371        let body = response
372            .bytes()
373            .await
374            .map_err(|error| map_reqwest_error(error, self.timeout()))?;
375
376        let mut key_value = KeyValue::new();
377        let mut cursor = Cursor::new(body.as_ref());
378
379        key_value
380            .read_as_text(&mut cursor)
381            .map(|()| key_value)
382            .map_err(|error| {
383                SteamError::Malformed(format!(
384                    "An internal error occurred when attempting to parse the response from the WebAPI server. \
385                     This can indicate a change in the VDF format. {error}"
386                ))
387            })
388    }
389
390    /// Calls a Web API function and parses the protobuf response.
391    pub async fn call_protobuf<T>(
392        &self,
393        method: reqwest::Method,
394        func: &str,
395        version: i32,
396        args: &[WebApiArgument],
397    ) -> Result<T, SteamError>
398    where
399        T: prost::Message + Default,
400    {
401        let response = self
402            .call_internal(method, func, version, args, "protobuf_raw")
403            .await?;
404        let body = response
405            .bytes()
406            .await
407            .map_err(|error| map_reqwest_error(error, self.timeout()))?;
408
409        T::decode(body.as_ref()).map_err(SteamError::Protobuf)
410    }
411
412    /// Calls a Web API function with a protobuf request and parses the protobuf
413    /// response. The request goes base64-encoded in `input_protobuf_encoded`;
414    /// the result code comes from the `x-eresult` response header.
415    pub async fn call_protobuf_with_request<TResponse, TRequest>(
416        &self,
417        method: reqwest::Method,
418        func: &str,
419        request: &TRequest,
420        version: i32,
421        extra_args: &[WebApiArgument],
422    ) -> Result<WebAPIResponse<TResponse>, SteamError>
423    where
424        TResponse: prost::Message + Default,
425        TRequest: prost::Message + Default,
426    {
427        let base64 = BASE64_STANDARD.encode(request.encode_to_vec());
428
429        let mut args: WebApiArgs = Vec::with_capacity(extra_args.len() + 1);
430        args.push(WebApiArgument::new(
431            "input_protobuf_encoded",
432            WebApiValue::Text(base64),
433        ));
434
435        for argument in extra_args {
436            if !args.iter().any(|existing| existing.name == argument.name) {
437                args.push(argument.clone());
438            }
439        }
440
441        let response = self
442            .call_internal(method, func, version, &args, "protobuf_raw")
443            .await?;
444
445        let result = response
446            .headers()
447            .get("x-eresult")
448            .and_then(|value| value.to_str().ok())
449            .and_then(|value| value.parse::<i32>().ok())
450            .and_then(EResult::from_value)
451            .unwrap_or(EResult::Invalid);
452
453        let body = response
454            .bytes()
455            .await
456            .map_err(|error| map_reqwest_error(error, self.timeout()))?;
457        let body = TResponse::decode(body.as_ref()).map_err(SteamError::Protobuf)?;
458
459        Ok(WebAPIResponse::new(result, body))
460    }
461
462    /// Sends the request and returns the raw response.
463    async fn call_internal(
464        &self,
465        method: reqwest::Method,
466        func: &str,
467        version: i32,
468        args: &[WebApiArgument],
469        expected_format: &str,
470    ) -> Result<reqwest::Response, SteamError> {
471        let format_provided = args.iter().any(|argument| argument.name == "format");
472
473        if format_provided {
474            let format = args
475                .iter()
476                .find(|argument| argument.name == "format")
477                .map(|argument| argument.value.clone())
478                .unwrap_or(WebApiValue::Null);
479
480            match format {
481                WebApiValue::Text(text) if text == expected_format => {}
482                other => {
483                    let text = match other {
484                        WebApiValue::Text(text) => text,
485                        WebApiValue::Bytes(bytes) => String::from_utf8_lossy(&bytes).into_owned(),
486                        WebApiValue::Null => String::new(),
487                    };
488
489                    return Err(SteamError::InvalidArgument(format!(
490                        "Unsupported 'format' value '{text}'. Format must either be '{expected_format}' or omitted."
491                    )));
492                }
493            }
494        }
495
496        let params = build_params(&self.api_key, expected_format, format_provided, args);
497        let params_go_in_query_string = method == reqwest::Method::GET;
498
499        let relative = format!("{}/{func}/v{version}/", self.iface);
500
501        let mut url = self
502            .base_address
503            .join(&relative)
504            .map_err(|error| SteamError::InvalidArgument(format!("Invalid Web API url: {error}")))?;
505
506        let request = if params_go_in_query_string {
507            url.set_query(Some(&params));
508            self.http_client.request(method.clone(), url)
509        } else {
510            self.http_client
511                .request(method.clone(), url)
512                .header(
513                    reqwest::header::CONTENT_TYPE,
514                    "application/x-www-form-urlencoded",
515                )
516                .body(params)
517        };
518
519        let timeout = self.timeout();
520
521        let response = match tokio::time::timeout(timeout, request.send()).await {
522            Ok(result) => result.map_err(|error| map_reqwest_error(error, timeout))?,
523            Err(_) => return Err(SteamError::Timeout(timeout)),
524        };
525
526        if !response.status().is_success() {
527            return Err(WebAPIRequestException::status(
528                response.status().as_u16(),
529                response
530                    .status()
531                    .canonical_reason()
532                    .unwrap_or("Unknown")
533                    .to_string(),
534            )
535            .into());
536        }
537
538        Ok(response)
539    }
540}
541
542/// Builds the query string: the API key first, then the format (unless the
543/// caller passed one), then every argument as `&name=value`.
544fn build_params(
545    api_key: &str,
546    expected_format: &str,
547    format_provided: bool,
548    args: &[WebApiArgument],
549) -> String {
550    let mut params = String::new();
551
552    if !api_key.is_empty() && !args.iter().any(|argument| argument.name == "key") {
553        params.push_str("key=");
554        params.push_str(&escape_data_string(api_key));
555        params.push('&');
556    }
557
558    if !format_provided {
559        params.push_str("format=");
560        params.push_str(expected_format);
561    }
562
563    for argument in args {
564        params.push('&');
565        params.push_str(&escape_data_string(&argument.name));
566        params.push('=');
567
568        match &argument.value {
569            WebApiValue::Null => {}
570            WebApiValue::Bytes(bytes) => params.push_str(&url_encode_bytes(bytes)),
571            WebApiValue::Text(text) => params.push_str(&escape_data_string(text)),
572        }
573    }
574
575    params
576}
577
578/// Escapes a string like `Uri.EscapeDataString`.
579pub(crate) fn escape_data_string(value: &str) -> String {
580    utf8_percent_encode(value, uri_unreserved()).to_string()
581}
582
583/// Percent-encodes every byte (`%XX`).
584fn url_encode_bytes(bytes: &[u8]) -> String {
585    let mut encoded = String::with_capacity(bytes.len() * 3);
586
587    for byte in bytes {
588        encoded.push('%');
589        encoded.push_str(&format!("{byte:02X}"));
590    }
591
592    encoded
593}
594
595/// Maps a `reqwest` error to a [`SteamError`].
596fn map_reqwest_error(error: reqwest::Error, timeout: Duration) -> SteamError {
597    if error.is_timeout() {
598        SteamError::Timeout(timeout)
599    } else if error.is_connect() {
600        SteamError::Connection(error.to_string())
601    } else {
602        SteamError::Other(error.to_string())
603    }
604}