Skip to content

Instantly share code, notes, and snippets.

@HurricanKai
Created February 15, 2024 14:58
Show Gist options
  • Star 0 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save HurricanKai/b96c5b4fd822e34d59f90ca3660d47ea to your computer and use it in GitHub Desktop.
Save HurricanKai/b96c5b4fd822e34d59f90ca3660d47ea to your computer and use it in GitHub Desktop.
struct RiotApiClient<T: RegionOrPlatform> {
inner: Arc<reqwest::Client>,
ratelimits: tokio::sync::Mutex<Vec<Ratelimit>>,
region_or_platform: T,
}
#[derive(Debug)]
struct Ratelimit {
pub current: u32,
pub limit: u32,
pub per_seconds: u32,
pub first_time: DateTime<Utc>,
}
fn region_to_str(region: Region) -> &'static str {
match region {
Region::BR1 => "BR1",
Region::EUN1 => "EUN1",
Region::EUW1 => "EUW1",
Region::JP1 => "JP1",
Region::KR => "KR",
Region::LA1 => "LA1",
Region::LA2 => "LA2",
Region::NA1 => "NA1",
Region::OC1 => "OC1",
Region::TR1 => "TR1",
Region::RU => "RU",
Region::PH2 => "PH2",
Region::SG2 => "SG2",
Region::TH2 => "TH2",
Region::TW2 => "TW2",
Region::VN2 => "VN2",
}
}
fn platform_to_str(platform: Platform) -> &'static str {
match platform {
Platform::Americas => "Americas",
Platform::Asia => "Asia",
Platform::Europe => "Europe",
Platform::Sea => "Sea",
}
}
impl<T: RegionOrPlatform> RiotApiClient<T> {
async fn make_request<E: RiotEndpoint<T>, P: serde::Serialize + ?Sized>(
self: &Self,
endpoint: &E,
method: reqwest::Method,
path: &str,
params: Option<&P>,
) -> anyhow::Result<Option<reqwest::Response>> {
'outer: loop {
let app_ratelimits = &self.ratelimits;
let endpoint_ratelimits = endpoint.ratelimits();
loop {
let mut endpoint_ratelimits = endpoint_ratelimits.lock().await;
if let Some(time) = check_ratelimits(&mut endpoint_ratelimits, Utc::now()) {
let r = time.signed_duration_since(Utc::now());
if r > Duration::zero() {
println!("Waiting on Endpoint Ratelimit for {r} {endpoint_ratelimits:#?}");
tokio::time::sleep(r.abs().to_std()?).await;
}
continue;
}
let mut app_ratelimits = app_ratelimits.lock().await;
loop {
if let Some(time) = check_ratelimits(&mut app_ratelimits, Utc::now()) {
let r = time.signed_duration_since(Utc::now());
if r > Duration::zero() {
println!("Waiting on App Ratelimit for {r} {app_ratelimits:#?}");
tokio::time::sleep(r.abs().to_std()?).await;
}
continue;
}
// ready to make the request now!
let mut url = String::new();
url.push_str(self.region_or_platform.to_url());
url.push_str(path);
println!("Making request to {url}");
let mut builder = self.inner.request(method.clone(), url);
if let Some(params) = params {
builder = builder.query(params);
}
builder = builder.header("X-Riot-Token", API_KEY);
let request = builder.build()?;
let response = self.inner.execute(request).await?;
let response_date = response
.headers()
.get("Date")
.and_then(|d| d.to_str().ok())
.and_then(|d| DateTime::parse_from_rfc2822(d).ok())
.map(|d| d.with_timezone(&Utc))
.unwrap();
if response.status() == StatusCode::TOO_MANY_REQUESTS {
// Handle overflow
let seconds = response
.headers()
.get("Retry-After")
.and_then(|h| h.to_str().ok())
.and_then(|h| h.parse().ok());
if let Some(seconds) = seconds {
println!("Waiting on Overflow for {seconds}");
tokio::time::sleep(
(Duration::seconds(seconds)
- Utc::now().signed_duration_since(response_date))
.to_std()?,
)
.await;
// if an overflow happens & we got a retry-after, it's due to a desync. Drop all current ratelimits.
endpoint_ratelimits.clear();
app_ratelimits.clear();
continue 'outer;
}
}
// parse headers & update ratelimits.
match (
response
.headers()
.get("X-App-Rate-Limit")
.and_then(|h| h.to_str().ok()),
response
.headers()
.get("X-App-Rate-Limit-Count")
.and_then(|h| h.to_str().ok()),
) {
(Some(l), Some(r)) => {
update_ratelimits(&mut app_ratelimits, l, r, response_date)?
}
_ => {}
}
match (
response
.headers()
.get("X-Method-Rate-Limit")
.and_then(|h| h.to_str().ok()),
response
.headers()
.get("X-Method-Rate-Limit-Count")
.and_then(|h| h.to_str().ok()),
) {
(Some(l), Some(r)) => {
update_ratelimits(&mut endpoint_ratelimits, l, r, response_date)?
}
_ => {}
}
if response.status() == StatusCode::NOT_FOUND {
return Ok(None);
}
// TODO: Improve
if response.status() != StatusCode::OK {
println!("Request Error: {response:#?}");
return Err(anyhow!("Request Error!!"));
}
return Ok(Some(response));
}
#[allow(unreachable_code)]
{
unreachable!("Above loop should never break or complete!");
}
}
}
}
}
fn check_ratelimits(
limits: &mut Vec<Ratelimit>,
relative_to: DateTime<Utc>,
) -> Option<DateTime<Utc>> {
for ratelimit in limits.iter_mut() {
let diff = (relative_to.signed_duration_since(ratelimit.first_time)).abs();
let diffs = diff.num_seconds();
// only consider ratelimits that are still relevant.
if diffs <= ratelimit.per_seconds.into() {
if ratelimit.current >= ratelimit.limit {
let d = ratelimit
.first_time
.checked_add_signed(Duration::seconds(ratelimit.per_seconds.into()))
.unwrap();
return Some(d);
}
}
}
return None;
}
fn update_ratelimits(
limits: &mut Vec<Ratelimit>,
real_limit: &str,
real_count: &str,
relative_to: DateTime<Utc>,
) -> anyhow::Result<()> {
let mut map_counts = HashMap::new();
let mut map_limits = HashMap::new();
fn parse_list(map: &mut HashMap<u32, u32>, str: &str) {
for l in str.split(',') {
match {
let mut s = l.split(':');
(
s.next().and_then(|s| s.parse().ok()),
s.next().and_then(|s| s.parse().ok()),
)
} {
(Some(limit), Some(time)) => {
map.insert(time, limit);
}
_ => {}
}
}
}
parse_list(&mut map_counts, real_count);
parse_list(&mut map_limits, real_limit);
let mut new_limits = Vec::new();
for (time, count) in map_counts {
let limit = map_limits.get(&time).map_or_else(
|| Err(anyhow!("? Riot didn't return complete lists")),
|o| Ok(*o),
)?;
let mut first_time = limits
.iter()
.find(|l| l.per_seconds == time)
.map_or(relative_to, |r| r.first_time);
// TODO: Also check first time to be in range??
if count == 0 || first_time + Duration::seconds(time.into()) < relative_to {
first_time = relative_to;
}
new_limits.push(Ratelimit {
current: count,
limit: limit,
per_seconds: time,
first_time: first_time,
});
}
*limits = new_limits;
Ok(())
}
trait RiotEndpoint<T: RegionOrPlatform> {
fn ratelimits(&self) -> &tokio::sync::Mutex<Vec<Ratelimit>>;
}
trait RegionOrPlatform {
fn to_url(&self) -> &'static str;
}
impl RegionOrPlatform for Platform {
fn to_url(&self) -> &'static str {
match self {
Platform::Americas => "https://americas.api.riotgames.com",
Platform::Asia => "https://asia.api.riotgames.com",
Platform::Europe => "https://europe.api.riotgames.com",
Platform::Sea => "https://sea.api.riotgames.com",
}
}
}
impl RegionOrPlatform for Region {
fn to_url(&self) -> &'static str {
match self {
Region::BR1 => "https://br1.api.riotgames.com",
Region::EUN1 => "https://eun1.api.riotgames.com",
Region::EUW1 => "https://euw1.api.riotgames.com",
Region::JP1 => "https://jp1.api.riotgames.com",
Region::KR => "https://kr.api.riotgames.com",
Region::LA1 => "https://la1.api.riotgames.com",
Region::LA2 => "https://la2.api.riotgames.com",
Region::NA1 => "https://na1.api.riotgames.com",
Region::OC1 => "https://oc1.api.riotgames.com",
Region::TR1 => "https://tr1.api.riotgames.com",
Region::RU => "https://ru.api.riotgames.com",
Region::PH2 => "https://ph2.api.riotgames.com",
Region::SG2 => "https://sg2.api.riotgames.com",
Region::TH2 => "https://th2.api.riotgames.com",
Region::TW2 => "https://tw2.api.riotgames.com",
Region::VN2 => "https://vn2.api.riotgames.com",
}
}
}
const ALL_REGIONS: [Region; 16] = [
Region::BR1,
Region::EUN1,
Region::EUW1,
Region::JP1,
Region::KR,
Region::LA1,
Region::LA2,
Region::NA1,
Region::OC1,
Region::TR1,
Region::RU,
Region::PH2,
Region::SG2,
Region::TH2,
Region::TW2,
Region::VN2,
];
#[derive(Debug, PartialEq, Eq, Hash, Clone, Copy)]
enum Region {
BR1,
EUN1,
EUW1,
JP1,
KR,
LA1,
LA2,
NA1,
OC1,
TR1,
RU,
PH2,
SG2,
TH2,
TW2,
VN2,
}
#[derive(Debug)]
enum RegionParseError {
UnknownRegion,
}
impl<'a> TryFrom<&'a str> for Region {
type Error = RegionParseError;
fn try_from(value: &'a str) -> Result<Self, Self::Error> {
match value {
"BR1" => Ok(Region::BR1),
"EUN1" => Ok(Region::EUN1),
"EUW1" => Ok(Region::EUW1),
"JP1" => Ok(Region::JP1),
"KR" => Ok(Region::KR),
"LA1" => Ok(Region::LA1),
"LA2" => Ok(Region::LA2),
"NA1" => Ok(Region::NA1),
"OC1" => Ok(Region::OC1),
"TR1" => Ok(Region::TR1),
"RU" => Ok(Region::RU),
"PH2" => Ok(Region::PH2),
"SG2" => Ok(Region::SG2),
"TH2" => Ok(Region::TH2),
"TW2" => Ok(Region::TW2),
"VN2" => Ok(Region::VN2),
_ => Err(Self::Error::UnknownRegion),
}
}
}
const ALL_PLATFORMS: [Platform; 4] = [
Platform::Americas,
Platform::Asia,
Platform::Europe,
Platform::Sea,
];
#[derive(Debug, PartialEq, Eq, Hash, Clone, Copy)]
enum Platform {
Americas,
Asia,
Europe,
Sea,
}
impl<'a> TryFrom<&'a str> for Platform {
type Error = RegionParseError;
fn try_from(value: &'a str) -> Result<Self, Self::Error> {
match value {
"Americas" => Ok(Platform::Americas),
"Asia" => Ok(Platform::Asia),
"Europe" => Ok(Platform::Europe),
"Sea" => Ok(Platform::Sea),
_ => Err(Self::Error::UnknownRegion),
}
}
}
fn region_to_platform(region: Region) -> Platform {
match region {
Region::NA1 => Platform::Americas,
Region::BR1 => Platform::Americas,
Region::LA1 => Platform::Americas,
Region::LA2 => Platform::Americas,
Region::KR => Platform::Asia,
Region::JP1 => Platform::Asia,
Region::EUN1 => Platform::Europe,
Region::EUW1 => Platform::Europe,
Region::TR1 => Platform::Europe,
Region::RU => Platform::Europe,
Region::OC1 => Platform::Sea,
Region::PH2 => Platform::Sea,
Region::SG2 => Platform::Sea,
Region::TH2 => Platform::Sea,
Region::TW2 => Platform::Sea,
Region::VN2 => Platform::Sea,
}
}
fn region_partition_offset(region: Region) -> u32 {
match region {
Region::NA1 => 0,
Region::BR1 => 1,
Region::LA1 => 2,
Region::LA2 => 3,
Region::KR => 4,
Region::JP1 => 5,
Region::EUN1 => 6,
Region::EUW1 => 7,
Region::TR1 => 8,
Region::RU => 9,
Region::OC1 => 10,
Region::PH2 => 11,
Region::SG2 => 12,
Region::TH2 => 13,
Region::TW2 => 14,
Region::VN2 => 15,
}
}
// Platform ONLY
struct Matchv5 {
ratelimits: tokio::sync::Mutex<Vec<Ratelimit>>,
base: Arc<RiotApiClient<Platform>>,
}
impl Matchv5 {
fn new(base: Arc<RiotApiClient<Platform>>) -> Self {
return Self {
ratelimits: Mutex::new(Vec::new()),
base,
};
}
async fn get_match_list_by_puuid(
&self,
puuid: &Puuid,
start_time: Option<chrono::DateTime<Utc>>,
end_time: Option<chrono::DateTime<Utc>>,
queue: Option<u32>,
match_type: Option<String>,
start: Option<u32>,
count: Option<u32>,
) -> anyhow::Result<Vec<MatchId>> {
let mut params = Vec::new();
if let Some(start_time) = start_time {
params.push(("startTime", start_time.timestamp().to_string()))
}
if let Some(end_time) = end_time {
params.push(("endTime", end_time.timestamp().to_string()))
}
if let Some(queue) = queue {
params.push(("queue", queue.to_string()))
}
if let Some(match_type) = match_type {
params.push(("type", match_type.to_string()))
}
if let Some(start) = start {
params.push(("start", start.to_string()))
}
params.push(("count", count.unwrap_or(100).to_string()));
let response = self
.base
.make_request::<_, Vec<(&str, String)>>(
self,
Method::GET,
&("/lol/match/v5/matches/by-puuid/".to_owned() + puuid.to_str() + "/ids"),
Some(&params),
)
.await?;
return Ok(response
.expect("puuids should not go missing")
.json::<Vec<String>>()
.await?
.into_iter()
.map(|e| e.into())
.collect());
}
async fn get_match_by_id(
&self,
match_id: &MatchId,
) -> anyhow::Result<Option<serde_json::Value>> {
let response = self
.base
.make_request::<_, [String; 0]>(
self,
Method::GET,
&("/lol/match/v5/matches/".to_owned() + match_id.to_str()),
None,
)
.await?;
if let Some(response) = response {
return Ok(response.json().await?);
}
return Ok(None);
}
}
impl<'a> RiotEndpoint<Platform> for Matchv5 {
fn ratelimits(&self) -> &tokio::sync::Mutex<Vec<Ratelimit>> {
return &self.ratelimits;
}
}
#[derive(Debug)]
struct Puuid {
inner: String,
}
impl Puuid {
fn to_str(&self) -> &str {
return &self.inner;
}
}
impl From<String> for Puuid {
fn from(value: String) -> Self {
Self { inner: value }
}
}
#[derive(Debug)]
struct AccountId {
inner: String,
}
impl AccountId {
fn to_str(&self) -> &str {
return &self.inner;
}
}
impl From<String> for AccountId {
fn from(value: String) -> Self {
Self { inner: value }
}
}
#[derive(Debug)]
struct SummonerId {
inner: String,
}
impl SummonerId {
fn to_str(&self) -> &str {
return &self.inner;
}
}
impl From<String> for SummonerId {
fn from(value: String) -> Self {
Self { inner: value }
}
}
#[derive(Debug)]
struct MatchId {
inner: String,
}
impl MatchId {
fn to_str(&self) -> &str {
return &self.inner;
}
fn into_parts(&self) -> (Region, u64) {
let mut it = self.to_str().split('_');
(
it.next()
.expect("matchid format should be <region>_<id>")
.try_into()
.expect("first part of matchid should always be real region"),
it.next()
.expect("matchid format should be <region>_<id>")
.parse()
.expect("second part of matchid should always be a number"),
)
}
fn from_parts(region: Region, id: u64) -> Self {
return Self {
inner: region_to_str(region).to_owned() + "_" + &id.to_string(),
};
}
}
impl From<String> for MatchId {
fn from(value: String) -> Self {
Self { inner: value }
}
}
#[derive(Debug)]
struct RiotId {
pub game_name: String,
pub tagline: String,
}
struct LeagueV4 {
ratelimits: tokio::sync::Mutex<Vec<Ratelimit>>,
base: Arc<RiotApiClient<Region>>,
}
impl LeagueV4 {
fn new(base: Arc<RiotApiClient<Region>>) -> Self {
Self {
ratelimits: Mutex::new(Vec::new()),
base,
}
}
async fn league_entries(
&self,
queue: &str,
tier: &str,
division: &str,
page: u32,
) -> anyhow::Result<Vec<SummonerId>> {
// TODO: Return object instead of summonerId array
let response = self
.base
.make_request(
self,
Method::GET,
&("/lol/league/v4/entries/".to_owned() + queue + "/" + tier + "/" + division),
Some(&[("page", page.to_string())]),
)
.await?;
let body = response
.expect("league should not go missing")
.json::<serde_json::Value>()
.await?;
// TODO: Fix parsing to error instead of panic
Ok(body
.as_array()
.map(|a| {
a.into_iter()
.map(|v| {
v.as_object()
.unwrap()
.get("summonerId")
.unwrap()
.as_str()
.unwrap()
.to_owned()
.into()
})
.collect()
})
.unwrap())
}
}
impl RiotEndpoint<Region> for LeagueV4 {
fn ratelimits(&self) -> &tokio::sync::Mutex<Vec<Ratelimit>> {
return &self.ratelimits;
}
}
#[derive(Debug)]
struct AccountInfo {
puuid: Puuid,
riotid: RiotId,
}
// PLATFORM ONLY
struct AccountV1 {
ratelimits: tokio::sync::Mutex<Vec<Ratelimit>>,
base: Arc<RiotApiClient<Platform>>,
}
impl AccountV1 {
fn new(base: Arc<RiotApiClient<Platform>>) -> Self {
return Self {
ratelimits: Mutex::new(Vec::new()),
base,
};
}
async fn puuid_from_riotid(&self, riotid: &RiotId) -> anyhow::Result<AccountInfo> {
let response = self
.base
.make_request::<_, [String; 0]>(
self,
Method::GET,
&("/riot/account/v1/accounts/by-riot-id/".to_owned()
+ &riotid.game_name
+ "/"
+ &riotid.tagline),
None,
)
.await?;
let body = response
.expect("accounts should not go missing")
.json::<serde_json::Value>()
.await?;
body.as_object()
.and_then(|o| o.get("puuid").and_then(|x| x.as_str()).map(|p| (o, p)))
.and_then(|(o, p)| {
o.get("gameName")
.and_then(|x| x.as_str())
.map(|g| (o, p, g))
})
.and_then(|(o, p, g)| o.get("tagLine").and_then(|x| x.as_str()).map(|t| (p, g, t)))
.map(|(p, g, t)| AccountInfo {
puuid: p.to_owned().into(),
riotid: RiotId {
game_name: g.to_owned(),
tagline: t.to_owned(),
},
})
.map_or_else(|| Err(anyhow!("Could not parse account info!")), |e| Ok(e))
}
}
impl RiotEndpoint<Platform> for AccountV1 {
fn ratelimits(&self) -> &tokio::sync::Mutex<Vec<Ratelimit>> {
return &self.ratelimits;
}
}
struct SummonerV4 {
ratelimits: tokio::sync::Mutex<Vec<Ratelimit>>,
base: Arc<RiotApiClient<Region>>,
}
impl SummonerV4 {
fn new(base: Arc<RiotApiClient<Region>>) -> Self {
return Self {
ratelimits: Mutex::new(Vec::new()),
base,
};
}
fn parse_body(body: serde_json::Value) -> anyhow::Result<SummonerInfo> {
body.as_object()
.map(|o| {
match (
o.get("accountId")
.and_then(|e| e.as_str())
.map(|e| e.to_owned().into()),
o.get("profileIconId")
.and_then(|e| e.as_i64())
.and_then(|e| e.try_into().ok()),
o.get("revisionDate")
.and_then(|e| e.as_i64())
.map(|e| chrono::DateTime::UNIX_EPOCH + chrono::Duration::milliseconds(e)),
o.get("name").and_then(|e| e.as_str()).map(|e| e.to_owned()),
o.get("id")
.and_then(|e| e.as_str())
.map(|e| e.to_owned().into()),
o.get("puuid")
.and_then(|e| e.as_str())
.map(|e| e.to_owned().into()),
o.get("summonerLevel")
.and_then(|e| e.as_i64())
.and_then(|e| e.try_into().ok()),
) {
(
Some(account_id),
Some(profile_icon_id),
Some(revision_date),
Some(name),
Some(id),
Some(puuid),
Some(summoner_level),
) => Ok(SummonerInfo {
account_id,
profile_icon_id,
revision_date,
name,
id,
puuid,
summoner_level,
}),
_ => Err(anyhow!("Failed to Parse SummonerInfo!")),
}
})
.unwrap()
}
async fn summoner_from_id(&self, summoner_id: &SummonerId) -> anyhow::Result<SummonerInfo> {
let response = self
.base
.make_request::<_, [String; 0]>(
self,
Method::GET,
&("/lol/summoner/v4/summoners/".to_owned() + &summoner_id.to_str()),
None,
)
.await?;
let body = response
.expect("summonerids should not go missing")
.json::<serde_json::Value>()
.await?;
Self::parse_body(body)
}
async fn summoner_from_puuid(&self, puuid: &Puuid) -> anyhow::Result<SummonerInfo> {
let response = self
.base
.make_request::<_, [String; 0]>(
self,
Method::GET,
&("/lol/summoner/v4/summoners/by-puuid/".to_owned() + &puuid.to_str()),
None,
)
.await?;
let body = response
.expect("puuids should not go missing")
.json::<serde_json::Value>()
.await?;
Self::parse_body(body)
}
}
impl<'a> RiotEndpoint<Region> for SummonerV4 {
fn ratelimits(&self) -> &tokio::sync::Mutex<Vec<Ratelimit>> {
return &self.ratelimits;
}
}
#[derive(Debug)]
struct SummonerInfo {
account_id: AccountId,
profile_icon_id: u32,
revision_date: chrono::DateTime<Utc>,
name: String,
id: SummonerId,
puuid: Puuid,
summoner_level: u64,
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment