Skip to main content

steel_core/player/profile/
lookup.rs

1//! Online player name-to-identity lookup.
2
3use 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);
15/// Bounds every attempt so suspended administrative commands always release their ordering barrier.
16const PROFILE_LOOKUP_REQUEST_TIMEOUT: Duration = Duration::from_secs(5);
17
18/// Failure while resolving a player identity through the configured profile service.
19#[derive(Debug, Error)]
20pub enum ProfileLookupError {
21    /// No profile exists for the requested name.
22    #[error("Unknown player {0}")]
23    UnknownPlayer(String),
24    /// The configured profile lookup endpoint is invalid.
25    #[error("Invalid profile server URL configured: {0}")]
26    InvalidProfileServer(String),
27    /// The request failed before a response was received.
28    #[error("Profile lookup failed for {name}: {source}")]
29    Request {
30        /// Requested player name.
31        name: String,
32        /// Transport error.
33        source: reqwest::Error,
34    },
35    /// The service returned an unexpected status.
36    #[error("Profile lookup service returned status {status} for {name}")]
37    ServiceResponse {
38        /// Requested player name.
39        name: String,
40        /// HTTP response status.
41        status: StatusCode,
42    },
43    /// The service returned malformed identity data.
44    #[error("Invalid profile lookup response for {name}: {reason}")]
45    InvalidResponse {
46        /// Requested player name.
47        name: String,
48        /// Response validation failure.
49        reason: String,
50    },
51}
52
53#[derive(Deserialize)]
54struct ProfileLookupResponse {
55    id: String,
56    name: String,
57}
58
59/// Resolves one online-mode profile through the configured service.
60///
61/// The caller handles local caches, offline mode, and name validation first.
62pub 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}