api.rs
  1  use std::collections::HashMap;
  2  use std::io::ErrorKind;
  3  use std::path::Path;
  4  use anyhow::{anyhow, Context, Result};
  5  use path_slash::PathExt;
  6  use serde::de::DeserializeOwned;
  7  use crate::data::secure::getRetroAchievementsAuth;
  8  use super::{Payload_GetGameInfo, Payload_GetUserCompletionProgress,
  9  	Payload_GetUserProfile, RetroAchievementsAuth};
 10  
 11  pub struct RetroAchievementsApi;
 12  
 13  impl RetroAchievementsApi
 14  {
 15  	const BaseUrl: &str = "https://retroachievements.org/API/";
 16  	const MediaUrl: &str = "https://media.retroachievements.org/";
 17  	
 18  	const Endpoint_GetGameInfo: &str = "API_GetGameInfoAndUserProgress.php";
 19  	const Endpoint_GetUserGameCompletion: &str = "API_GetUserCompletionProgress.php";
 20  	const Endpoint_GetUserProfile: &str = "API_GetUserProfile.php";
 21  	
 22  	pub const GetUserGameCompletion_Count: u64 = 100;
 23  	
 24  	pub const BadgePath: &str = "Badge";
 25  	pub const BadgeLockedSuffix: &str = "lock";
 26  	
 27  	const Parameter_ApiKey: &str = "y";
 28  	const Parameter_ApiUsername: &str = "u";
 29  	
 30  	pub const Platform: &str = "RetroAchievements";
 31  	
 32  	pub fn sanitizeIconTitle(title: &String) -> String
 33  	{
 34  		return title
 35  			.replace("/", " - ")
 36  			.replace("\\", " - ");
 37  	}
 38  	
 39  	/**
 40  	Call the GetGameInfoAndUserProgress endpoint to retrieve detailed information
 41  	about a specific game and the current user's progress for that game.
 42  	
 43  	---
 44  	
 45  	# [GetGameInfoAndUserProgress](https://api-docs.retroachievements.org/v1/get-game-info-and-user-progress.html)
 46  	
 47  	Example URL: `https://retroachievements.org/API/API_GetGameInfoAndUserProgress.php?a=1&g=#####&u=XXXXXXXX&y=XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX`
 48  	
 49  	---
 50  	
 51  	### Arguments
 52  	
 53  	Name | Required | Description
 54  	:--|:--|:--
 55  	y | Yes | Your web API key.
 56  	u | Yes | The target username or ULID.
 57  	g | Yes | The target game ID.
 58  	a | No | Set to "1" if user award metadata should be included (default: 0).
 59  	
 60  	You must query the user by either their username or their ULID. Please note the username is not considered a stable value. As of 2025, users can change their usernames. Initially querying by username is a good way to fetch a ULID.
 61  	
 62  	### Return Value
 63  	
 64  	A JSON response with the following properties:
 65  	
 66  	Name | Description
 67  	:--|:--
 68  	ID | int
 69  	Title | String
 70  	ConsoleID | int
 71  	ForumTopicID | int
 72  	Flags | int ; optional
 73  	ImageIcon | String
 74  	ImageTitle | String
 75  	ImageIngame | String
 76  	ImageBoxArt | String
 77  	Publisher | String
 78  	Developer | String
 79  	Genre | String
 80  	Released | Date time string
 81  	ReleasedAtGranularity | String
 82  	IsFinal | bool ; Deprecated, always returns false
 83  	RichPresencePatch | String
 84  	GuideURL | String ; optional
 85  	ConsoleName | String
 86  	ParentGameID | int ; optional
 87  	NumDistinctPlayers | int
 88  	NumAchievements | int
 89  	Achievements | Map\<int, Achievement\>
 90  	NumAwardedToUser | int
 91  	NumAwardedToUserHardcore | int
 92  	NumDistinctPlayersCasual | int
 93  	NumDistinctPlayersHardcore | int
 94  	UserCompletion | String
 95  	UserCompletionHardcore | String
 96  	HighestAwardKind | String ; optional
 97  	HighestAwardDate | Timestamp string ; optional
 98  	
 99  	#### Achievement JSON properties:
100  	
101  	Name | Description
102  	:--|:--
103  	ID | int
104  	Title | String
105  	Description | String
106  	Points | int
107  	TrueRatio | int
108  	Type | String ; optional
109  	BadgeName | String
110  	NumAwarded | int
111  	NumAwardedHardcore | int
112  	DisplayOrder | int
113  	Author | String
114  	AuthorULID | String
115  	DateCreated | String
116  	DateModified | String
117  	MemAddr | String
118  	DateEarned | String ; optional
119  	DateEarnedHardcore | String ; optional
120  	*/
121  	#[allow(unused)]
122  	pub fn getGameInfo(ulid: &String, gameId: u64) -> Result<Payload_GetGameInfo>
123  	{
124  		let auth = getRetroAchievementsAuth()?;
125  		let mut parameters = Self::generateParameterMap(&auth);
126  		parameters.remove(Self::Parameter_ApiUsername);
127  		parameters.insert("u".into(), ulid.clone());
128  		parameters.insert("g".into(), gameId.to_string());
129  		parameters.insert("a".into(), "1".into());
130  		
131  		return Ok(Self::get::<Payload_GetGameInfo>(
132  			&Self::Endpoint_GetGameInfo.into(),
133  			&parameters
134  		)
135  			.context(format!(
136  				"Error retrieving game info for {} from username {}",
137  				gameId,
138  				auth.username()
139  			))?);
140  	}
141  	
142  	/**
143  	Call the GetUserCompletionProgress endpoint to retrieve the current user's
144  	completion information for all games associated with their account.
145  	
146  	---
147  	
148  	# [GetUserCompletionProgress](https://api-docs.retroachievements.org/v1/get-user-completion-progress.html)
149  	
150  	Example URL:
151  	`https://retroachievements.org/API/API_GetUserCompletionProgress.php?u=XXXXXXXX&y=XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX`
152  	
153  	---
154  	
155  	### Arguments
156  	
157  	Name | Required | Description
158  	:--|:--|:--
159  	y | Yes | Your web API key.
160  	u | Yes | The target username or ULID.
161  	c | No | Count, number of records to return (default: 100, max: 500).
162  	o | No | Offset, number of entries to skip (default: 0).
163  	
164  	You must query the user by either their username or their ULID. Please note the username is not considered a stable value. As of 2025, users can change their usernames. Initially querying by username is a good way to fetch a ULID.
165  	
166  	### Return Value
167  	
168  	A JSON response with the following properties:
169  	
170  	Name | Description
171  	:--|:--
172  	Count | int
173  	Total | int
174  	Results | Array\<Game\>
175  	
176  	#### Game JSON properties:
177  	
178  	Name | Description
179  	:--|:--
180  	GameID | int
181  	Title | String
182  	ImageIcon | String
183  	ConsoleID | int
184  	ConsoleName | String
185  	MaxPossible | int
186  	NumAwarded | int
187  	NumAwardedHardcore | int
188  	MostRecentAwardedDate | Timestamp string
189  	HighestAwardKind | String
190  	HighestAwardDate | Timestamp string
191  	*/
192  	pub fn getUserCompletionProgress(ulid: Option<String>, offset: Option<u64>) -> Result<Payload_GetUserCompletionProgress>
193  	{
194  		let auth = getRetroAchievementsAuth()?;
195  		let mut parameters = Self::generateParameterMap(&auth);
196  		parameters.remove(Self::Parameter_ApiUsername);
197  		parameters.insert("u".into(), match ulid.clone()
198  		{
199  			Some(ulid) => ulid,
200  			None => auth.username().clone(),
201  		});
202  		parameters.insert("c".into(), Self::GetUserGameCompletion_Count.to_string());
203  		
204  		if let Some(o) = offset
205  		{
206  			parameters.insert("o".into(), o.to_string());
207  		}
208  		
209  		return Ok(Self::get::<Payload_GetUserCompletionProgress>(
210  			&Self::Endpoint_GetUserGameCompletion.into(),
211  			&parameters
212  		)
213  			.context(format!(
214  				"Error retrieving user completion progress for username {} (ulid {})",
215  				auth.username(),
216  				ulid.unwrap_or_default(),
217  			))?);
218  	}
219  	
220  	/**
221  	Call the GetUserProfile endpoint to retrieve the current user's profile
222  	information.
223  	
224  	---
225  	
226  	# [GetUserProfile](https://api-docs.retroachievements.org/v1/get-user-profile.html)
227  	
228  	Example URL:
229  	`https://retroachievements.org/API/API_GetUserProfile.php?u=XXXXXXXX&y=XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX`
230  	
231  	---
232  	
233  	### Arguments
234  	
235  	Name | Required | Description
236  	:--|:--|:--
237  	y | Yes | Your web API key.
238  	u | Yes | The target username or ULID.
239  	
240  	You must query the user by either their username or their ULID. Please note the username is not considered a stable value. As of 2025, users can change their usernames. Initially querying by username is a good way to fetch a ULID.
241  	
242  	### Return Value
243  	
244  	A JSON response with the following properties:
245  	
246  	Name | Description
247  	:--|:--
248  	User | String
249  	ULID | String
250  	UserPic | String
251  	MemberSince | Date time string
252  	RichPresenceMsg | String
253  	LastGameID | int
254  	ContribCount | int
255  	ContribYield | int
256  	TotalPoints | int
257  	TotalSoftcorePoints | int
258  	TotalTruePoints | int
259  	Permissions | int
260  	ID | int
261  	UserWallActive | bool
262  	Motto | String
263  	*/
264  	pub fn getUserProfile(ulid: Option<String>) -> Result<Payload_GetUserProfile>
265  	{
266  		let auth = getRetroAchievementsAuth()?;
267  		let mut parameters = Self::generateParameterMap(&auth);
268  		parameters.remove(Self::Parameter_ApiUsername);
269  		parameters.insert("u".into(), match ulid
270  		{
271  			Some(ulid) => ulid,
272  			None => auth.username().clone(),
273  		});
274  		
275  		return Ok(Self::get::<Payload_GetUserProfile>(
276  			&Self::Endpoint_GetUserProfile.into(),
277  			&parameters
278  		)
279  			.context(format!(
280  				"Error retrieving user profile for username {}",
281  				auth.username()
282  			))?);
283  	}
284  	
285  	pub fn buildMediaUrl(endpoint: &str) -> Option<String>
286  	{
287  		return Self::buildUrl(Self::MediaUrl, endpoint);
288  	}
289  	
290  	/**
291  	Generate a default parameter map containing the most commonly used parameters.
292  	*/
293  	fn buildUrl(base: &str, endpoint: &str) -> Option<String>
294  	{
295  		return Some(
296  			Path::new(base)
297  				.join(endpoint)
298  				.to_slash()?
299  				.into_owned()
300  		);
301  	}
302  	
303  	/**
304  	Generate a default parameter map containing the most commonly used parameters.
305  	*/
306  	fn generateParameterMap(auth: &RetroAchievementsAuth) -> HashMap<String, String>
307  	{
308  		return HashMap::from([
309  			(Self::Parameter_ApiKey.into(), auth.key().clone()),
310  			(Self::Parameter_ApiUsername.into(), auth.username().clone()),
311  		]);
312  	}
313  	
314  	/**
315  	Execute an HTTP GET request.
316  	*/
317  	fn get<T>(endpoint: &String, parameters: &HashMap<String, String>) -> Result<T>
318  		where T: DeserializeOwned
319  	{
320  		if let Some(url) = Self::buildUrl(Self::BaseUrl, endpoint)
321  		{
322  			let mut params = String::default();
323  			for (k, v) in parameters
324  			{
325  				params = format!("{}&{}={}", params, k, v);
326  			}
327  			
328  			let requestUrl = format!("{}?{}", url, params.split_off(1));
329  			
330  			let response = ureq::get(requestUrl)
331  				.call()
332  					.context("Error retrieving RetroAchievements API response")?
333  				.body_mut()
334  				.read_json::<T>()
335  					.context("Error parsing RetroAchievements API response as JSON")?;
336  			
337  			return Ok(response);
338  		}
339  		
340  		return Err(anyhow!(ErrorKind::InvalidInput));
341  	}
342  }