Compare commits
11 commits
main
...
tbsliver/w
| Author | SHA1 | Date | |
|---|---|---|---|
| b2820c1ca8 | |||
| c9745d292e | |||
| 28d5fd71fb | |||
| c194daa42c | |||
| ac0f6292ed | |||
| f1561590a8 | |||
| 74f782da45 | |||
| 7c1bd99be3 | |||
| f94f1baba5 | |||
| abb999e3f2 | |||
| e9b12a999c |
10 changed files with 2121 additions and 2 deletions
3
.dockerignore
Normal file
3
.dockerignore
Normal file
|
|
@ -0,0 +1,3 @@
|
||||||
|
.idea
|
||||||
|
.git
|
||||||
|
target
|
||||||
1
.gitignore
vendored
1
.gitignore
vendored
|
|
@ -1 +1,2 @@
|
||||||
/target
|
/target
|
||||||
|
.env
|
||||||
|
|
|
||||||
1634
Cargo.lock
generated
1634
Cargo.lock
generated
File diff suppressed because it is too large
Load diff
20
Cargo.toml
20
Cargo.toml
|
|
@ -6,3 +6,23 @@ edition = "2021"
|
||||||
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
|
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
|
||||||
|
|
||||||
[dependencies]
|
[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"
|
||||||
|
|
|
||||||
23
Dockerfile
Normal file
23
Dockerfile
Normal file
|
|
@ -0,0 +1,23 @@
|
||||||
|
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"]
|
||||||
78
src/avatar_cache.rs
Normal file
78
src/avatar_cache.rs
Normal file
|
|
@ -0,0 +1,78 @@
|
||||||
|
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(())
|
||||||
|
}
|
||||||
|
}
|
||||||
62
src/avatar_fetch.rs
Normal file
62
src/avatar_fetch.rs
Normal file
|
|
@ -0,0 +1,62 @@
|
||||||
|
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))
|
||||||
|
}
|
||||||
|
}
|
||||||
96
src/config.rs
Normal file
96
src/config.rs
Normal file
|
|
@ -0,0 +1,96 @@
|
||||||
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
151
src/main.rs
151
src/main.rs
|
|
@ -1,3 +1,150 @@
|
||||||
fn main() {
|
use axum::extract::{Query, State};
|
||||||
println!("Hello, world!");
|
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>,
|
||||||
}
|
}
|
||||||
|
|
|
||||||
55
src/user_data.rs
Normal file
55
src/user_data.rs
Normal file
|
|
@ -0,0 +1,55 @@
|
||||||
|
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()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Add table
Reference in a new issue