steel_core/player/profile/
lookup.rs1use reqwest::{StatusCode, Url};
4use serde::Deserialize;
5use thiserror::Error;
6use tokio::time::{Duration, sleep};
7use uuid::Uuid;
8
9use super::known_players::KnownPlayer;
10
11const DEFAULT_PROFILE_SERVER: &str =
12 "https://api.minecraftservices.com/minecraft/profile/lookup/name";
13const MAX_PROFILE_LOOKUP_ATTEMPTS: usize = 3;
14const PROFILE_LOOKUP_RETRY_DELAY: Duration = Duration::from_millis(750);
15const PROFILE_LOOKUP_REQUEST_TIMEOUT: Duration = Duration::from_secs(5);
17
18#[derive(Debug, Error)]
20pub enum ProfileLookupError {
21 #[error("Unknown player {0}")]
23 UnknownPlayer(String),
24 #[error("Invalid profile server URL configured: {0}")]
26 InvalidProfileServer(String),
27 #[error("Profile lookup failed for {name}: {source}")]
29 Request {
30 name: String,
32 source: reqwest::Error,
34 },
35 #[error("Profile lookup service returned status {status} for {name}")]
37 ServiceResponse {
38 name: String,
40 status: StatusCode,
42 },
43 #[error("Invalid profile lookup response for {name}: {reason}")]
45 InvalidResponse {
46 name: String,
48 reason: String,
50 },
51}
52
53#[derive(Deserialize)]
54struct ProfileLookupResponse {
55 id: String,
56 name: String,
57}
58
59pub async fn lookup_online_profile(
63 client: &reqwest::Client,
64 profile_server: Option<&str>,
65 name: &str,
66) -> Result<KnownPlayer, ProfileLookupError> {
67 let lookup_name = name.to_ascii_lowercase();
68 let url = profile_lookup_url(profile_server, &lookup_name)?;
69 for attempt in 1..=MAX_PROFILE_LOOKUP_ATTEMPTS {
70 let result =
71 lookup_online_profile_once(client, url.as_str(), name, PROFILE_LOOKUP_REQUEST_TIMEOUT)
72 .await;
73 match result {
74 Ok(profile) => return Ok(profile),
75 Err(error @ ProfileLookupError::UnknownPlayer(_)) => return Err(error),
76 Err(error) if attempt == MAX_PROFILE_LOOKUP_ATTEMPTS => return Err(error),
77 Err(_) => sleep(PROFILE_LOOKUP_RETRY_DELAY).await,
78 }
79 }
80 unreachable!("the profile lookup attempt range is non-empty")
81}
82
83async fn lookup_online_profile_once(
84 client: &reqwest::Client,
85 url: &str,
86 name: &str,
87 request_timeout: Duration,
88) -> Result<KnownPlayer, ProfileLookupError> {
89 let response = client
90 .get(url)
91 .timeout(request_timeout)
92 .send()
93 .await
94 .map_err(|source| ProfileLookupError::Request {
95 name: name.to_owned(),
96 source,
97 })?;
98
99 match response.status() {
100 StatusCode::OK => parse_profile_response(response, name).await,
101 StatusCode::NO_CONTENT | StatusCode::NOT_FOUND => {
102 Err(ProfileLookupError::UnknownPlayer(name.to_owned()))
103 }
104 status => Err(ProfileLookupError::ServiceResponse {
105 name: name.to_owned(),
106 status,
107 }),
108 }
109}
110
111fn profile_lookup_url(
112 profile_server: Option<&str>,
113 normalized_name: &str,
114) -> Result<Url, ProfileLookupError> {
115 let server = profile_server.unwrap_or(DEFAULT_PROFILE_SERVER);
116 let endpoint = format!("{}/{normalized_name}", server.trim_end_matches('/'));
117 Url::parse(&endpoint).map_err(|_| ProfileLookupError::InvalidProfileServer(endpoint))
118}
119
120async fn parse_profile_response(
121 response: reqwest::Response,
122 requested_name: &str,
123) -> Result<KnownPlayer, ProfileLookupError> {
124 let profile = response
125 .json::<ProfileLookupResponse>()
126 .await
127 .map_err(|source| ProfileLookupError::InvalidResponse {
128 name: requested_name.to_owned(),
129 reason: source.to_string(),
130 })?;
131 let uuid =
132 Uuid::parse_str(&profile.id).map_err(|source| ProfileLookupError::InvalidResponse {
133 name: requested_name.to_owned(),
134 reason: source.to_string(),
135 })?;
136 Ok(KnownPlayer::new(uuid, profile.name))
137}
138
139#[cfg(test)]
140mod tests {
141 use super::profile_lookup_url;
142
143 #[test]
144 fn profile_lookup_url_uses_mojangs_default_endpoint() {
145 let url = profile_lookup_url(None, "steve");
146 let Ok(url) = url else {
147 panic!("default profile lookup URL should build");
148 };
149 assert_eq!(
150 url.as_str(),
151 "https://api.minecraftservices.com/minecraft/profile/lookup/name/steve"
152 );
153 }
154
155 #[test]
156 fn profile_lookup_url_uses_the_configured_endpoint() {
157 let url = profile_lookup_url(Some("https://profiles.example.com/lookup/"), "steve");
158 let Ok(url) = url else {
159 panic!("configured profile lookup URL should build");
160 };
161 assert_eq!(url.as_str(), "https://profiles.example.com/lookup/steve");
162 }
163}