From c194daa42c50835f4943771b6de55bc9b2904bcc Mon Sep 17 00:00:00 2001 From: Tom Bloor Date: Sun, 21 Apr 2024 21:30:00 +0100 Subject: [PATCH] Set up web routes --- Cargo.lock | 84 +++++++++++++++++++++++++- Cargo.toml | 2 + src/avatar_cache.rs | 27 +++++---- src/avatar_fetch.rs | 27 ++++----- src/config.rs | 31 +++++++++- src/main.rs | 143 +++++++++++++++++++++++++++++++++++++++----- src/user_data.rs | 24 ++++++-- 7 files changed, 286 insertions(+), 52 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index e32a808..8b343b9 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -54,6 +54,61 @@ version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f1fdabc7756949593fe60f30ec81974b613357de856987752631dea1e3394c80" +[[package]] +name = "axum" +version = "0.7.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a6c9af12842a67734c9a2e355436e5d03b22383ed60cf13cd0c18fbfe3dcbcf" +dependencies = [ + "async-trait", + "axum-core", + "bytes", + "futures-util", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-util", + "itoa", + "matchit", + "memchr", + "mime", + "percent-encoding", + "pin-project-lite", + "rustversion", + "serde", + "serde_json", + "serde_path_to_error", + "serde_urlencoded", + "sync_wrapper 1.0.1", + "tokio", + "tower", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "axum-core" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a15c63fd72d41492dc4f497196f5da1fb04fb7529e631d73630d1b491e47a2e3" +dependencies = [ + "async-trait", + "bytes", + "futures-util", + "http", + "http-body", + "http-body-util", + "mime", + "pin-project-lite", + "rustversion", + "sync_wrapper 0.1.2", + "tower-layer", + "tower-service", + "tracing", +] + [[package]] name = "backtrace" version = "0.3.71" @@ -404,6 +459,12 @@ version = "1.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d897f394bad6a705d5f4104762e116a75639e470d80901eed05a860a95cb1904" +[[package]] +name = "httpdate" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" + [[package]] name = "hyper" version = "1.3.1" @@ -417,6 +478,7 @@ dependencies = [ "http", "http-body", "httparse", + "httpdate", "itoa", "pin-project-lite", "smallvec", @@ -525,6 +587,12 @@ version = "0.4.21" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "90ed8c1e510134f979dbc4f070f87d4313098b704861a105fe34231c70a3901c" +[[package]] +name = "matchit" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0e7465ac9959cc2b1404e8e2367b43684a6d13790fe23056cc8c6c5a6b7bcb94" + [[package]] name = "memchr" version = "2.7.2" @@ -814,7 +882,7 @@ dependencies = [ "serde", "serde_json", "serde_urlencoded", - "sync_wrapper", + "sync_wrapper 0.1.2", "system-configuration", "tokio", "tokio-native-tls", @@ -830,10 +898,12 @@ dependencies = [ name = "rust-twitch-avatar-cache" version = "0.1.0" dependencies = [ + "axum", "dotenvy", "redis 0.25.3", "redis-derive", "reqwest", + "serde", "tokio", "twitch_api", "twitch_oauth2", @@ -875,6 +945,12 @@ version = "1.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ecd36cc4259e3e4514335c4a138c6b43171a8d61d8f5c9348f9fc7529416f247" +[[package]] +name = "rustversion" +version = "1.0.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "80af6f9131f277a45a3fba6ce8e2258037bb0477a67e610d3c1fe046ab31de47" + [[package]] name = "ryu" version = "1.0.17" @@ -1034,6 +1110,12 @@ version = "0.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2047c6ded9c721764247e62cd3b03c09ffc529b2ba5b10ec482ae507a4a70160" +[[package]] +name = "sync_wrapper" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7065abeca94b6a8a577f9bd45aa0867a2238b74e8eb67cf10d492bc39351394" + [[package]] name = "system-configuration" version = "0.5.1" diff --git a/Cargo.toml b/Cargo.toml index 593834a..5f54301 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -6,6 +6,7 @@ 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" @@ -16,6 +17,7 @@ tokio = { version = "1.0.0", features = ["rt", "rt-multi-thread", "macros"] } 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"] } # workaround for https://github.com/twitch-rs/twitch_api/issues/256 [patch.crates-io.twitch_types] diff --git a/src/avatar_cache.rs b/src/avatar_cache.rs index 81f2454..311952c 100644 --- a/src/avatar_cache.rs +++ b/src/avatar_cache.rs @@ -10,12 +10,9 @@ pub struct AvatarCache { impl AvatarCache { pub fn new(redis: Client, timeout: i64) -> Self { - AvatarCache { - redis, - timeout, - } + AvatarCache { redis, timeout } } - + pub fn connect(config: &RedisConfig) -> RedisResult { let client = Client::open(config.url())?; Ok(Self::new(client, config.cache_time())) @@ -24,7 +21,7 @@ impl AvatarCache { /// Get user data from Cache pub fn get_cache_by_name(&self, name: &str) -> RedisResult> { let mut con = self.redis.get_connection()?; - + let id: String = match con.get(UserData::redis_name_from_str(name)) { Ok(v) => v, Err(e) => { @@ -34,10 +31,16 @@ impl AvatarCache { } else { // Something else went wrong Err(e) - } + }; } }; - + + self.get_cache_by_id(&id) + } + + pub fn get_cache_by_id(&self, id: &str) -> RedisResult> { + let mut con = self.redis.get_connection()?; + match con.hgetall(UserData::redis_id_from_str(&id)) { Ok(v) => Ok(Some(v)), Err(e) => { @@ -48,7 +51,7 @@ impl AvatarCache { // Something else went wrong Err(e) } - }, + } } } @@ -60,12 +63,12 @@ impl AvatarCache { .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)?; diff --git a/src/avatar_fetch.rs b/src/avatar_fetch.rs index d71a40c..90d5312 100644 --- a/src/avatar_fetch.rs +++ b/src/avatar_fetch.rs @@ -14,10 +14,7 @@ pub struct AvatarFetch { impl AvatarFetch { pub fn new(client: TwitchClient<'static, reqwest::Client>, token: AppAccessToken) -> Self { - AvatarFetch { - client, - token, - } + AvatarFetch { client, token } } pub async fn connect(config: &config::TwitchConfig) -> Result> { @@ -27,7 +24,8 @@ impl AvatarFetch { config.client_id().into(), config.client_secret().into(), vec![], - ).await?; + ) + .await?; Ok(Self::new(client, token)) } @@ -35,30 +33,29 @@ impl AvatarFetch { let mut request = users::GetUsersRequest::new(); let ids: &[&twitch_types::UserIdRef] = &[id.into()]; request.id = ids.into(); - let response: Vec = self.client.helix.req_get(request, &self.token).await?.data; + let response: Vec = + self.client.helix.req_get(request, &self.token).await?.data; Ok(self.user_from_response(response)) } - + pub fn user_from_response(&self, response: Vec) -> Option { - if let Some(user) = response.first() { - dbg!(user); - Some(UserData::new( + 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()), - )) - } else { - None - } + ) + }) } pub async fn get_user_by_name(&self, name: &str) -> Result, Box> { let mut request = users::GetUsersRequest::new(); let logins: &[&twitch_types::UserNameRef] = &[name.into()]; request.login = logins.into(); - let response: Vec = self.client.helix.req_get(request, &self.token).await?.data; + let response: Vec = + self.client.helix.req_get(request, &self.token).await?.data; Ok(self.user_from_response(response)) } diff --git a/src/config.rs b/src/config.rs index 36be74a..faa3c09 100644 --- a/src/config.rs +++ b/src/config.rs @@ -32,10 +32,23 @@ impl RedisConfig { } } +#[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 { @@ -43,9 +56,15 @@ impl Config { dotenvy::dotenv()?; 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 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 { @@ -56,6 +75,10 @@ impl Config { url: redis_url, cache_time: redis_cache_time, }, + app: AppConfig { + bind_port: app_bind_port, + bind_host: app_bind_host, + }, }) } @@ -66,4 +89,8 @@ impl Config { pub fn twitch(&self) -> &TwitchConfig { &self.twitch } -} \ No newline at end of file + + pub fn app(&self) -> &AppConfig { + &self.app + } +} diff --git a/src/main.rs b/src/main.rs index 56029fc..e8597e6 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,13 +1,19 @@ +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 crate::avatar_cache::AvatarCache; use crate::avatar_fetch::AvatarFetch; use crate::config::Config; -mod user_data; mod avatar_cache; -mod config; mod avatar_fetch; +mod config; +mod user_data; #[tokio::main] async fn main() -> Result<(), Box> { @@ -15,22 +21,127 @@ async fn main() -> Result<(), Box> { let cache = AvatarCache::connect(config.redis())?; let fetch = AvatarFetch::connect(config.twitch()).await?; - let target = "tbsliver"; + let state = Arc::new(AppState::new(cache, fetch)); - let data = cache.get_cache_by_name(target)?; + let router = Router::new() + .route("/", get(AppRoute::root)) + .with_state(state); - match data { - Some(d) => { - println!("Cache hit for {}", d.name()); - cache.update_cache_expiry(&d)?; - } - None => { - println!("Cache miss for {}", target); - if let Some(u) = fetch.get_user_by_name(target).await? { - cache.set_cache_data(&u)?; - } - } - } + 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>, + Query(params): Query, + ) -> Json { + 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, + name: Option, + display_name: Option, + profile_picture_url: Option, +} + +#[derive(Debug, Deserialize)] +struct AppQuery { + id: Option, + name: Option, +} diff --git a/src/user_data.rs b/src/user_data.rs index ca8411f..8373d96 100644 --- a/src/user_data.rs +++ b/src/user_data.rs @@ -1,3 +1,4 @@ +use crate::AppResponse; use redis_derive::{FromRedisValue, ToRedisArgs}; #[derive(Debug, ToRedisArgs, FromRedisValue)] @@ -9,12 +10,17 @@ pub struct UserData { } impl UserData { - pub fn new(name: String, display_name: String, id: String, profile_picture_url: String) -> Self { + pub fn new( + name: String, + display_name: String, + id: String, + profile_picture_url: String, + ) -> Self { UserData { name, display_name, id, - profile_picture_url + profile_picture_url, } } pub fn redis_id_from_str(name: &str) -> String { @@ -28,16 +34,22 @@ impl UserData { 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 name(&self) -> &str { - &self.name + 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()), + } } }