Skip to main content

league_link/
http.rs

1//! HTTPS requests to the LCU REST API.
2//!
3//! The LCU uses a Riot self-signed TLS certificate. [`build_lcu_client`]
4//! returns a `reqwest::Client` configured to accept it, with a 10-second
5//! request timeout. **Reuse this client** — it maintains an internal
6//! connection pool.
7
8use std::time::Duration;
9
10use reqwest::{Client, ClientBuilder, Method};
11use serde::{de::DeserializeOwned, Serialize};
12
13use crate::{auth::Credentials, error::LcuError};
14
15/// Default per-request timeout applied by [`build_lcu_client`].
16pub const DEFAULT_TIMEOUT: Duration = Duration::from_secs(10);
17
18/// Build a reusable HTTP client configured for the LCU API.
19///
20/// TLS certificate and hostname verification are disabled — required
21/// because the LCU serves a Riot-signed certificate for `127.0.0.1`.
22/// A 10-second per-request [`timeout`](DEFAULT_TIMEOUT) is applied so a
23/// stuck client cannot hang caller tasks indefinitely.
24pub fn build_lcu_client() -> Result<Client, LcuError> {
25    Ok(ClientBuilder::new()
26        .danger_accept_invalid_certs(true)
27        .danger_accept_invalid_hostnames(true)
28        .timeout(DEFAULT_TIMEOUT)
29        .build()?)
30}
31
32// ─── Core ─────────────────────────────────────────────────────
33
34async fn send<T, B>(
35    client: &Client,
36    credentials: &Credentials,
37    method: Method,
38    endpoint: &str,
39    body: Option<&B>,
40) -> Result<T, LcuError>
41where
42    T: DeserializeOwned,
43    B: Serialize + ?Sized,
44{
45    let url = format!("{}{}", credentials.lcu_base_url(), endpoint);
46    let mut req = client
47        .request(method, &url)
48        .header("Authorization", credentials.basic_auth())
49        .header("Accept", "application/json");
50    if let Some(b) = body {
51        req = req.json(b);
52    }
53    let resp = req.send().await?;
54    let status = resp.status();
55    if !status.is_success() {
56        let body = resp.text().await.unwrap_or_default();
57        return Err(LcuError::Status {
58            code: status.as_u16(),
59            body,
60        });
61    }
62    Ok(resp.json::<T>().await?)
63}
64
65/// Send an HTTP request with **no body** to the LCU and deserialize the
66/// JSON response as `T`.
67pub async fn lcu_request<T: DeserializeOwned>(
68    client: &Client,
69    credentials: &Credentials,
70    method: Method,
71    endpoint: &str,
72) -> Result<T, LcuError> {
73    send::<T, ()>(client, credentials, method, endpoint, None).await
74}
75
76/// Send an HTTP request with a JSON-serializable body.
77pub async fn lcu_request_with_body<T, B>(
78    client: &Client,
79    credentials: &Credentials,
80    method: Method,
81    endpoint: &str,
82    body: &B,
83) -> Result<T, LcuError>
84where
85    T: DeserializeOwned,
86    B: Serialize + ?Sized,
87{
88    send(client, credentials, method, endpoint, Some(body)).await
89}
90
91// ─── Convenience wrappers ────────────────────────────────────
92
93/// `GET` an LCU endpoint and deserialize into `T`.
94///
95/// ```no_run
96/// use league_link::{authenticate, build_lcu_client, lcu_get};
97/// use serde_json::Value;
98/// # async fn run() -> Result<(), league_link::LcuError> {
99/// let creds = authenticate(1000, 30).await?;
100/// let client = build_lcu_client()?;
101/// let me: Value = lcu_get(&client, &creds, "/lol-summoner/v1/current-summoner").await?;
102/// # Ok(()) }
103/// ```
104pub async fn lcu_get<T: DeserializeOwned>(
105    client: &Client,
106    credentials: &Credentials,
107    endpoint: &str,
108) -> Result<T, LcuError> {
109    lcu_request(client, credentials, Method::GET, endpoint).await
110}
111
112/// `POST` to an LCU endpoint with a JSON-serializable body.
113///
114/// Unlike v0.1.0, `body` accepts any `Serialize` type — you no longer have
115/// to pre-convert to `serde_json::Value`.
116pub async fn lcu_post<T, B>(
117    client: &Client,
118    credentials: &Credentials,
119    endpoint: &str,
120    body: &B,
121) -> Result<T, LcuError>
122where
123    T: DeserializeOwned,
124    B: Serialize + ?Sized,
125{
126    lcu_request_with_body(client, credentials, Method::POST, endpoint, body).await
127}
128
129/// `DELETE` an LCU endpoint.
130pub async fn lcu_delete<T: DeserializeOwned>(
131    client: &Client,
132    credentials: &Credentials,
133    endpoint: &str,
134) -> Result<T, LcuError> {
135    lcu_request(client, credentials, Method::DELETE, endpoint).await
136}
137
138// ─── Utility ─────────────────────────────────────────────────
139
140/// Convert an internal LCU version string to the player-facing patch number.
141///
142/// The LCU reports versions like `"4.21.614.6789"`, where `4` is the internal
143/// season offset (Season 4 = 0). Adding 10 yields the marketing version:
144/// `"14.21"`. Returns `None` if the input is not in the expected format.
145pub fn parse_marketing_version(raw: &str) -> Option<String> {
146    let parts: Vec<&str> = raw.split('.').collect();
147    if parts.len() < 2 {
148        return None;
149    }
150    let internal_major: u32 = parts[0].parse().ok()?;
151    let minor = parts[1];
152    Some(format!("{}.{:0>2}", internal_major + 10, minor))
153}
154
155#[cfg(test)]
156mod tests {
157    use super::*;
158
159    #[test]
160    fn version_parsing() {
161        assert_eq!(parse_marketing_version("4.21.614.6789"), Some("14.21".into()));
162        assert_eq!(parse_marketing_version("4.3.614.6789"), Some("14.03".into()));
163        assert_eq!(parse_marketing_version("bad"), None);
164        assert_eq!(parse_marketing_version(""), None);
165    }
166}