uggo_ugg_api/
lib.rs

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