use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct UserInfo {
pub sub: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub name: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub given_name: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub family_name: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub middle_name: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub nickname: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub preferred_username: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub profile: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub picture: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub website: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub email: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub email_verified: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
pub gender: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub birthdate: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub zoneinfo: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub locale: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub phone_number: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub phone_number_verified: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
pub address: Option<serde_json::Value>,
#[serde(skip_serializing_if = "Option::is_none")]
pub updated_at: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub groups: Option<Vec<String>>,
}
impl UserInfo {
pub fn validate(&self) -> Result<(), UserInfoError> {
if self.sub.is_empty() {
return Err(UserInfoError::MissingSubject);
}
Ok(())
}
}
pub struct UserInfoClient;
impl UserInfoClient {
pub async fn fetch(
userinfo_url: &str,
access_token: &str,
) -> Result<UserInfo, UserInfoError> {
let body = fetch_userinfo(userinfo_url, access_token).await?;
let info: UserInfo = serde_json::from_str(&body)
.map_err(|e| UserInfoError::ParseError(e.to_string()))?;
info.validate()?;
Ok(info)
}
}
#[derive(Debug)]
pub enum UserInfoError {
InvalidUrl(String),
FetchFailed(String),
Unauthorized(String),
ParseError(String),
MissingSubject,
}
impl core::fmt::Display for UserInfoError {
fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
match self {
UserInfoError::InvalidUrl(s) => write!(f, "UserInfo invalid URL: {s}"),
UserInfoError::FetchFailed(s) => write!(f, "UserInfo fetch failed: {s}"),
UserInfoError::Unauthorized(s) => write!(f, "UserInfo unauthorized: {s}"),
UserInfoError::ParseError(s) => write!(f, "UserInfo parse error: {s}"),
UserInfoError::MissingSubject => write!(f, "UserInfo missing subject"),
}
}
}
impl std::error::Error for UserInfoError {}
async fn fetch_userinfo(
url: &str,
access_token: &str,
) -> Result<String, UserInfoError> {
use foundation_core::url::Uri;
use foundation_netio::default_http_client;
use foundation_netio::shared::client::body_reader::try_collect_bytes;
use foundation_netio::shared::client::request::PreparedRequest;
use foundation_netio::shared::http::{
SendSafeBody, SimpleHeader, SimpleHeaders, SimpleMethod,
};
let client = default_http_client();
let uri = Uri::parse(url)
.map_err(|e| UserInfoError::FetchFailed(format!("invalid URL: {e}")))?;
let mut headers = SimpleHeaders::new();
headers.insert(SimpleHeader::ACCEPT, vec!["application/json".into()]);
headers.insert(
SimpleHeader::AUTHORIZATION,
vec![format!("Bearer {access_token}")],
);
let req = PreparedRequest {
method: SimpleMethod::GET,
url: uri,
headers,
body: SendSafeBody::None,
extensions: Default::default(),
};
let resp = client
.send_async(req)
.await
.map_err(|e| UserInfoError::FetchFailed(e.to_string()))?;
let status: usize = resp.get_status().into();
let body = try_collect_bytes(resp.take_body())
.map(|b| String::from_utf8_lossy(&b).to_string())
.map_err(|e| UserInfoError::FetchFailed(format!("read body: {e}")))?;
if status == 401 {
return Err(UserInfoError::Unauthorized(body));
}
if !(200..300).contains(&status) {
return Err(UserInfoError::FetchFailed(format!("HTTP {status}: {body}")));
}
Ok(body)
}