Skip to main content

rusty_lcu/
client.rs

1use std::time::Duration;
2
3use reqwest::{Method, Url};
4use serde::{Serialize, de::DeserializeOwned};
5use serde_json::Value;
6use tokio::time::sleep;
7
8use crate::{
9    Credentials, CredentialsSource, Error, Result, events::EventStream, generated::Endpoint,
10};
11
12#[derive(Debug, Clone)]
13pub struct LcuClient {
14    http: reqwest::Client,
15    credentials: Option<Credentials>,
16    default_request_options: RequestOptions,
17}
18
19#[derive(Debug, Clone)]
20pub struct RequestOptions {
21    pub max_retries: usize,
22    pub retry_delay: Duration,
23    pub retry_on_statuses: Vec<reqwest::StatusCode>,
24}
25
26impl Default for RequestOptions {
27    fn default() -> Self {
28        Self {
29            max_retries: 0,
30            retry_delay: Duration::from_millis(500),
31            retry_on_statuses: vec![
32                reqwest::StatusCode::TOO_MANY_REQUESTS,
33                reqwest::StatusCode::BAD_GATEWAY,
34                reqwest::StatusCode::SERVICE_UNAVAILABLE,
35                reqwest::StatusCode::GATEWAY_TIMEOUT,
36            ],
37        }
38    }
39}
40
41#[derive(Debug, Clone, Default)]
42pub struct EndpointParams {
43    path: Vec<(String, String)>,
44    query: Vec<(String, String)>,
45    body: Option<Value>,
46    request_options: Option<RequestOptions>,
47}
48
49impl EndpointParams {
50    pub fn new() -> Self {
51        Self::default()
52    }
53
54    pub fn path(mut self, name: impl Into<String>, value: impl ToString) -> Self {
55        self.path.push((name.into(), value.to_string()));
56        self
57    }
58
59    pub fn query(mut self, name: impl Into<String>, value: impl ToString) -> Self {
60        self.query.push((name.into(), value.to_string()));
61        self
62    }
63
64    pub fn body(mut self, value: impl Serialize) -> Result<Self> {
65        self.body = Some(serde_json::to_value(value)?);
66        Ok(self)
67    }
68
69    pub fn request_options(mut self, options: RequestOptions) -> Self {
70        self.request_options = Some(options);
71        self
72    }
73}
74
75#[derive(Debug, Clone)]
76pub struct PollOptions {
77    pub interval: Duration,
78    pub max_attempts: Option<usize>,
79    pub stop_on_error: bool,
80}
81
82impl Default for PollOptions {
83    fn default() -> Self {
84        Self {
85            interval: Duration::from_secs(1),
86            max_attempts: None,
87            stop_on_error: true,
88        }
89    }
90}
91
92#[derive(Debug, Clone, PartialEq)]
93pub enum PollEvent<T> {
94    Response(T),
95    DistinctResponse(T),
96    Error(String),
97}
98
99#[derive(Debug, Clone)]
100pub struct ConnectOptions {
101    pub readiness_check: Option<ReadinessCheck>,
102}
103
104impl Default for ConnectOptions {
105    fn default() -> Self {
106        Self {
107            readiness_check: Some(ReadinessCheck::default()),
108        }
109    }
110}
111
112#[derive(Debug, Clone)]
113pub struct ReadinessCheck {
114    pub path: String,
115    pub max_attempts: usize,
116    pub delay: Duration,
117}
118
119impl Default for ReadinessCheck {
120    fn default() -> Self {
121        Self {
122            path: "/lol-gameflow/v1/gameflow-phase".to_string(),
123            max_attempts: 10,
124            delay: Duration::from_secs(1),
125        }
126    }
127}
128
129impl LcuClient {
130    pub fn new() -> Result<Self> {
131        let http = reqwest::Client::builder()
132            .danger_accept_invalid_certs(true)
133            .build()?;
134
135        Ok(Self {
136            http,
137            credentials: None,
138            default_request_options: RequestOptions::default(),
139        })
140    }
141
142    pub fn with_credentials(credentials: Credentials) -> Result<Self> {
143        let mut client = Self::new()?;
144        client.credentials = Some(credentials);
145        Ok(client)
146    }
147
148    pub fn set_default_request_options(&mut self, options: RequestOptions) {
149        self.default_request_options = options;
150    }
151
152    pub async fn connect(&mut self) -> Result<()> {
153        self.connect_with_options(CredentialsSource::Auto, ConnectOptions::default())
154            .await
155    }
156
157    pub async fn connect_with(&mut self, source: CredentialsSource) -> Result<()> {
158        self.credentials = Some(Credentials::discover(source).await?);
159        Ok(())
160    }
161
162    pub async fn connect_with_options(
163        &mut self,
164        source: CredentialsSource,
165        options: ConnectOptions,
166    ) -> Result<()> {
167        self.credentials = Some(Credentials::discover(source).await?);
168
169        if let Some(readiness_check) = options.readiness_check {
170            self.wait_until_ready(readiness_check).await?;
171        }
172
173        Ok(())
174    }
175
176    pub fn credentials(&self) -> Option<&Credentials> {
177        self.credentials.as_ref()
178    }
179
180    pub async fn event_stream(&self) -> Result<EventStream> {
181        let credentials = self.credentials.as_ref().ok_or(Error::NotConnected)?;
182        EventStream::connect(credentials).await
183    }
184
185    pub async fn wait_until_ready(&self, readiness_check: ReadinessCheck) -> Result<()> {
186        let attempts = readiness_check.max_attempts.max(1);
187
188        for attempt in 0..attempts {
189            if self.get(&readiness_check.path).await.is_ok() {
190                return Ok(());
191            }
192
193            if attempt + 1 < attempts {
194                sleep(readiness_check.delay).await;
195            }
196        }
197
198        Err(Error::ReadinessCheckFailed { attempts })
199    }
200
201    pub async fn get(&self, path: &str) -> Result<Value> {
202        self.request(Method::GET, path, EndpointParams::new()).await
203    }
204
205    pub async fn get_as<T>(&self, path: &str) -> Result<T>
206    where
207        T: DeserializeOwned,
208    {
209        self.request_as(Method::GET, path, EndpointParams::new())
210            .await
211    }
212
213    pub async fn post(&self, path: &str, params: EndpointParams) -> Result<Value> {
214        self.request(Method::POST, path, params).await
215    }
216
217    pub async fn post_as<T>(&self, path: &str, params: EndpointParams) -> Result<T>
218    where
219        T: DeserializeOwned,
220    {
221        self.request_as(Method::POST, path, params).await
222    }
223
224    pub async fn put(&self, path: &str, params: EndpointParams) -> Result<Value> {
225        self.request(Method::PUT, path, params).await
226    }
227
228    pub async fn put_as<T>(&self, path: &str, params: EndpointParams) -> Result<T>
229    where
230        T: DeserializeOwned,
231    {
232        self.request_as(Method::PUT, path, params).await
233    }
234
235    pub async fn patch(&self, path: &str, params: EndpointParams) -> Result<Value> {
236        self.request(Method::PATCH, path, params).await
237    }
238
239    pub async fn patch_as<T>(&self, path: &str, params: EndpointParams) -> Result<T>
240    where
241        T: DeserializeOwned,
242    {
243        self.request_as(Method::PATCH, path, params).await
244    }
245
246    pub async fn delete(&self, path: &str, params: EndpointParams) -> Result<Value> {
247        self.request(Method::DELETE, path, params).await
248    }
249
250    pub async fn delete_as<T>(&self, path: &str, params: EndpointParams) -> Result<T>
251    where
252        T: DeserializeOwned,
253    {
254        self.request_as(Method::DELETE, path, params).await
255    }
256
257    pub async fn request_endpoint(
258        &self,
259        endpoint: &'static Endpoint,
260        params: EndpointParams,
261    ) -> Result<Value> {
262        validate_endpoint_params(endpoint, &params)?;
263        let method = Method::from_bytes(endpoint.method.as_bytes()).expect("generated method");
264        self.request(method, endpoint.path, params).await
265    }
266
267    pub async fn request_endpoint_as<T>(
268        &self,
269        endpoint: &'static Endpoint,
270        params: EndpointParams,
271    ) -> Result<T>
272    where
273        T: DeserializeOwned,
274    {
275        parse_value(self.request_endpoint(endpoint, params).await?)
276    }
277
278    pub async fn poll_endpoint<F>(
279        &self,
280        endpoint: &'static Endpoint,
281        params: EndpointParams,
282        options: PollOptions,
283        mut callback: F,
284    ) -> Result<()>
285    where
286        F: FnMut(PollEvent<Value>) -> bool,
287    {
288        self.poll_endpoint_as(endpoint, params, options, |event| callback(event))
289            .await
290    }
291
292    pub async fn poll_endpoint_as<T, F>(
293        &self,
294        endpoint: &'static Endpoint,
295        params: EndpointParams,
296        options: PollOptions,
297        mut callback: F,
298    ) -> Result<()>
299    where
300        T: DeserializeOwned + Clone + PartialEq,
301        F: FnMut(PollEvent<T>) -> bool,
302    {
303        let mut attempts = 0;
304        let mut previous = None;
305
306        loop {
307            if options
308                .max_attempts
309                .is_some_and(|max_attempts| attempts >= max_attempts)
310            {
311                return Ok(());
312            }
313            attempts += 1;
314
315            match self
316                .request_endpoint_as::<T>(endpoint, params.clone())
317                .await
318            {
319                Ok(value) => {
320                    let is_distinct = previous.as_ref() != Some(&value);
321                    previous = Some(value.clone());
322
323                    if callback(PollEvent::Response(value.clone())) {
324                        return Ok(());
325                    }
326                    if is_distinct && callback(PollEvent::DistinctResponse(value)) {
327                        return Ok(());
328                    }
329                }
330                Err(error) => {
331                    let message = error.to_string();
332                    if callback(PollEvent::Error(message)) || options.stop_on_error {
333                        return Err(error);
334                    }
335                }
336            }
337
338            sleep(options.interval).await;
339        }
340    }
341
342    pub async fn request(
343        &self,
344        method: Method,
345        path: &str,
346        params: EndpointParams,
347    ) -> Result<Value> {
348        let credentials = self.credentials.as_ref().ok_or(Error::NotConnected)?;
349        let options = params
350            .request_options
351            .clone()
352            .unwrap_or_else(|| self.default_request_options.clone());
353
354        let url = build_url(credentials, path, &params.path, &params.query)?;
355        let mut last_error = None;
356
357        for attempt in 0..=options.max_retries {
358            let mut request = self
359                .http
360                .request(method.clone(), url.clone())
361                .basic_auth("riot", Some(&credentials.password));
362
363            if let Some(body) = params.body.clone() {
364                request = request.json(&body);
365            }
366
367            match request.send().await {
368                Ok(response) if response.status().is_success() => {
369                    return parse_success_response(response).await;
370                }
371                Ok(response) => {
372                    let status = response.status();
373                    let body = response.text().await.unwrap_or_default();
374                    if attempt < options.max_retries && options.retry_on_statuses.contains(&status)
375                    {
376                        sleep(options.retry_delay).await;
377                        continue;
378                    }
379                    return Err(Error::Lcu { status, body });
380                }
381                Err(error) => {
382                    last_error = Some(error);
383                    if attempt < options.max_retries {
384                        sleep(options.retry_delay).await;
385                        continue;
386                    }
387                }
388            }
389        }
390
391        Err(Error::Request(last_error.expect("request attempted")))
392    }
393
394    pub async fn request_as<T>(
395        &self,
396        method: Method,
397        path: &str,
398        params: EndpointParams,
399    ) -> Result<T>
400    where
401        T: DeserializeOwned,
402    {
403        parse_value(self.request(method, path, params).await?)
404    }
405}
406
407fn build_url(
408    credentials: &Credentials,
409    path: &str,
410    path_params: &[(String, String)],
411    query_params: &[(String, String)],
412) -> Result<Url> {
413    let mut rendered = path.to_string();
414    for (name, value) in path_params {
415        rendered = rendered.replace(
416            &format!("{{{name}}}"),
417            &urlencoding::encode(value).into_owned(),
418        );
419    }
420
421    let mut url = Url::parse(&format!("{}{}", credentials.base_url(), rendered))?;
422
423    for (name, value) in query_params {
424        url.query_pairs_mut().append_pair(name, value);
425    }
426
427    Ok(url)
428}
429
430fn validate_endpoint_params(endpoint: &'static Endpoint, params: &EndpointParams) -> Result<()> {
431    for required_path_param in endpoint.path_params {
432        if !params
433            .path
434            .iter()
435            .any(|(name, _)| name == required_path_param)
436        {
437            return Err(Error::MissingPathParameter {
438                method: endpoint.method,
439                path: endpoint.path,
440                name: required_path_param,
441            });
442        }
443    }
444
445    for required_query_param in endpoint.required_query_params {
446        if !params
447            .query
448            .iter()
449            .any(|(name, _)| name == required_query_param)
450        {
451            return Err(Error::MissingQueryParameter {
452                method: endpoint.method,
453                path: endpoint.path,
454                name: required_query_param,
455            });
456        }
457    }
458
459    Ok(())
460}
461
462async fn parse_success_response(response: reqwest::Response) -> Result<Value> {
463    let status = response.status();
464    let bytes = response.bytes().await?;
465
466    if bytes.is_empty() || status == reqwest::StatusCode::NO_CONTENT {
467        return Ok(Value::Null);
468    }
469
470    Ok(serde_json::from_slice(&bytes)?)
471}
472
473fn parse_value<T>(value: Value) -> Result<T>
474where
475    T: DeserializeOwned,
476{
477    Ok(serde_json::from_value(value)?)
478}
479
480#[cfg(test)]
481mod tests {
482    use std::{
483        io::{Read, Write},
484        net::TcpListener,
485        thread,
486    };
487
488    use serde::Deserialize;
489    use serde_json::json;
490
491    use crate::{
492        Credentials, EndpointParams, Error, LcuClient,
493        generated::{GET_LOL_CATALOG_V1_ITEM_DETAILS, GET_LOL_SUMMONER_V1_SUMMONERS_BY_ID},
494    };
495
496    use super::{parse_value, validate_endpoint_params};
497
498    #[test]
499    fn validates_generated_path_params() {
500        let error =
501            validate_endpoint_params(&GET_LOL_SUMMONER_V1_SUMMONERS_BY_ID, &EndpointParams::new())
502                .unwrap_err();
503
504        assert!(matches!(
505            error,
506            Error::MissingPathParameter {
507                method: "GET",
508                path: "/lol-summoner/v1/summoners/{id}",
509                name: "id"
510            }
511        ));
512
513        validate_endpoint_params(
514            &GET_LOL_SUMMONER_V1_SUMMONERS_BY_ID,
515            &EndpointParams::new().path("id", 123),
516        )
517        .unwrap();
518    }
519
520    #[test]
521    fn validates_generated_required_query_params() {
522        let error =
523            validate_endpoint_params(&GET_LOL_CATALOG_V1_ITEM_DETAILS, &EndpointParams::new())
524                .unwrap_err();
525
526        assert!(matches!(
527            error,
528            Error::MissingQueryParameter {
529                method: "GET",
530                path: "/lol-catalog/v1/item-details",
531                name: "inventoryType"
532            }
533        ));
534
535        validate_endpoint_params(
536            &GET_LOL_CATALOG_V1_ITEM_DETAILS,
537            &EndpointParams::new()
538                .query("inventoryType", "CHAMPION")
539                .query("itemId", 1),
540        )
541        .unwrap();
542    }
543
544    #[test]
545    fn parses_typed_values_for_raw_helpers() {
546        #[derive(Debug, Deserialize, PartialEq)]
547        struct Phase {
548            phase: String,
549        }
550
551        let phase: Phase = parse_value(json!({ "phase": "Lobby" })).unwrap();
552        assert_eq!(
553            phase,
554            Phase {
555                phase: "Lobby".to_string()
556            }
557        );
558    }
559
560    #[test]
561    fn raw_typed_requests_send_auth_query_and_body() {
562        #[derive(Debug, Deserialize, PartialEq)]
563        struct EchoResponse {
564            ok: bool,
565        }
566
567        let listener = TcpListener::bind("127.0.0.1:0").unwrap();
568        let port = listener.local_addr().unwrap().port();
569
570        let server = thread::spawn(move || {
571            let (mut stream, _) = listener.accept().unwrap();
572            let mut buffer = [0_u8; 4096];
573            let bytes_read = stream.read(&mut buffer).unwrap();
574            let request = String::from_utf8_lossy(&buffer[..bytes_read]);
575
576            assert!(request.starts_with("POST /test?answer=42 HTTP/1.1"));
577            assert!(request.contains("authorization: Basic cmlvdDpzZWNyZXQ="));
578            assert!(request.contains("content-type: application/json"));
579            assert!(request.contains(r#"{"hello":"world"}"#));
580
581            let body = r#"{"ok":true}"#;
582            write!(
583                stream,
584                "HTTP/1.1 200 OK\r\ncontent-type: application/json\r\ncontent-length: {}\r\n\r\n{}",
585                body.len(),
586                body
587            )
588            .unwrap();
589        });
590
591        let runtime = tokio::runtime::Builder::new_current_thread()
592            .enable_all()
593            .build()
594            .unwrap();
595
596        let response = runtime
597            .block_on(async {
598                let client = LcuClient::with_credentials(Credentials {
599                    port,
600                    password: "secret".to_string(),
601                    protocol: "http".to_string(),
602                })
603                .unwrap();
604
605                client
606                    .post_as::<EchoResponse>(
607                        "/test",
608                        EndpointParams::new()
609                            .query("answer", 42)
610                            .body(json!({ "hello": "world" }))
611                            .unwrap(),
612                    )
613                    .await
614            })
615            .unwrap();
616
617        server.join().unwrap();
618        assert_eq!(response, EchoResponse { ok: true });
619    }
620}