uggo_ugg_api/
lib.rs

1use crate::util::sha256;
2use ddragon::models::champions::ChampionShort;
3use ddragon::models::items::Item;
4use ddragon::models::runes::RuneElement;
5use ddragon::{Client, ClientBuilder};
6use levenshtein::levenshtein;
7use lru::LruCache;
8use serde::de::DeserializeOwned;
9use std::cell::RefCell;
10use std::collections::HashMap;
11use std::num::NonZeroUsize;
12use std::path::{Path, PathBuf};
13use thiserror::Error;
14use ugg_types::mappings::{self, Rank};
15use ugg_types::matchups::{MatchupData, Matchups};
16use ugg_types::overview::{ChampOverview, OverviewData};
17use ugg_types::rune::RuneExtended;
18use ureq::Agent;
19
20mod util;
21
22type UggAPIVersions = HashMap<String, HashMap<String, String>>;
23
24#[derive(Error, Debug)]
25pub enum UggError {
26    #[error("DDragon error")]
27    DDragonError(#[from] ddragon::ClientError),
28    #[error("HTTP request failed")]
29    RequestError(#[from] Box<ureq::Error>),
30    #[error("JSON parsing failed")]
31    ParseError(#[from] simd_json::Error),
32    #[error("Missing region or rank entry")]
33    MissingRegionOrRank,
34    #[error("Missing role entry")]
35    MissingRole,
36    #[error("Unknown error occurred")]
37    Unknown,
38}
39
40pub struct DataApi {
41    agent: Agent,
42    ddragon: Client,
43    overview_cache: RefCell<LruCache<String, ChampOverview>>,
44    matchup_cache: RefCell<LruCache<String, Matchups>>,
45}
46
47#[derive(Debug, Clone)]
48pub struct SupportedVersion {
49    pub ddragon: String,
50    pub ugg: String,
51}
52
53pub struct UggApi {
54    api: DataApi,
55    ugg_api_versions: UggAPIVersions,
56
57    pub current_version: String,
58    pub allowed_versions: Vec<SupportedVersion>,
59    pub patch_version: String,
60    pub champ_data: HashMap<String, ChampionShort>,
61    pub items: HashMap<String, Item>,
62    pub runes: HashMap<i64, RuneExtended<RuneElement>>,
63    pub summoner_spells: HashMap<i64, String>,
64}
65
66impl DataApi {
67    pub fn new(version: Option<String>, cache_dir: Option<PathBuf>) -> Result<Self, UggError> {
68        let mut client_builder = ClientBuilder::new();
69        let safe_dir = cache_dir.ok_or(UggError::Unknown)?;
70        if let Some(v) = version {
71            client_builder = client_builder.version(v.as_str());
72        }
73        if let Some(dir) = safe_dir.clone().to_str() {
74            client_builder = client_builder.cache(dir);
75        }
76
77        let cache_size = NonZeroUsize::new(50).unwrap_or(NonZeroUsize::MIN);
78        Ok(Self {
79            agent: Agent::new_with_defaults(),
80            ddragon: client_builder.build()?,
81            overview_cache: RefCell::new(LruCache::new(cache_size)),
82            matchup_cache: RefCell::new(LruCache::new(cache_size)),
83        })
84    }
85
86    fn get_data<T: DeserializeOwned>(&self, url: &str) -> Result<T, UggError> {
87        simd_json::serde::from_reader::<ureq::BodyReader<'_>, T>(
88            self.agent
89                .get(url)
90                .call()
91                .map_err(Box::new)?
92                .into_body()
93                .as_reader(),
94        )
95        .map_err(UggError::ParseError)
96    }
97
98    pub fn get_current_version(&mut self) -> String {
99        self.ddragon.version.clone()
100    }
101
102    pub fn get_supported_versions(&self) -> Result<Vec<String>, UggError> {
103        self.get_data("https://ddragon.leagueoflegends.com/api/versions.json")
104    }
105
106    pub fn get_champ_data(&self) -> Result<HashMap<String, ChampionShort>, UggError> {
107        Ok(self.ddragon.champions()?.data)
108    }
109
110    pub fn get_items(&self) -> Result<HashMap<String, Item>, UggError> {
111        Ok(self.ddragon.items()?.data)
112    }
113
114    pub fn get_runes(&self) -> Result<HashMap<i64, RuneExtended<RuneElement>>, UggError> {
115        let rune_data = self.ddragon.runes()?;
116
117        let mut processed_data = HashMap::new();
118        for class in rune_data {
119            for (slot_index, slot) in class.slots.iter().enumerate() {
120                for (index, rune) in slot.runes.iter().enumerate() {
121                    let extended_rune = RuneExtended {
122                        rune: (*rune).clone(),
123                        slot: slot_index as u64,
124                        index: index as u64,
125                        siblings: slot.runes.len() as u64,
126                        parent: class.name.clone(),
127                        parent_id: class.id,
128                    };
129                    processed_data.insert(rune.id, extended_rune);
130                }
131            }
132        }
133        Ok(processed_data)
134    }
135
136    pub fn get_summoner_spells(&self) -> Result<HashMap<i64, String>, UggError> {
137        let summoner_data = self.ddragon.summoner_spells()?;
138
139        let mut reduced_data: HashMap<i64, String> = HashMap::new();
140        for (_spell, spell_info) in summoner_data.data {
141            reduced_data.insert(
142                spell_info.key.parse::<i64>().ok().unwrap_or(0),
143                spell_info.name,
144            );
145        }
146        Ok(reduced_data)
147    }
148
149    pub fn get_ugg_api_versions(&self) -> Result<UggAPIVersions, UggError> {
150        self.get_data::<UggAPIVersions>("https://static.bigbrain.gg/assets/lol/riot_patch_update/prod/ugg/ugg-api-versions.json")
151    }
152
153    #[allow(clippy::too_many_arguments)]
154    pub fn get_stats(
155        &self,
156        patch: &str,
157        champ: &ChampionShort,
158        role: mappings::Role,
159        region: mappings::Region,
160        mode: mappings::Mode,
161        build: mappings::Build,
162        api_versions: &HashMap<String, HashMap<String, String>>,
163    ) -> Result<(OverviewData, mappings::Role), UggError> {
164        let api_version =
165            if api_versions.contains_key(patch) && api_versions[patch].contains_key("overview") {
166                api_versions[patch]["overview"].as_str()
167            } else {
168                "1.5.0"
169            };
170        let data_path = &format!(
171            "{}/{}/{}/{}/{}",
172            build.to_api_string(),
173            patch,
174            mode.to_api_string(),
175            champ.key.as_str(),
176            api_version
177        );
178        let cache_path = format!("{data_path}-{region}-{role}");
179
180        let stats_data = if let Some(data) = self
181            .overview_cache
182            .try_borrow_mut()
183            .ok()
184            .and_then(|mut c| c.get(&sha256(&cache_path)).cloned())
185        {
186            Ok(data)
187        } else {
188            self.get_data::<ChampOverview>(&format!("https://stats2.u.gg/lol/1.5/{data_path}.json"))
189        }?;
190
191        if let Ok(mut c) = self.overview_cache.try_borrow_mut() {
192            c.put(sha256(&cache_path), stats_data.clone());
193        }
194
195        let data_by_role = Rank::preferred_order()
196            .iter()
197            .find_map(|rank| {
198                stats_data
199                    .get(&region)
200                    .and_then(|region_data| region_data.get(rank))
201            })
202            .ok_or(UggError::MissingRegionOrRank)?;
203
204        data_by_role
205            .get_key_value(&role)
206            .or_else(|| {
207                data_by_role
208                    .iter()
209                    .max_by_key(|(_, data)| data.data.matches)
210                    .map(|(role, _)| role)
211                    .and_then(|r| data_by_role.get_key_value(r))
212            })
213            .map(|(role, data)| (data.data.clone(), *role))
214            .ok_or(UggError::MissingRole)
215    }
216
217    pub fn get_matchups(
218        &self,
219        patch: &str,
220        champ: &ChampionShort,
221        role: mappings::Role,
222        region: mappings::Region,
223        mode: mappings::Mode,
224        api_versions: &HashMap<String, HashMap<String, String>>,
225    ) -> Result<(MatchupData, mappings::Role), UggError> {
226        let api_version =
227            if api_versions.contains_key(patch) && api_versions[patch].contains_key("matchups") {
228                api_versions[patch]["matchups"].as_str()
229            } else {
230                "1.5.0"
231            };
232        let data_path = &format!(
233            "{}/{}/{}/{}",
234            patch,
235            mode.to_api_string(),
236            champ.key.as_str(),
237            api_version
238        );
239        let cache_path = format!("{data_path}-{region}-{role}");
240
241        let matchup_data = if let Some(data) = self
242            .matchup_cache
243            .try_borrow_mut()
244            .ok()
245            .and_then(|mut c| c.get(&sha256(&cache_path)).cloned())
246        {
247            Ok(data)
248        } else {
249            self.get_data::<Matchups>(&format!(
250                "https://stats2.u.gg/lol/1.5/matchups/{data_path}.json",
251            ))
252        }?;
253
254        let data_by_role = Rank::preferred_order()
255            .iter()
256            .find_map(|rank| {
257                matchup_data
258                    .get(&region)
259                    .and_then(|region_data| region_data.get(rank))
260            })
261            .ok_or(UggError::MissingRegionOrRank)?;
262
263        data_by_role
264            .get_key_value(&role)
265            .or_else(|| {
266                data_by_role
267                    .iter()
268                    .max_by_key(|(_, data)| data.data.total_matches)
269                    .map(|(role, _)| role)
270                    .and_then(|r| data_by_role.get_key_value(r))
271            })
272            .map(|(role, data)| (data.data.clone(), *role))
273            .ok_or(UggError::MissingRole)
274    }
275}
276
277impl UggApi {
278    pub fn new(version: Option<String>, cache_dir: Option<PathBuf>) -> Result<Self, UggError> {
279        let mut inner_api = DataApi::new(version, cache_dir.clone())?;
280
281        let mut current_version = inner_api.get_current_version();
282        let allowed_versions = inner_api.get_supported_versions()?;
283        let ugg_api_versions = inner_api.get_ugg_api_versions()?;
284        let versions_ugg_supports = allowed_versions
285            .into_iter()
286            .map(|v| SupportedVersion {
287                ddragon: v.clone(),
288                ugg: (v.split('.').take(2).collect::<Vec<&str>>()).join("_"),
289            })
290            .filter(|v| ugg_api_versions.contains_key(&v.ugg))
291            .collect::<Vec<_>>();
292
293        if let Some(default_if_fails) = versions_ugg_supports.first() {
294            if !versions_ugg_supports
295                .iter()
296                .any(|v| v.ddragon == current_version)
297            {
298                inner_api = DataApi::new(Some(default_if_fails.ddragon.clone()), cache_dir)?;
299                current_version = inner_api.get_current_version();
300            }
301        } else {
302            return Err(UggError::Unknown);
303        }
304
305        let champ_data = inner_api.get_champ_data()?;
306        let items = inner_api.get_items()?;
307        let runes = inner_api.get_runes()?;
308        let summoner_spells = inner_api.get_summoner_spells()?;
309
310        let mut patch_version_split = current_version.split('.').collect::<Vec<&str>>();
311        patch_version_split.remove(patch_version_split.len() - 1);
312        let patch_version = patch_version_split.join("_");
313
314        Ok(Self {
315            api: inner_api,
316            allowed_versions: versions_ugg_supports,
317            ugg_api_versions,
318            current_version,
319            patch_version,
320            champ_data,
321            items,
322            runes,
323            summoner_spells,
324        })
325    }
326
327    pub fn find_champ(&self, name: &str) -> &ChampionShort {
328        if self.champ_data.contains_key(name) {
329            &self.champ_data[name]
330        } else {
331            let mut lowest_distance = usize::MAX;
332            let mut closest_champ: &ChampionShort = &self.champ_data["Annie"];
333
334            let mut substring_lowest_dist = usize::MAX;
335            let mut substring_closest_champ: Option<&ChampionShort> = None;
336
337            for value in self.champ_data.values() {
338                let query_compare = name.to_ascii_lowercase();
339                let champ_compare = value.name.to_ascii_lowercase();
340                // Prefer matches where search query is an exact starting substring
341                let distance = levenshtein(query_compare.as_str(), champ_compare.as_str());
342                if champ_compare.starts_with(&query_compare) {
343                    if distance <= substring_lowest_dist {
344                        substring_lowest_dist = distance;
345                        substring_closest_champ = Some(value);
346                    }
347                } else if distance <= lowest_distance {
348                    lowest_distance = distance;
349                    closest_champ = value;
350                }
351            }
352
353            substring_closest_champ.unwrap_or(closest_champ)
354        }
355    }
356
357    pub fn get_stats(
358        &self,
359        champ: &ChampionShort,
360        role: mappings::Role,
361        region: mappings::Region,
362        mode: mappings::Mode,
363        build: mappings::Build,
364    ) -> Result<(OverviewData, mappings::Role), UggError> {
365        self.api.get_stats(
366            &self.patch_version,
367            champ,
368            role,
369            region,
370            mode,
371            build,
372            &self.ugg_api_versions,
373        )
374    }
375
376    pub fn get_matchups(
377        &self,
378        champ: &ChampionShort,
379        role: mappings::Role,
380        region: mappings::Region,
381        mode: mappings::Mode,
382    ) -> Result<(MatchupData, mappings::Role), UggError> {
383        self.api.get_matchups(
384            &self.patch_version,
385            champ,
386            role,
387            region,
388            mode,
389            &self.ugg_api_versions,
390        )
391    }
392}
393
394pub struct UggApiBuilder {
395    version: Option<String>,
396    cache_dir: Option<PathBuf>,
397}
398
399impl UggApiBuilder {
400    #[must_use]
401    pub fn new() -> Self {
402        Self {
403            version: None,
404            cache_dir: None,
405        }
406    }
407
408    #[must_use]
409    pub fn version(mut self, version: &str) -> Self {
410        self.version = Some(version.to_owned());
411        self
412    }
413
414    #[must_use]
415    pub fn cache_dir(mut self, cache_dir: &Path) -> Self {
416        self.cache_dir = Some(cache_dir.to_path_buf());
417        self
418    }
419
420    pub fn build(self) -> Result<UggApi, UggError> {
421        UggApi::new(self.version, self.cache_dir)
422    }
423}
424
425impl Default for UggApiBuilder {
426    fn default() -> Self {
427        Self::new()
428    }
429}