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, ¶ms)?;
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, ¶ms.path, ¶ms.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}