#[non_exhaustive]
#[derive(Debug, Clone, serde::Deserialize)]
pub struct TokenResponseFields {
pub access_token: String,
#[serde(default)]
pub refresh_token: Option<String>,
#[serde(default)]
pub expires_in: Option<u64>,
#[serde(default)]
pub token_type: Option<String>,
#[serde(default)]
pub id_token: Option<String>,
#[serde(default)]
pub scope: Option<String>,
}
impl TokenResponseFields {
#[must_use]
pub const fn new(access_token: String) -> Self {
Self {
access_token,
refresh_token: None,
expires_in: None,
token_type: None,
id_token: None,
scope: None,
}
}
#[must_use]
pub fn with_refresh_token(mut self, refresh_token: Option<String>) -> Self {
self.refresh_token = refresh_token;
self
}
#[must_use]
pub const fn with_expires_in(mut self, expires_in: Option<u64>) -> Self {
self.expires_in = expires_in;
self
}
#[must_use]
pub fn with_token_type(mut self, token_type: Option<String>) -> Self {
self.token_type = token_type;
self
}
#[must_use]
pub fn with_id_token(mut self, id_token: Option<String>) -> Self {
self.id_token = id_token;
self
}
#[must_use]
pub fn with_scope(mut self, scope: Option<String>) -> Self {
self.scope = scope;
self
}
}
pub type TokenParser = Box<dyn Fn(&str) -> Result<TokenResponseFields, String> + Send + Sync>;
pub fn default_token_parser() -> TokenParser {
Box::new(|body: &str| serde_json::from_str(body).map_err(|e| e.to_string()))
}
pub fn custom_token_parser<R>() -> TokenParser
where
R: serde::de::DeserializeOwned + Into<TokenResponseFields> + Send + 'static,
{
Box::new(|body: &str| {
let response: R = serde_json::from_str(body).map_err(|e| e.to_string())?;
Ok(response.into())
})
}
#[cfg(test)]
mod tests {
#![expect(clippy::unwrap_used, reason = "tests use unwrap for brevity")]
use super::*;
#[test]
fn default_parser_parses_standard_flat_response() {
let json = r#"{
"access_token": "tok_abc",
"refresh_token": "ref_xyz",
"expires_in": 3600,
"token_type": "Bearer",
"scope": "read write"
}"#;
let parser = default_token_parser();
let fields = parser(json).unwrap();
assert_eq!(fields.access_token, "tok_abc", "access_token should match");
assert_eq!(
fields.refresh_token.as_deref(),
Some("ref_xyz"),
"refresh_token should match"
);
assert_eq!(fields.expires_in, Some(3600), "expires_in should match");
assert_eq!(
fields.token_type.as_deref(),
Some("Bearer"),
"token_type should match"
);
assert_eq!(
fields.scope.as_deref(),
Some("read write"),
"scope should match"
);
assert!(fields.id_token.is_none(), "id_token should be None");
}
#[test]
fn default_parser_handles_minimal_response() {
let json = r#"{"access_token": "tok"}"#;
let parser = default_token_parser();
let fields = parser(json).unwrap();
assert_eq!(fields.access_token, "tok", "access_token should match");
assert!(
fields.refresh_token.is_none(),
"refresh_token should be None"
);
assert!(fields.expires_in.is_none(), "expires_in should be None");
}
#[test]
fn default_parser_rejects_missing_access_token() {
let json = r#"{"refresh_token": "ref"}"#;
let parser = default_token_parser();
assert!(parser(json).is_err(), "should fail without access_token");
}
#[derive(serde::Deserialize)]
struct NestedTokenResponse {
authed_user: NestedUser,
}
#[derive(serde::Deserialize)]
struct NestedUser {
access_token: String,
refresh_token: Option<String>,
expires_in: Option<u64>,
}
impl From<NestedTokenResponse> for TokenResponseFields {
fn from(resp: NestedTokenResponse) -> Self {
Self::new(resp.authed_user.access_token)
.with_refresh_token(resp.authed_user.refresh_token)
.with_expires_in(resp.authed_user.expires_in)
.with_token_type(Some("Bearer".to_string()))
}
}
#[test]
fn custom_parser_parses_nested_response() {
let json = r#"{
"ok": true,
"authed_user": {
"access_token": "xoxp-slack-token",
"refresh_token": "xoxe-refresh",
"expires_in": 43200
}
}"#;
let parser = custom_token_parser::<NestedTokenResponse>();
let fields = parser(json).unwrap();
assert_eq!(
fields.access_token, "xoxp-slack-token",
"should extract nested access_token"
);
assert_eq!(
fields.refresh_token.as_deref(),
Some("xoxe-refresh"),
"should extract nested refresh_token"
);
assert_eq!(
fields.expires_in,
Some(43200),
"should extract nested expires_in"
);
assert_eq!(
fields.token_type.as_deref(),
Some("Bearer"),
"should use impl-provided token_type"
);
}
#[test]
fn custom_parser_rejects_flat_response() {
let json = r#"{"access_token": "tok"}"#;
let parser = custom_token_parser::<NestedTokenResponse>();
assert!(
parser(json).is_err(),
"nested parser should reject flat response"
);
}
}