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 .
55class 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