1use 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
26pub const DEFAULT_BASE_ADDRESS: &str = "https://api.steampowered.com/";
28
29pub const DEFAULT_TIMEOUT: Duration = Duration::from_secs(100);
31
32fn 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#[derive(Clone, Debug, PartialEq, Eq)]
49pub enum WebApiValue {
50 Text(String),
52 Bytes(Vec<u8>),
54 Null,
56}
57
58impl From<String> for WebApiValue {
59 fn from(value: String) -> Self {
61 WebApiValue::Text(value)
62 }
63}
64
65impl From<&str> for WebApiValue {
66 fn from(value: &str) -> Self {
68 WebApiValue::Text(value.to_string())
69 }
70}
71
72impl From<Vec<u8>> for WebApiValue {
73 fn from(value: Vec<u8>) -> Self {
75 WebApiValue::Bytes(value)
76 }
77}
78
79impl From<i32> for WebApiValue {
80 fn from(value: i32) -> Self {
82 WebApiValue::Text(value.to_string())
83 }
84}
85
86impl From<u32> for WebApiValue {
87 fn from(value: u32) -> Self {
89 WebApiValue::Text(value.to_string())
90 }
91}
92
93impl From<i64> for WebApiValue {
94 fn from(value: i64) -> Self {
96 WebApiValue::Text(value.to_string())
97 }
98}
99
100impl From<u64> for WebApiValue {
101 fn from(value: u64) -> Self {
103 WebApiValue::Text(value.to_string())
104 }
105}
106
107impl From<bool> for WebApiValue {
108 fn from(value: bool) -> Self {
110 WebApiValue::Text(value.to_string())
111 }
112}
113
114#[derive(Clone, Debug, PartialEq, Eq)]
116pub struct WebApiArgument {
117 pub name: String,
119 pub value: WebApiValue,
121}
122
123impl WebApiArgument {
124 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
133pub type WebApiArgs = Vec<WebApiArgument>;
135
136pub 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#[derive(Clone, Debug, PartialEq, Eq)]
147pub struct WebAPIResponse<TResponse> {
148 pub result: EResult,
150 pub body: TResponse,
152}
153
154impl<TResponse> WebAPIResponse<TResponse> {
155 pub fn new(result: EResult, body: TResponse) -> Self {
157 WebAPIResponse { result, body }
158 }
159}
160
161pub struct WebAPI;
163
164impl WebAPI {
165 pub fn default_base_address() -> reqwest::Url {
168 reqwest::Url::parse(DEFAULT_BASE_ADDRESS)
170 .expect("DEFAULT_BASE_ADDRESS is a valid absolute URL")
171 }
172
173 pub fn default_timeout() -> Duration {
175 DEFAULT_TIMEOUT
176 }
177
178 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 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 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 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
227fn 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
235pub struct Interface {
237 inner: Arc<AsyncInterface>,
238}
239
240impl Interface {
241 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 pub fn async_interface(&self) -> &AsyncInterface {
255 &self.inner
256 }
257
258 pub fn iface(&self) -> &str {
260 self.inner.iface()
261 }
262
263 pub fn timeout(&self) -> Duration {
265 self.inner.timeout()
266 }
267
268 pub fn set_timeout(&self, timeout: Duration) {
270 self.inner.set_timeout(timeout);
271 }
272
273 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 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
297pub 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 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 pub fn iface(&self) -> &str {
325 &self.iface
326 }
327
328 pub fn api_key(&self) -> &str {
330 &self.api_key
331 }
332
333 pub fn base_address(&self) -> &reqwest::Url {
335 &self.base_address
336 }
337
338 pub fn timeout(&self) -> Duration {
340 Duration::from_millis(self.timeout_millis.load(Ordering::Relaxed))
341 }
342
343 pub fn set_timeout(&self, timeout: Duration) {
345 self.timeout_millis
346 .store(timeout.as_millis() as u64, Ordering::Relaxed);
347 }
348
349 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 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 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 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 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(¶ms));
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
542fn 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
578pub(crate) fn escape_data_string(value: &str) -> String {
580 utf8_percent_encode(value, uri_unreserved()).to_string()
581}
582
583fn 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
595fn 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}