Skip to content

Commit e14a8d3

Browse files
committed
feat: implement go riot proxy
1 parent c3fd972 commit e14a8d3

1 file changed

Lines changed: 84 additions & 150 deletions

File tree

app/modules/riot_integration/services/riot_api_service.rb

Lines changed: 84 additions & 150 deletions
Original file line numberDiff line numberDiff line change
@@ -1,209 +1,146 @@
11
# frozen_string_literal: true
22

3-
# Wrapper around the Riot Games API with built-in rate limiting and regional routing.
4-
# Provides methods for summoner, match, league, and champion mastery lookups.
3+
# Proxy to the prostaff-riot-gateway Go service.
4+
# Rate limiting, caching and circuit breaking are handled by the gateway.
55
class RiotApiService
6-
RATE_LIMITS = {
7-
per_second: 20,
8-
per_two_minutes: 100
9-
}.freeze
10-
116
REGIONS = {
12-
'BR' => { platform: 'BR1', region: 'americas' },
13-
'NA' => { platform: 'NA1', region: 'americas' },
14-
'EUW' => { platform: 'EUW1', region: 'europe' },
15-
'EUNE' => { platform: 'EUN1', region: 'europe' },
16-
'KR' => { platform: 'KR', region: 'asia' },
17-
'JP' => { platform: 'JP1', region: 'asia' },
18-
'OCE' => { platform: 'OC1', region: 'sea' },
19-
'LAN' => { platform: 'LA1', region: 'americas' },
20-
'LAS' => { platform: 'LA2', region: 'americas' },
21-
'RU' => { platform: 'RU', region: 'europe' },
22-
'TR' => { platform: 'TR1', region: 'europe' }
7+
'BR' => { platform: 'br1', region: 'americas' },
8+
'NA' => { platform: 'na1', region: 'americas' },
9+
'EUW' => { platform: 'euw1', region: 'europe' },
10+
'EUNE' => { platform: 'eun1', region: 'europe' },
11+
'KR' => { platform: 'kr', region: 'asia' },
12+
'JP' => { platform: 'jp1', region: 'asia' },
13+
'OCE' => { platform: 'oc1', region: 'sea' },
14+
'LAN' => { platform: 'la1', region: 'americas' },
15+
'LAS' => { platform: 'la2', region: 'americas' },
16+
'RU' => { platform: 'ru', region: 'europe' },
17+
'TR' => { platform: 'tr1', region: 'europe' }
2318
}.freeze
2419

2520
class RiotApiError < StandardError; end
2621
class RateLimitError < RiotApiError; end
2722
class NotFoundError < RiotApiError; end
2823
class UnauthorizedError < RiotApiError; end
2924

30-
def initialize(api_key: nil)
31-
@api_key = api_key || ENV['RIOT_API_KEY']
32-
raise RiotApiError, 'Riot API key not configured' if @api_key.blank?
25+
def initialize(_api_key: nil)
26+
@gateway_url = ENV.fetch('RIOT_GATEWAY_URL', 'http://riot-gateway:4444')
3327
end
3428

3529
def get_summoner_by_name(summoner_name:, region:)
36-
platform = platform_for_region(region)
37-
url = "https://#{platform}.api.riotgames.com/lol/summoner/v4/summoners/by-name/#{ERB::Util.url_encode(summoner_name)}"
30+
platform = platform_for(region)
31+
response = get("/riot/summoner/#{platform}/by-name/#{ERB::Util.url_encode(summoner_name)}")
32+
parse_summoner_response(response)
33+
end
3834

39-
response = make_request(url)
35+
def get_summoner_by_puuid(puuid:, region:)
36+
platform = platform_for(region)
37+
response = get("/riot/summoner/#{platform}/by-puuid/#{puuid}")
4038
parse_summoner_response(response)
4139
end
4240

4341
def get_account_by_puuid(puuid:, region:)
44-
regional_route = regional_route_for_region(region)
45-
url = "https://#{regional_route}.api.riotgames.com/riot/account/v1/accounts/by-puuid/#{puuid}"
46-
47-
response = make_request(url)
42+
routing = routing_for(region)
43+
response = get("/riot/account/#{routing}/by-puuid/#{puuid}")
4844
parse_account_response(response)
4945
end
5046

51-
def get_summoner_by_puuid(puuid:, region:)
52-
platform = platform_for_region(region)
53-
url = "https://#{platform}.api.riotgames.com/lol/summoner/v4/summoners/by-puuid/#{puuid}"
54-
55-
response = make_request(url)
56-
parse_summoner_response(response)
57-
end
58-
5947
def get_league_entries(summoner_id:, region:)
60-
platform = platform_for_region(region)
61-
url = "https://#{platform}.api.riotgames.com/lol/league/v4/entries/by-summoner/#{summoner_id}"
62-
63-
response = make_request(url)
48+
platform = platform_for(region)
49+
response = get("/riot/league/#{platform}/by-summoner/#{summoner_id}")
6450
parse_league_entries(response)
6551
end
6652

6753
def get_league_entries_by_puuid(puuid:, region:)
68-
platform = platform_for_region(region)
69-
url = "https://#{platform}.api.riotgames.com/lol/league/v4/entries/by-puuid/#{puuid}"
70-
71-
response = make_request(url)
54+
platform = platform_for(region)
55+
response = get("/riot/league/#{platform}/by-puuid/#{puuid}")
7256
parse_league_entries(response)
7357
end
7458

7559
def get_match_history(puuid:, region:, count: 20, start: 0)
76-
regional_route = regional_route_for_region(region)
77-
url = "https://#{regional_route}.api.riotgames.com/lol/match/v5/matches/by-puuid/#{puuid}/ids?start=#{start}&count=#{count}"
78-
79-
response = make_request(url)
60+
platform = platform_for(region)
61+
response = get("/riot/matches/#{platform}/#{puuid}/ids?count=#{count}&start=#{start}")
8062
JSON.parse(response.body)
8163
end
8264

8365
def get_match_details(match_id:, region:)
84-
regional_route = regional_route_for_region(region)
85-
url = "https://#{regional_route}.api.riotgames.com/lol/match/v5/matches/#{match_id}"
86-
87-
response = make_request(url)
66+
platform = platform_for(region)
67+
response = get("/riot/match/#{platform}/#{match_id}")
8868
parse_match_details(response)
8969
end
9070

9171
def get_champion_mastery(puuid:, region:)
92-
platform = platform_for_region(region)
93-
url = "https://#{platform}.api.riotgames.com/lol/champion-mastery/v4/champion-masteries/by-puuid/#{puuid}"
94-
95-
response = make_request(url)
72+
platform = platform_for(region)
73+
response = get("/riot/mastery/#{platform}/#{puuid}/top?count=50")
9674
parse_champion_mastery(response)
9775
end
9876

9977
private
10078

101-
def make_request(url)
102-
check_rate_limit!
103-
104-
conn = Faraday.new do |f|
105-
f.request :retry, max: 3, interval: 0.5, backoff_factor: 2
79+
def get(path)
80+
conn = Faraday.new(@gateway_url) do |f|
81+
f.request :retry, max: 2, interval: 0.5, backoff_factor: 2
10682
f.adapter Faraday.default_adapter
10783
end
10884

109-
response = conn.get(url) do |req|
110-
req.headers['X-Riot-Token'] = @api_key
85+
response = conn.get(path) do |req|
86+
req.headers['Authorization'] = "Bearer #{internal_jwt}"
11187
req.options.timeout = 10
11288
end
11389

11490
handle_response(response)
11591
rescue Faraday::TimeoutError => e
116-
raise RiotApiError, "Request timeout: #{e.message}"
92+
raise RiotApiError, "Gateway timeout: #{e.message}"
11793
rescue Faraday::Error => e
118-
raise RiotApiError, "Network error: #{e.message}"
94+
raise RiotApiError, "Gateway error: #{e.message}"
95+
end
96+
97+
def internal_jwt
98+
payload = { service: 'prostaff-api', exp: 1.hour.from_now.to_i }
99+
JWT.encode(payload, ENV.fetch('JWT_SECRET_KEY'), 'HS256')
119100
end
120101

121102
def handle_response(response)
122103
case response.status
123-
when 200
124-
response
125-
when 404
126-
raise NotFoundError, 'Resource not found'
127-
when 401, 403
128-
raise UnauthorizedError, 'Invalid API key or unauthorized'
104+
when 200 then response
105+
when 404 then raise NotFoundError, 'Resource not found'
106+
when 401, 403 then raise UnauthorizedError, 'Gateway auth failed'
129107
when 429
130-
retry_after = response.headers['Retry-After']&.to_i || 120
108+
retry_after = response.headers['Retry-After']&.to_i || 60
131109
raise RateLimitError, "Rate limit exceeded. Retry after #{retry_after} seconds"
132-
when 500..599
133-
raise RiotApiError, "Riot API server error: #{response.status}"
134-
else
135-
raise RiotApiError, "Unexpected response: #{response.status}"
136-
end
137-
end
138-
139-
def check_rate_limit!
140-
return unless Rails.cache
141-
142-
current_second = Time.current.to_i
143-
key_second = "riot_api:rate_limit:second:#{current_second}"
144-
key_two_min = "riot_api:rate_limit:two_minutes:#{current_second / 120}"
145-
146-
count_second = Rails.cache.increment(key_second, 1, expires_in: 1.second) || 0
147-
count_two_min = Rails.cache.increment(key_two_min, 1, expires_in: 2.minutes) || 0
148-
149-
if count_second > RATE_LIMITS[:per_second]
150-
sleep(1 - (Time.current.to_f % 1)) # Sleep until next second
110+
when 503 then raise RiotApiError, 'Riot API circuit breaker open'
111+
when 500..599 then raise RiotApiError, "Gateway error: #{response.status}"
112+
else raise RiotApiError, "Unexpected response: #{response.status}"
151113
end
152-
153-
return unless count_two_min > RATE_LIMITS[:per_two_minutes]
154-
155-
raise RateLimitError, 'Rate limit exceeded for 2-minute window'
156114
end
157115

158-
def platform_for_region(region)
116+
def platform_for(region)
159117
normalized = normalize_region(region)
160118
REGIONS.dig(normalized, :platform) || raise(RiotApiError, "Unknown region: #{region}")
161119
end
162120

163-
def regional_route_for_region(region)
121+
def routing_for(region)
164122
normalized = normalize_region(region)
165123
REGIONS.dig(normalized, :region) || raise(RiotApiError, "Unknown region: #{region}")
166124
end
167125

168-
# Normalizes platform codes (br1, na1, euw1) to region codes (BR, NA, EUW)
169126
def normalize_region(region)
170127
return nil if region.nil?
171128

172-
# Convert to uppercase and remove trailing digit
173-
normalized = region.to_s.upcase.sub(/\d+$/, '')
174-
175-
# Map platform codes to region codes
176-
platform_to_region = {
177-
'BR' => 'BR',
178-
'NA' => 'NA',
179-
'EUW' => 'EUW',
180-
'EUN' => 'EUNE',
181-
'KR' => 'KR',
182-
'JP' => 'JP',
183-
'OC' => 'OCE',
184-
'LA' => 'LAN', # LA1 -> LAN, LA2 -> LAS (handle separately)
185-
'RU' => 'RU',
186-
'TR' => 'TR'
187-
}
188-
189-
# Special case for LA1/LA2
190-
if region.to_s.upcase == 'LA1'
191-
return 'LAN'
192-
elsif region.to_s.upcase == 'LA2'
193-
return 'LAS'
194-
end
129+
upper = region.to_s.upcase
130+
return 'LAN' if upper == 'LA1'
131+
return 'LAS' if upper == 'LA2'
195132

196-
# Return mapped region or the normalized value
197-
platform_to_region[normalized] || normalized
133+
stripped = upper.sub(/\d+$/, '')
134+
{
135+
'BR' => 'BR', 'NA' => 'NA', 'EUW' => 'EUW', 'EUN' => 'EUNE',
136+
'KR' => 'KR', 'JP' => 'JP', 'OC' => 'OCE', 'LA' => 'LAN',
137+
'RU' => 'RU', 'TR' => 'TR'
138+
}.fetch(stripped, stripped)
198139
end
199140

200141
def parse_account_response(response)
201142
data = JSON.parse(response.body)
202-
{
203-
puuid: data['puuid'],
204-
game_name: data['gameName'],
205-
tag_line: data['tagLine']
206-
}
143+
{ puuid: data['puuid'], game_name: data['gameName'], tag_line: data['tagLine'] }
207144
end
208145

209146
def parse_summoner_response(response)
@@ -219,7 +156,6 @@ def parse_summoner_response(response)
219156

220157
def parse_league_entries(response)
221158
entries = JSON.parse(response.body)
222-
223159
{
224160
solo_queue: find_queue_entry(entries, 'RANKED_SOLO_5x5'),
225161
flex_queue: find_queue_entry(entries, 'RANKED_FLEX_SR')
@@ -240,8 +176,8 @@ def find_queue_entry(entries, queue_type)
240176
end
241177

242178
def parse_match_details(response)
243-
data = JSON.parse(response.body)
244-
info = data['info']
179+
data = JSON.parse(response.body)
180+
info = data['info']
245181
metadata = data['metadata']
246182

247183
{
@@ -282,11 +218,7 @@ def parse_participant(participant)
282218
quadra_kills: participant['quadraKills'],
283219
penta_kills: participant['pentaKills'],
284220
win: participant['win'],
285-
items: [
286-
participant['item0'], participant['item1'], participant['item2'],
287-
participant['item3'], participant['item4'], participant['item5'],
288-
participant['item6']
289-
].compact.reject(&:zero?),
221+
items: extract_items(participant),
290222
item_build_order: extract_item_build_order(participant),
291223
trinket: participant['item6'],
292224
summoner_spell_1: participant['summoner1Id'],
@@ -310,11 +242,25 @@ def parse_participant(participant)
310242
}
311243
end
312244

245+
def extract_items(participant)
246+
[
247+
participant['item0'], participant['item1'], participant['item2'],
248+
participant['item3'], participant['item4'], participant['item5'],
249+
participant['item6']
250+
].compact.reject(&:zero?)
251+
end
252+
253+
def extract_item_build_order(participant)
254+
[
255+
participant['item0'], participant['item1'], participant['item2'],
256+
participant['item3'], participant['item4'], participant['item5']
257+
].compact.reject(&:zero?)
258+
end
259+
313260
def extract_runes(participant)
314261
perks = participant.dig('perks', 'styles')
315262
return [] unless perks
316263

317-
# Extract primary and sub-style selections
318264
perks.flat_map { |style| style['selections'].map { |s| s['perk'] } }
319265
end
320266

@@ -338,20 +284,8 @@ def extract_pings(participant)
338284
}
339285
end
340286

341-
def extract_item_build_order(participant)
342-
# Riot API doesn't provide item purchase order in match details
343-
# We can only get the final items, so return them in the order they appear
344-
# (item0-5 are main items, item6 is trinket)
345-
[
346-
participant['item0'], participant['item1'], participant['item2'],
347-
participant['item3'], participant['item4'], participant['item5']
348-
].compact.reject(&:zero?)
349-
end
350-
351287
def parse_champion_mastery(response)
352-
masteries = JSON.parse(response.body)
353-
354-
masteries.map do |mastery|
288+
JSON.parse(response.body).map do |mastery|
355289
{
356290
champion_id: mastery['championId'],
357291
champion_level: mastery['championLevel'],

0 commit comments

Comments
 (0)