Compare commits

..

No commits in common. "tbsliver/wip" and "main" have entirely different histories.

10 changed files with 2 additions and 2121 deletions

View file

@ -1,3 +0,0 @@
.idea
.git
target

1
.gitignore vendored
View file

@ -1,2 +1 @@
/target
.env

1634
Cargo.lock generated

File diff suppressed because it is too large Load diff

View file

@ -6,23 +6,3 @@ edition = "2021"
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
[dependencies]
axum = "0.7.5"
dotenvy = "0.15.7"
redis = "0.25.3"
redis-derive = "0.1.7"
reqwest = "0.12.4"
tokio = { version = "1.0.0", features = ["rt", "rt-multi-thread", "macros"] }
tower-http = { version = "0.5.2", features = ["cors"] }
# found in https://github.com/twitch-rs/twitch_api/issues/405
twitch_api = { git = "https://github.com/twitch-rs/twitch_api/", features = ["client", "helix", "reqwest", "twitch_oauth2"] }
twitch_oauth2 = { git = "https://github.com/twitch-rs/twitch_api/", features = ["reqwest", "client"] }
twitch_types = { git = "https://github.com/twitch-rs/twitch_api/" }
serde = { version = "1.0.198", features = ["derive"] }
http = "1.1.0"
# workaround for https://github.com/twitch-rs/twitch_api/issues/256
[patch.crates-io.twitch_types]
git = "https://github.com/twitch-rs/twitch_api"
[patch.crates-io.twitch_oauth2]
git = "https://github.com/twitch-rs/twitch_api"

View file

@ -1,23 +0,0 @@
FROM rust:1.77.2-alpine3.19 as builder
RUN rustup --version
RUN rustup show
RUN apk add pkgconfig openssl-dev musl-dev
WORKDIR /usr/src/rust-twitch-avatar-cache
COPY . .
ENV RUSTFLAGS='-C target-feature=-crt-static'
RUN cargo install --verbose --path .
FROM alpine:3.19
RUN apk add --no-cache libgcc
COPY --from=builder /usr/local/cargo/bin/rust-twitch-avatar-cache /usr/local/bin/rust-twitch-avatar-cache
CMD ["rust-twitch-avatar-cache"]

View file

@ -1,78 +0,0 @@
use redis::{Client, Commands, ErrorKind, RedisResult};
use crate::config::RedisConfig;
use crate::user_data::UserData;
pub struct AvatarCache {
redis: Client,
timeout: i64,
}
impl AvatarCache {
pub fn new(redis: Client, timeout: i64) -> Self {
AvatarCache { redis, timeout }
}
pub fn connect(config: &RedisConfig) -> RedisResult<Self> {
let client = Client::open(config.url())?;
Ok(Self::new(client, config.cache_time()))
}
/// Get user data from Cache
pub fn get_cache_by_name(&self, name: &str) -> RedisResult<Option<UserData>> {
let mut con = self.redis.get_connection()?;
let id: String = match con.get(UserData::redis_name_from_str(name)) {
Ok(v) => v,
Err(e) => {
return if e.kind() == ErrorKind::TypeError {
// No cache for name
Ok(None)
} else {
// Something else went wrong
Err(e)
};
}
};
self.get_cache_by_id(&id)
}
pub fn get_cache_by_id(&self, id: &str) -> RedisResult<Option<UserData>> {
let mut con = self.redis.get_connection()?;
match con.hgetall(UserData::redis_id_from_str(&id)) {
Ok(v) => Ok(Some(v)),
Err(e) => {
if e.kind() == ErrorKind::TypeError {
// No cache for id
Ok(None)
} else {
// Something else went wrong
Err(e)
}
}
}
}
/// Set the data as needed
pub fn set_cache_data(&self, user_data: &UserData) -> RedisResult<()> {
let mut con = self.redis.get_connection()?;
redis::cmd("HSET")
.arg(user_data.id_to_redis())
.arg(user_data)
.query(&mut con)?;
con.set(user_data.name_to_redis(), user_data.id())?;
self.update_cache_expiry(user_data)
}
pub fn update_cache_expiry(&self, user_data: &UserData) -> RedisResult<()> {
let mut con = self.redis.get_connection()?;
con.expire(user_data.id_to_redis(), self.timeout)?;
con.expire(user_data.name_to_redis(), self.timeout)?;
Ok(())
}
}

View file

@ -1,62 +0,0 @@
use std::error::Error;
use twitch_api::helix::users;
use twitch_api::TwitchClient;
use twitch_oauth2::AppAccessToken;
use crate::config;
use crate::user_data::UserData;
pub struct AvatarFetch {
client: TwitchClient<'static, reqwest::Client>,
token: AppAccessToken,
}
impl AvatarFetch {
pub fn new(client: TwitchClient<'static, reqwest::Client>, token: AppAccessToken) -> Self {
AvatarFetch { client, token }
}
pub async fn connect(config: &config::TwitchConfig) -> Result<Self, Box<dyn Error>> {
let client: TwitchClient<reqwest::Client> = TwitchClient::new();
let token = AppAccessToken::get_app_access_token(
&client,
config.client_id().into(),
config.client_secret().into(),
vec![],
)
.await?;
Ok(Self::new(client, token))
}
pub async fn get_user_by_id(&self, id: &str) -> Result<Option<UserData>, Box<dyn Error>> {
let mut request = users::GetUsersRequest::new();
let ids: &[&twitch_types::UserIdRef] = &[id.into()];
request.id = ids.into();
let response: Vec<users::User> =
self.client.helix.req_get(request, &self.token).await?.data;
Ok(self.user_from_response(response))
}
pub fn user_from_response(&self, response: Vec<users::User>) -> Option<UserData> {
response.first().map(|user| {
UserData::new(
user.login.to_string(),
user.display_name.to_string(),
user.id.to_string(),
user.profile_image_url.clone().unwrap_or("".to_string()),
)
})
}
pub async fn get_user_by_name(&self, name: &str) -> Result<Option<UserData>, Box<dyn Error>> {
let mut request = users::GetUsersRequest::new();
let logins: &[&twitch_types::UserNameRef] = &[name.into()];
request.login = logins.into();
let response: Vec<users::User> =
self.client.helix.req_get(request, &self.token).await?.data;
Ok(self.user_from_response(response))
}
}

View file

@ -1,96 +0,0 @@
use std::error::Error;
#[derive(Debug)]
pub struct TwitchConfig {
client_id: String,
client_secret: String,
}
impl TwitchConfig {
pub fn client_id(&self) -> &str {
&self.client_id
}
pub fn client_secret(&self) -> &str {
&self.client_secret
}
}
#[derive(Debug)]
pub struct RedisConfig {
url: String,
cache_time: i64,
}
impl RedisConfig {
pub fn url(&self) -> &str {
&self.url
}
pub fn cache_time(&self) -> i64 {
self.cache_time
}
}
#[derive(Debug)]
pub struct AppConfig {
bind_port: i64,
bind_host: String,
}
impl AppConfig {
pub fn bind_address(&self) -> String {
format!("{}:{}", self.bind_host, self.bind_port)
}
}
#[derive(Debug)]
pub struct Config {
twitch: TwitchConfig,
redis: RedisConfig,
app: AppConfig,
}
impl Config {
pub fn from_env() -> Result<Self, Box<dyn Error>> {
dotenvy::dotenv().ok();
let redis_url = std::env::var("REDIS_URL").unwrap_or("redis://127.0.0.1/".to_string());
let redis_cache_time: i64 = std::env::var("REDIS_CACHE_TIME")
.unwrap_or("60".to_string())
.parse()?;
let twitch_client_id = std::env::var("TWITCH_CLIENT_ID")?;
let twitch_client_secret = std::env::var("TWITCH_CLIENT_SECRET")?;
let app_bind_port: i64 = std::env::var("APP_BIND_PORT")
.unwrap_or("3000".to_string())
.parse()?;
let app_bind_host = std::env::var("APP_BIND_HOST").unwrap_or("127.0.0.1".to_string());
Ok(Self {
twitch: TwitchConfig {
client_id: twitch_client_id,
client_secret: twitch_client_secret,
},
redis: RedisConfig {
url: redis_url,
cache_time: redis_cache_time,
},
app: AppConfig {
bind_port: app_bind_port,
bind_host: app_bind_host,
},
})
}
pub fn redis(&self) -> &RedisConfig {
&self.redis
}
pub fn twitch(&self) -> &TwitchConfig {
&self.twitch
}
pub fn app(&self) -> &AppConfig {
&self.app
}
}

View file

@ -1,150 +1,3 @@
use axum::extract::{Query, State};
use axum::routing::get;
use axum::{serve, Json, Router};
use serde::{Deserialize, Serialize};
use std::error::Error;
use std::sync::Arc;
use tokio::net::TcpListener;
use tower_http::cors::{Any, CorsLayer};
use http::Method;
use crate::avatar_cache::AvatarCache;
use crate::avatar_fetch::AvatarFetch;
use crate::config::Config;
mod avatar_cache;
mod avatar_fetch;
mod config;
mod user_data;
#[tokio::main]
async fn main() -> Result<(), Box<dyn Error>> {
let config = Config::from_env()?;
let cache = AvatarCache::connect(config.redis())?;
let fetch = AvatarFetch::connect(config.twitch()).await?;
let state = Arc::new(AppState::new(cache, fetch));
let router = Router::new()
.route("/", get(AppRoute::root))
.layer(CorsLayer::new().allow_origin(Any).allow_methods([Method::GET]))
.with_state(state);
let listener = TcpListener::bind(config.app().bind_address()).await?;
println!("listening on {}", listener.local_addr()?);
serve(listener, router).await?;
Ok(())
}
struct AppState {
cache: AvatarCache,
fetch: AvatarFetch,
}
impl AppState {
pub fn new(cache: AvatarCache, fetch: AvatarFetch) -> Self {
AppState { cache, fetch }
}
}
struct AppRoute {}
impl AppRoute {
async fn root(
State(state): State<Arc<AppState>>,
Query(params): Query<AppQuery>,
) -> Json<AppResponse> {
let mut response = AppResponse {
success: false,
id: None,
name: None,
display_name: None,
profile_picture_url: None,
};
if let Some(id) = params.id {
let cache_val = state.cache.get_cache_by_id(&id).unwrap_or_else(|e| {
// Error fetching from cache
println!("Error Getting Cache by ID [{}] {}", id, e);
// Might still be able to just fetch, pretend empty
None
});
if let Some(user) = cache_val {
println!("Cache Hit for ID [{}]", id);
response = user.to_app_response();
state.cache.update_cache_expiry(&user).unwrap_or_else(|e| {
// this is going well, isn't it?
println!("Error Updating Cache Expiry for ID [{}] {}", id, e);
});
} else {
println!("Cache Miss for ID [{}]", id);
let fetch_val = state.fetch.get_user_by_id(&id).await.unwrap_or_else(|e| {
// Something went really wrong...
println!("Error Fetching by ID [{}] {}", id, e);
None
});
if let Some(user) = fetch_val {
println!("Fetch Hit for ID [{}]", id);
response = user.to_app_response();
state.cache.set_cache_data(&user).unwrap_or_else(|e| {
println!("Error saving Cache Data for ID [{}] {}", id, e);
})
}
}
} else if let Some(name) = params.name {
let cache_val = state.cache.get_cache_by_name(&name).unwrap_or_else(|e| {
// Error fetching from cache
println!("Error Getting Cache by Name [{}] {}", name, e);
// Might still be able to just fetch, pretend empty
None
});
if let Some(user) = cache_val {
println!("Cache Hit for Name [{}]", name);
response = user.to_app_response();
state.cache.update_cache_expiry(&user).unwrap_or_else(|e| {
// this is going well, isn't it?
println!("Error Updating Cache Expiry for Name [{}] {}", name, e);
});
} else {
println!("Cache Miss for Name [{}]", name);
let fetch_val = state
.fetch
.get_user_by_name(&name)
.await
.unwrap_or_else(|e| {
// Something went really wrong...
println!("Error Fetching by Name [{}] {}", name, e);
None
});
if let Some(user) = fetch_val {
println!("Fetch Hit for Name [{}]", name);
response = user.to_app_response();
state.cache.set_cache_data(&user).unwrap_or_else(|e| {
println!("Error saving Cache Data for Name [{}] {}", name, e);
})
}
}
}
Json(response)
}
}
#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
struct AppResponse {
success: bool,
id: Option<String>,
name: Option<String>,
display_name: Option<String>,
profile_picture_url: Option<String>,
}
#[derive(Debug, Deserialize)]
struct AppQuery {
id: Option<String>,
name: Option<String>,
fn main() {
println!("Hello, world!");
}

View file

@ -1,55 +0,0 @@
use crate::AppResponse;
use redis_derive::{FromRedisValue, ToRedisArgs};
#[derive(Debug, ToRedisArgs, FromRedisValue)]
pub struct UserData {
name: String,
display_name: String,
id: String,
profile_picture_url: String,
}
impl UserData {
pub fn new(
name: String,
display_name: String,
id: String,
profile_picture_url: String,
) -> Self {
UserData {
name,
display_name,
id,
profile_picture_url,
}
}
pub fn redis_id_from_str(name: &str) -> String {
format!("twitchId:{}", name)
}
pub fn redis_name_from_str(name: &str) -> String {
format!("twitchName:{}", name)
}
pub fn id_to_redis(&self) -> String {
Self::redis_id_from_str(&self.id)
}
pub fn name_to_redis(&self) -> String {
Self::redis_name_from_str(&self.name)
}
pub fn id(&self) -> &str {
&self.id
}
pub fn to_app_response(&self) -> AppResponse {
AppResponse {
success: true,
id: Some(self.id.clone()),
name: Some(self.name.clone()),
display_name: Some(self.display_name.clone()),
profile_picture_url: Some(self.profile_picture_url.clone()),
}
}
}