use serde::Deserialize;
use thiserror::Error;
use crate::TwitchIdentity;
use crate::eventsub::{CreateEventSubSubscriptionRequest, CreateEventSubSubscriptionResponse};
use crate::http::form_body;
use crate::oauth::{TwitchAuthOutcome, TwitchTokenState};
const TWITCH_TOKEN_URL: &str = "https://id.twitch.tv/oauth2/token";
#[derive(Debug, Error)]
pub enum HelixError {
#[error("twitch API request failed with status {status}: {body}")]
ApiError { status: u16, body: String },
#[error("twitch API response failed to decode: {0}")]
Json(#[from] serde_json::Error),
#[error("twitch user lookup returned no users")]
NoUsers,
#[error("twitch token exchange omitted refresh token")]
MissingRefreshToken,
#[error("client_id or client_secret is not configured")]
MissingCredentials,
}
pub use crate::http::{HttpMethod, PreparedRequest, RawResponse};
#[derive(Clone, Debug, PartialEq, Eq, Deserialize)]
pub struct TwitchTokenExchange {
pub access_token: String,
pub refresh_token: Option<String>,
pub expires_in: Option<u32>,
pub scope: Option<Vec<String>>,
pub token_type: Option<String>,
}
#[derive(Clone, Debug, PartialEq, Eq, Deserialize)]
struct TwitchUsersResponse {
data: Vec<TwitchUserRecord>,
}
#[derive(Clone, Debug, PartialEq, Eq, Deserialize)]
struct TwitchUserRecord {
id: String,
login: String,
display_name: String,
}
pub fn token_exchange_request(
client_id: &str,
client_secret: &str,
code: &str,
redirect_uri: &str,
) -> PreparedRequest {
PreparedRequest {
url: TWITCH_TOKEN_URL.to_string(),
method: HttpMethod::Post,
headers: vec![(
"Content-Type".to_string(),
"application/x-www-form-urlencoded".to_string(),
)],
body: Some(form_body(&[
("client_id", client_id),
("client_secret", client_secret),
("code", code),
("grant_type", "authorization_code"),
("redirect_uri", redirect_uri),
])),
}
}
pub fn token_refresh_request(
client_id: &str,
client_secret: &str,
refresh_token: &str,
) -> PreparedRequest {
PreparedRequest {
url: TWITCH_TOKEN_URL.to_string(),
method: HttpMethod::Post,
headers: vec![(
"Content-Type".to_string(),
"application/x-www-form-urlencoded".to_string(),
)],
body: Some(form_body(&[
("client_id", client_id),
("client_secret", client_secret),
("grant_type", "refresh_token"),
("refresh_token", refresh_token),
])),
}
}
pub fn user_lookup_request(access_token: &str, client_id: &str) -> PreparedRequest {
PreparedRequest {
url: "https://api.twitch.tv/helix/users".to_string(),
method: HttpMethod::Get,
headers: vec![
(
"Authorization".to_string(),
format!("Bearer {access_token}"),
),
("Client-Id".to_string(), client_id.to_string()),
],
body: None,
}
}
pub fn user_lookup_by_login_request(
access_token: &str,
client_id: &str,
login: &str,
) -> PreparedRequest {
PreparedRequest {
url: format!(
"https://api.twitch.tv/helix/users?login={}",
crate::http::percent_encode(login)
),
method: HttpMethod::Get,
headers: vec![
(
"Authorization".to_string(),
format!("Bearer {access_token}"),
),
("Client-Id".to_string(), client_id.to_string()),
],
body: None,
}
}
pub fn create_eventsub_subscription_request(
client_id: &str,
access_token: &str,
subscription: &CreateEventSubSubscriptionRequest,
) -> Result<PreparedRequest, HelixError> {
let body = serde_json::to_string(subscription)?;
Ok(PreparedRequest {
url: "https://api.twitch.tv/helix/eventsub/subscriptions".to_string(),
method: HttpMethod::Post,
headers: vec![
(
"Authorization".to_string(),
format!("Bearer {access_token}"),
),
("Client-Id".to_string(), client_id.to_string()),
("Content-Type".to_string(), "application/json".to_string()),
],
body: Some(body),
})
}
pub fn list_eventsub_subscriptions_request(client_id: &str, access_token: &str) -> PreparedRequest {
PreparedRequest {
url: "https://api.twitch.tv/helix/eventsub/subscriptions?type=channel.chat.message"
.to_string(),
method: HttpMethod::Get,
headers: vec![
(
"Authorization".to_string(),
format!("Bearer {access_token}"),
),
("Client-Id".to_string(), client_id.to_string()),
],
body: None,
}
}
pub fn delete_eventsub_subscription_request(
client_id: &str,
access_token: &str,
subscription_id: &str,
) -> PreparedRequest {
PreparedRequest {
url: format!(
"https://api.twitch.tv/helix/eventsub/subscriptions?id={}",
crate::http::percent_encode(subscription_id)
),
method: HttpMethod::Delete,
headers: vec![
(
"Authorization".to_string(),
format!("Bearer {access_token}"),
),
("Client-Id".to_string(), client_id.to_string()),
],
body: None,
}
}
pub fn parse_token_exchange(response: RawResponse) -> Result<TwitchTokenExchange, HelixError> {
if response.status != 200 {
return Err(HelixError::ApiError {
status: response.status,
body: response.body,
});
}
serde_json::from_str(&response.body).map_err(HelixError::from)
}
pub fn parse_token_refresh(response: RawResponse) -> Result<TwitchTokenExchange, HelixError> {
parse_token_exchange(response)
}
pub fn parse_user_lookup(response: RawResponse) -> Result<TwitchIdentity, HelixError> {
if response.status != 200 {
return Err(HelixError::ApiError {
status: response.status,
body: response.body,
});
}
let users: TwitchUsersResponse = serde_json::from_str(&response.body)?;
let user = users.data.into_iter().next().ok_or(HelixError::NoUsers)?;
Ok(TwitchIdentity::new(user.id, user.login, user.display_name))
}
pub fn build_auth_outcome(
identity: TwitchIdentity,
exchange: TwitchTokenExchange,
now_ms: i64,
) -> Result<TwitchAuthOutcome, HelixError> {
Ok(TwitchAuthOutcome {
identity,
tokens: TwitchTokenState {
access_token: exchange.access_token,
refresh_token: exchange
.refresh_token
.ok_or(HelixError::MissingRefreshToken)?,
expires_in_seconds: exchange.expires_in,
scope: exchange.scope.unwrap_or_default(),
token_type: exchange.token_type.unwrap_or_else(|| "bearer".to_string()),
linked_at_ms: now_ms,
},
})
}
pub fn parse_create_eventsub_subscription(
response: RawResponse,
) -> Result<CreateEventSubSubscriptionResponse, HelixError> {
if response.status != 202 {
return Err(HelixError::ApiError {
status: response.status,
body: response.body,
});
}
serde_json::from_str(&response.body).map_err(HelixError::from)
}
pub fn parse_list_eventsub_subscriptions(
response: RawResponse,
) -> Result<CreateEventSubSubscriptionResponse, HelixError> {
if response.status != 200 {
return Err(HelixError::ApiError {
status: response.status,
body: response.body,
});
}
serde_json::from_str(&response.body).map_err(HelixError::from)
}
pub fn parse_delete_eventsub_subscription(response: RawResponse) -> Result<(), HelixError> {
if response.status != 204 {
return Err(HelixError::ApiError {
status: response.status,
body: response.body,
});
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn token_exchange_request_has_correct_structure() {
let req = token_exchange_request("cid", "csecret", "authcode", "https://example.com/cb");
assert_eq!(req.url, "https://id.twitch.tv/oauth2/token");
assert_eq!(req.method, HttpMethod::Post);
let body = req.body.unwrap();
assert!(body.contains("client_id=cid"));
assert!(body.contains("grant_type=authorization_code"));
assert!(body.contains("code=authcode"));
}
#[test]
fn token_refresh_request_has_correct_structure() {
let req = token_refresh_request("cid", "csecret", "rtoken");
assert_eq!(req.method, HttpMethod::Post);
let body = req.body.unwrap();
assert!(body.contains("grant_type=refresh_token"));
assert!(body.contains("refresh_token=rtoken"));
}
#[test]
fn user_lookup_request_has_auth_headers() {
let req = user_lookup_request("my-token", "my-client");
assert_eq!(req.method, HttpMethod::Get);
assert!(
req.headers
.iter()
.any(|(k, v)| k == "Authorization" && v == "Bearer my-token")
);
assert!(
req.headers
.iter()
.any(|(k, v)| k == "Client-Id" && v == "my-client")
);
}
#[test]
fn parse_token_exchange_rejects_non_200() {
let resp = RawResponse {
status: 400,
body: "bad request".to_string(),
};
assert!(matches!(
parse_token_exchange(resp),
Err(HelixError::ApiError { status: 400, .. })
));
}
#[test]
fn parse_user_lookup_extracts_identity() {
let resp = RawResponse {
status: 200,
body: r#"{"data":[{"id":"42","login":"tester","display_name":"Tester"}]}"#.to_string(),
};
let identity = parse_user_lookup(resp).expect("should parse");
assert_eq!(identity.user_id, "42");
assert_eq!(identity.login, "tester");
assert_eq!(identity.display_name, "Tester");
}
#[test]
fn parse_user_lookup_rejects_empty_data() {
let resp = RawResponse {
status: 200,
body: r#"{"data":[]}"#.to_string(),
};
assert!(matches!(parse_user_lookup(resp), Err(HelixError::NoUsers)));
}
#[test]
fn delete_eventsub_request_uses_query_param() {
let req = delete_eventsub_subscription_request("cid", "tok", "sub-123");
assert_eq!(req.method, HttpMethod::Delete);
assert!(req.url.contains("id=sub-123"));
}
}