diff --git a/Cargo.lock b/Cargo.lock index c39c719..a7af335 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -44,6 +44,16 @@ version = "1.0.102" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" +[[package]] +name = "assert-json-diff" +version = "2.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47e4f2b81832e72834d7518d8487a0396a28cc408186a2e8854c0f98011faf12" +dependencies = [ + "serde", + "serde_json", +] + [[package]] name = "async-compression" version = "0.4.41" @@ -290,6 +300,24 @@ dependencies = [ "parking_lot_core", ] +[[package]] +name = "deadpool" +version = "0.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0be2b1d1d6ec8d846f05e137292d0b89133caf95ef33695424c09568bdd39b1b" +dependencies = [ + "deadpool-runtime", + "lazy_static", + "num_cpus", + "tokio", +] + +[[package]] +name = "deadpool-runtime" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "092966b41edc516079bdf31ec78a2e0588d1d0c08f78b91d8307215928642b2b" + [[package]] name = "displaydoc" version = "0.2.5" @@ -370,6 +398,21 @@ dependencies = [ "thiserror 1.0.69", ] +[[package]] +name = "futures" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b147ee9d1f6d097cef9ce628cd2ee62288d963e16fb287bd9286455b241382d" +dependencies = [ + "futures-channel", + "futures-core", + "futures-executor", + "futures-io", + "futures-sink", + "futures-task", + "futures-util", +] + [[package]] name = "futures-channel" version = "0.3.32" @@ -377,6 +420,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "07bbe89c50d7a535e539b8c17bc0b49bdb77747034daa8087407d655f3f7cc1d" dependencies = [ "futures-core", + "futures-sink", ] [[package]] @@ -385,6 +429,34 @@ version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d" +[[package]] +name = "futures-executor" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "baf29c38818342a3b26b5b923639e7b1f4a61fc5e76102d4b1981c6dc7a7579d" +dependencies = [ + "futures-core", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-io" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cecba35d7ad927e23624b22ad55235f2239cfa44fd10428eecbeba6d6a717718" + +[[package]] +name = "futures-macro" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e835b70203e41293343137df5c0664546da5745f82ec9b84d40be8336958447b" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "futures-sink" version = "0.3.32" @@ -409,9 +481,13 @@ version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6" dependencies = [ + "futures-channel", "futures-core", + "futures-io", + "futures-macro", "futures-sink", "futures-task", + "memchr", "pin-project-lite", "slab", ] @@ -508,6 +584,12 @@ version = "0.17.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4f467dd6dccf739c208452f8014c75c18bb8301b050ad1cfb27153803edb0f51" +[[package]] +name = "hermit-abi" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc0fef456e4baa96da950455cd02c081ca953b141298e41db3fc7e36b1da849c" + [[package]] name = "http" version = "1.4.0" @@ -898,7 +980,6 @@ name = "nadzu" version = "0.3.1" dependencies = [ "anyhow", - "async-trait", "axum", "dashmap", "dotenvy", @@ -917,6 +998,7 @@ dependencies = [ "tracing-subscriber", "url", "validator", + "wiremock", ] [[package]] @@ -940,6 +1022,16 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "num_cpus" +version = "1.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91df4bbde75afed763b708b7eee1e8e7651e02d97f6d5dd763e89367e957b23b" +dependencies = [ + "hermit-abi", + "libc", +] + [[package]] name = "once_cell" version = "1.21.4" @@ -2185,6 +2277,29 @@ version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650" +[[package]] +name = "wiremock" +version = "0.6.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08db1edfb05d9b3c1542e521aea074442088292f00b5f28e435c714a98f85031" +dependencies = [ + "assert-json-diff", + "base64", + "deadpool", + "futures", + "http", + "http-body-util", + "hyper", + "hyper-util", + "log", + "once_cell", + "regex", + "serde", + "serde_json", + "tokio", + "url", +] + [[package]] name = "wit-bindgen" version = "0.51.0" diff --git a/Cargo.toml b/Cargo.toml index f8e196f..a49b32e 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -11,14 +11,14 @@ categories = ["web-programming::http-server", "multimedia::video"] [dependencies] anyhow = "1.0" -async-trait = "0.1" axum = { version = "0.8", features = ["macros"] } dashmap = "6.1.0" dotenvy = "0.15" governor = "0.10" +reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls", "socks"] } serde = { version = "1.0", features = ["derive"] } serde_json = "1.0" -reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls", "socks"] } +thiserror = "2.0" tokio = { version = "1", features = ["full"] } tokio-stream = "0.1" tower = "0.5" @@ -26,12 +26,12 @@ tower-http = { version = "0.6", features = ["cors", "trace", "compression-full", tower_governor = "0.8" tracing = "0.1" tracing-subscriber = { version = "0.3", features = ["env-filter", "fmt"] } -validator = { version = "0.20", features = ["derive"] } url = "2.5.8" -thiserror = "2.0" +validator = { version = "0.20", features = ["derive"] } [dev-dependencies] http-body-util = "0.1" +wiremock = "0.6.5" [lints.rust] unsafe_code = "deny" @@ -45,12 +45,10 @@ all = { level = "warn", priority = -1 } pedantic = { level = "warn", priority = -1 } nursery = { level = "warn", priority = -1 } cargo = { level = "warn", priority = -1 } -# Allow these to reduce noise for a prototype multiple_crate_versions = "allow" missing_errors_doc = "allow" missing_panics_doc = "allow" must_use_candidate = "allow" -# Critical ones to keep unwrap_used = "warn" expect_used = "warn" todo = "warn" diff --git a/infra/common/cloud-init.template b/infra/common/cloud-init.template index f14980e..788153a 100644 --- a/infra/common/cloud-init.template +++ b/infra/common/cloud-init.template @@ -134,6 +134,7 @@ write_files: ALLOWED_ORIGINS=${ALLOWED_ORIGINS} WARP_LICENSE_KEY=${WARP_LICENSE_KEY} MASTER_API_KEY=${MASTER_API_KEY} + GITHUB_PAT=${GHCR_PAT} - path: /root/.ghcr_token permissions: '0600' diff --git a/infra/digitalocean/accounts/naduns-team/variables.tf b/infra/digitalocean/accounts/naduns-team/variables.tf index 2a7e224..f43eb54 100644 --- a/infra/digitalocean/accounts/naduns-team/variables.tf +++ b/infra/digitalocean/accounts/naduns-team/variables.tf @@ -73,6 +73,7 @@ variable "HOST_PORT" { // - - - - - - - - - - - - - - variable "GHCR_PAT" { //SECRET: Expected to be set via root TF_VAR_GHCR_PAT. never Declare + //update: I use this to process contribtion grapth in backend server. to avoid confusion it passed to backend as GITHUB_PAT. im using the same description = "GitHub Personal Access Token for GHCR login" type = string sensitive = true diff --git a/postman/collections/Nadzu API/Contributions (Default User).request.yaml b/postman/collections/Nadzu API/Contributions (Default User).request.yaml new file mode 100644 index 0000000..09df63c --- /dev/null +++ b/postman/collections/Nadzu API/Contributions (Default User).request.yaml @@ -0,0 +1,22 @@ +$kind: http-request +name: Contributions (Default User) +url: "{{base_url}}/api/v1/contributions" +method: GET +scripts: + - type: afterResponse + code: >- + pm.test("Status is 200", function () { + pm.response.to.have.status(200); + }); + + pm.test("Response matches expected schema", function () { + var jsonData = pm.response.json(); + pm.expect(jsonData.username).to.be.a('string'); + pm.expect(jsonData.meta.provider).to.eql('github'); + pm.expect(jsonData.meta.schemaVersion).to.eql(1); + pm.expect(jsonData.cells).to.be.an('array'); + pm.expect(jsonData.months).to.be.an('array'); + pm.expect(jsonData.summary).to.have.property('totalWeeks'); + }); + language: text/javascript +order: 3000 diff --git a/postman/collections/Nadzu API/Contributions (Specific User).request.yaml b/postman/collections/Nadzu API/Contributions (Specific User).request.yaml new file mode 100644 index 0000000..7a3cd2d --- /dev/null +++ b/postman/collections/Nadzu API/Contributions (Specific User).request.yaml @@ -0,0 +1,22 @@ +$kind: http-request +name: Contributions (Specific User) +url: "{{base_url}}/api/v1/contributions?username=nxdun" +method: GET +scripts: + - type: afterResponse + code: >- + pm.test("Status is 200", function () { + pm.response.to.have.status(200); + }); + + pm.test("Response matches expected schema and specific user", function () { + var jsonData = pm.response.json(); + pm.expect(jsonData.username).to.eql('nxdun'); + pm.expect(jsonData.meta.provider).to.eql('github'); + pm.expect(jsonData.meta.schemaVersion).to.eql(1); + pm.expect(jsonData.cells).to.be.an('array'); + pm.expect(jsonData.months).to.be.an('array'); + pm.expect(jsonData.summary).to.have.property('totalWeeks'); + }); + language: text/javascript +order: 3001 diff --git a/src/app.rs b/src/app.rs index 9b507d5..72ee8b5 100644 --- a/src/app.rs +++ b/src/app.rs @@ -52,11 +52,24 @@ pub async fn run() { let rate_limiters = Arc::new(RateLimiters::new()); log_rate_limit_mode(&config); let http_client = reqwest::Client::new(); + + let contributions_service = + Arc::new(crate::services::contributions::ContributionsService::new( + http_client.clone(), + config.github_pat.clone().unwrap_or_default(), + config + .github_username + .clone() + .unwrap_or_else(|| "nxdun".to_string()), + config.github_graphql_url.clone(), + )); + let state = AppState { config: config.clone(), ytdlp_manager, rate_limiters, http_client, + contributions_service, }; // 5. Build middleware layers (compression + CORS) diff --git a/src/config.rs b/src/config.rs index 4ca375f..727f861 100644 --- a/src/config.rs +++ b/src/config.rs @@ -38,6 +38,9 @@ pub struct AppConfig { pub max_concurrent_downloads: usize, pub captcha_secret_key: Option, pub master_api_key: String, + pub github_pat: Option, + pub github_username: Option, + pub github_graphql_url: String, } impl AppConfig { @@ -59,6 +62,9 @@ impl AppConfig { tracing::error!("MASTER_API_KEY must be set to a non-empty value"); std::process::exit(1) }), + github_pat: env_opt("GITHUB_PAT"), + github_username: env_opt("GITHUB_USERNAME"), + github_graphql_url: env_or("GITHUB_GRAPHQL_URL", "https://api.github.com/graphql"), } } diff --git a/src/controllers/api/v1/contributions_controller.rs b/src/controllers/api/v1/contributions_controller.rs new file mode 100644 index 0000000..22d80dd --- /dev/null +++ b/src/controllers/api/v1/contributions_controller.rs @@ -0,0 +1,39 @@ +use axum::{ + Json, + extract::{Query, State}, +}; +use serde::Deserialize; + +use crate::{error::AppError, models::contributions_model::ContributionsResponse, state::AppState}; + +#[derive(Debug, Deserialize)] +pub struct ContributionsQuery { + pub username: Option, +} + +pub async fn get_contributions( + State(state): State, + Query(query): Query, +) -> Result, AppError> { + let username = query + .username + .as_deref() + .map(str::trim) + .filter(|u| !u.is_empty()) + .map_or_else( + || { + state + .contributions_service + .get_default_username() + .to_string() + }, + ToOwned::to_owned, + ); + + let resp = state + .contributions_service + .get_contributions(&username) + .await?; + + Ok(Json(resp)) +} diff --git a/src/controllers/api/v1/mod.rs b/src/controllers/api/v1/mod.rs index 4495a4a..02967dd 100644 --- a/src/controllers/api/v1/mod.rs +++ b/src/controllers/api/v1/mod.rs @@ -1 +1,2 @@ +pub mod contributions_controller; pub mod ytdlp_controller; diff --git a/src/models/contributions_model.rs b/src/models/contributions_model.rs new file mode 100644 index 0000000..84b3923 --- /dev/null +++ b/src/models/contributions_model.rs @@ -0,0 +1,74 @@ +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ContributionsResponse { + pub username: String, + pub range: ContributionRange, + pub summary: ContributionSummary, + pub legend: Vec, + pub months: Vec, + pub cells: Vec, + pub meta: ContributionMeta, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ContributionRange { + pub from: String, + pub to: String, + pub timezone: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ContributionSummary { + #[serde(rename = "totalContributions")] + pub total_contributions: u32, + #[serde(rename = "totalWeeks")] + pub total_weeks: u32, + #[serde(rename = "maxDailyCount")] + pub max_daily_count: u32, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ContributionLegend { + pub level: u32, + pub label: String, + pub min: u32, + pub max: u32, + pub color: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ContributionMonth { + pub label: String, + #[serde(rename = "weekIndex")] + pub week_index: usize, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ContributionCell { + pub date: String, + #[serde(rename = "weekIndex")] + pub week_index: usize, + pub weekday: u8, + #[serde(rename = "weekdayLabel")] + pub weekday_label: String, + pub count: u32, + pub level: u32, + pub color: String, + #[serde(rename = "isFuture")] + pub is_future: bool, + #[serde(rename = "isInCurrentMonth")] + pub is_in_current_month: bool, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ContributionMeta { + pub provider: String, + pub cached: bool, + #[serde(rename = "cacheTtlSeconds")] + pub cache_ttl_seconds: u32, + #[serde(rename = "fetchedAt")] + pub fetched_at: String, + #[serde(rename = "schemaVersion")] + pub schema_version: u32, +} diff --git a/src/models/mod.rs b/src/models/mod.rs index c452081..6fdbd45 100644 --- a/src/models/mod.rs +++ b/src/models/mod.rs @@ -1,3 +1,4 @@ +pub mod contributions_model; pub mod health_model; pub mod validation_model; pub mod ytdlp_model; diff --git a/src/routes/api/v1/contributions_routes.rs b/src/routes/api/v1/contributions_routes.rs new file mode 100644 index 0000000..cd85e17 --- /dev/null +++ b/src/routes/api/v1/contributions_routes.rs @@ -0,0 +1,7 @@ +use axum::{Router, routing::get}; + +use crate::{controllers::api::v1::contributions_controller::get_contributions, state::AppState}; + +pub fn create_contributions_router(_state: AppState) -> Router { + Router::new().route("/", get(get_contributions)) +} diff --git a/src/routes/api/v1/mod.rs b/src/routes/api/v1/mod.rs index 9cb19b4..ea66758 100644 --- a/src/routes/api/v1/mod.rs +++ b/src/routes/api/v1/mod.rs @@ -2,9 +2,15 @@ use axum::Router; use crate::state::AppState; +mod contributions_routes; mod ytdlp_routes; #[allow(unreachable_pub)] pub fn router(state: AppState) -> Router { - Router::new().merge(ytdlp_routes::router(state)) + Router::new() + .merge(ytdlp_routes::router(state.clone())) + .nest( + "/api/v1/contributions", + contributions_routes::create_contributions_router(state), + ) } diff --git a/src/services/contributions.rs b/src/services/contributions.rs new file mode 100644 index 0000000..bbc386c --- /dev/null +++ b/src/services/contributions.rs @@ -0,0 +1,491 @@ +use dashmap::DashMap; +use reqwest::Client; +use serde::Deserialize; +use serde_json::json; +use std::sync::Arc; +use std::time::{Duration, SystemTime, UNIX_EPOCH}; +use tracing::info; + +use crate::{ + error::AppError, + models::contributions_model::{ + ContributionCell, ContributionLegend, ContributionMeta, ContributionMonth, + ContributionRange, ContributionSummary, ContributionsResponse, + }, +}; + +const CACHE_TTL_SECONDS: u32 = 86400; +const CACHE_MAX_CAPACITY: u32 = 1000; +const SCHEMA_VERSION: u32 = 1; +const PROVIDER_GITHUB: &str = "github"; +const USER_AGENT: &str = "nadzu-backend"; + +const WEEKDAY_LABELS: [&str; 7] = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"]; +const MONTH_LABELS: [&str; 12] = [ + "Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec", +]; + +const CONTRIBUTION_COLORS: [&str; 5] = [ + "#2b2c3494", // Level 0 + "#9be9a8", // Level 1 + "#40c463", // Level 2 + "#30a14e", // Level 3 + "#216e39", // Level 4 +]; + +#[derive(Debug, Deserialize)] +struct GithubGqlResponse { + data: Option, + errors: Option>, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +struct GithubGraphQLUser { + user: Option, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +struct GithubUserNode { + contributions_collection: GithubContributionsCollection, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +struct GithubContributionsCollection { + contribution_calendar: GithubContributionCalendar, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +struct GithubContributionCalendar { + total_contributions: u32, + weeks: Vec, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +struct GithubWeek { + contribution_days: Vec, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +struct GithubContributionDay { + date: String, + weekday: u8, // 0 = Sunday + contribution_count: u32, + contribution_level: String, +} + +pub struct ContributionsService { + http_client: Client, + pat: String, + default_username: String, + graphql_url: String, + cache: Arc>, +} + +impl std::fmt::Debug for ContributionsService { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("ContributionsService") + .field("default_username", &self.default_username) + .field("cache_len", &self.cache.len()) + .field("graphql_url", &self.graphql_url) + .finish_non_exhaustive() + } +} + +impl ContributionsService { + pub fn new( + http_client: Client, + pat: String, + default_username: String, + graphql_url: String, + ) -> Self { + let cache = Arc::new(DashMap::new()); + let cache_weak = Arc::downgrade(&cache); + + tokio::spawn(async move { + let mut interval = tokio::time::interval(Duration::from_mins(10)); // Clean every 10 mins + loop { + interval.tick().await; + if let Some(cache) = cache_weak.upgrade() { + let now = now_unix(); + cache.retain(|_, (_, expires_at)| *expires_at > now); + } else { + info!("ContributionsService dropped, stopping cleanup task"); + break; + } + } + }); + + Self { + http_client, + pat, + default_username, + graphql_url, + cache, + } + } + + pub fn seed_cache(&self, username: &str, response: ContributionsResponse, ttl_secs: u64) { + let expires_at = now_unix() + ttl_secs; + self.cache + .insert(username.to_string(), (response, expires_at)); + } + + pub fn get_default_username(&self) -> &str { + &self.default_username + } + + pub async fn get_contributions( + &self, + username: &str, + ) -> Result { + let now = now_unix(); + let cache_key = username.to_string(); + + if username.trim().is_empty() { + return Err(AppError::Validation("Username cannot be empty".into())); + } + + //not allow any other username other than default for now. + if username != self.default_username { + return Err(AppError::Validation( + "Only the default username is allowed".into(), + )); + } + + if let Some(entry) = self.cache.get(&cache_key) { + let (cached_resp, expires_at) = entry.value(); + if *expires_at > now { + let mut resp = cached_resp.clone(); + resp.meta.cached = true; + return Ok(resp); + } + } + + let resp_result = self.fetch_and_process(username, SystemTime::now()).await; + + match resp_result { + Ok(new_resp) => { + let expires_at = now + u64::from(CACHE_TTL_SECONDS); + // Bounded: only insert if under capacity to prevent memory leaks + if self.cache.len() < CACHE_MAX_CAPACITY as usize { + self.cache.insert(cache_key, (new_resp.clone(), expires_at)); + } + Ok(new_resp) + } + Err(e) => { + // Stale-cache fallback + if let Some(entry) = self.cache.get(&cache_key) { + let mut resp = entry.value().0.clone(); + resp.meta.cached = true; + return Ok(resp); + } + Err(e) + } + } + } + + async fn fetch_and_process( + &self, + username: &str, + fetched_at: SystemTime, + ) -> Result { + let query = r" + query($username: String!) { + user(login: $username) { + contributionsCollection { + contributionCalendar { + totalContributions + weeks { + contributionDays { + date + weekday + contributionCount + contributionLevel + } + } + } + } + } + } + "; + + let payload = json!({ + "query": query, + "variables": { "username": username } + }); + + let resp = self + .http_client + .post(&self.graphql_url) + .bearer_auth(&self.pat) + .header("User-Agent", USER_AGENT) + .timeout(Duration::from_secs(30)) + .json(&payload) + .send() + .await + .map_err(|e| AppError::Internal(anyhow::anyhow!("Network or timeout error: {e}")))?; + + if !resp.status().is_success() { + return Err(AppError::Internal(anyhow::anyhow!( + "Upstream API returned status: {}", + resp.status() + ))); + } + + let gh_resp: GithubGqlResponse = resp + .json() + .await + .map_err(|e| AppError::Internal(anyhow::anyhow!("JSON parsing error: {e}")))?; + + if let Some(errs) = gh_resp.errors + && !errs.is_empty() + { + return Err(AppError::Internal(anyhow::anyhow!( + "GitHub GraphQL returned errors" + ))); + } + + let calendar = gh_resp + .data + .and_then(|d| d.user) + .map(|u| u.contributions_collection.contribution_calendar) + .ok_or_else(|| { + AppError::Internal(anyhow::anyhow!("User not found or missing fields")) + })?; + + Ok(Self::transform_calendar(username, &calendar, fetched_at)) + } + + #[allow(clippy::too_many_lines)] + fn transform_calendar( + username: &str, + calendar: &GithubContributionCalendar, + fetched_at: SystemTime, + ) -> ContributionsResponse { + let mut cells = Vec::new(); + let mut months = Vec::new(); + let mut max_daily_count = 0; + + let mut last_month: Option = None; + + let (current_year, current_month_num, current_day) = get_utc_date(SystemTime::now()); + let current_date_str = format!("{current_year:04}-{current_month_num:02}-{current_day:02}"); + + let fetched_at_str = format_iso_time(fetched_at); + + for (week_idx, week) in calendar.weeks.iter().enumerate() { + let mut month_added_this_week = false; + + for day in &week.contribution_days { + if day.contribution_count > max_daily_count { + max_daily_count = day.contribution_count; + } + + // Parse YYYY-MM-DD + let (y_day, m_day, _) = parse_ymd(&day.date).unwrap_or((1970, 1, 1)); + + if let Some(lm) = last_month { + if lm != m_day && !month_added_this_week { + months.push(ContributionMonth { + label: get_month_label(m_day), + week_index: week_idx, + }); + month_added_this_week = true; + } + } else if !month_added_this_week { + months.push(ContributionMonth { + label: get_month_label(m_day), + week_index: week_idx, + }); + month_added_this_week = true; + } + last_month = Some(m_day); + + let is_future = day.date > current_date_str; + let is_in_current_month = m_day == current_month_num && y_day == current_year; + + let level = match day.contribution_level.as_str() { + "FIRST_QUARTILE" => 1, + "SECOND_QUARTILE" => 2, + "THIRD_QUARTILE" => 3, + "FOURTH_QUARTILE" => 4, + _ => 0, + }; + + let weekday_label = WEEKDAY_LABELS + .get(day.weekday as usize) + .unwrap_or(&"") + .to_string(); + + cells.push(ContributionCell { + date: day.date.clone(), + week_index: week_idx, + weekday: day.weekday, + weekday_label, + count: day.contribution_count, + level, + color: CONTRIBUTION_COLORS[level as usize].to_string(), + is_future, + is_in_current_month, + }); + } + } + + let total_weeks = u32::try_from(calendar.weeks.len()).unwrap_or_default(); + + let mut level_mins = [u32::MAX; 5]; + let mut level_maxs = [0u32; 5]; + level_mins[0] = 0; + level_maxs[0] = 0; + + for cell in &cells { + let l = cell.level as usize; + if l < 5 && l > 0 { + if cell.count < level_mins[l] { + level_mins[l] = cell.count; + } + if cell.count > level_maxs[l] { + level_maxs[l] = cell.count; + } + } + } + + for min_val in level_mins.iter_mut().skip(1) { + if *min_val == u32::MAX { + *min_val = 0; + } + } + + let legend = vec![ + ContributionLegend { + level: 0, + label: "No contributions".into(), + min: level_mins[0], + max: level_maxs[0], + color: CONTRIBUTION_COLORS[0].to_string(), + }, + ContributionLegend { + level: 1, + label: "Low".into(), + min: level_mins[1], + max: level_maxs[1], + color: CONTRIBUTION_COLORS[1].to_string(), + }, + ContributionLegend { + level: 2, + label: "Medium".into(), + min: level_mins[2], + max: level_maxs[2], + color: CONTRIBUTION_COLORS[2].to_string(), + }, + ContributionLegend { + level: 3, + label: "High".into(), + min: level_mins[3], + max: level_maxs[3], + color: CONTRIBUTION_COLORS[3].to_string(), + }, + ContributionLegend { + level: 4, + label: "Very high".into(), + min: level_mins[4], + max: level_maxs[4], + color: CONTRIBUTION_COLORS[4].to_string(), + }, + ]; + + let from_date = cells.first().map(|c| c.date.clone()).unwrap_or_default(); + let to_date = cells.last().map(|c| c.date.clone()).unwrap_or_default(); + + ContributionsResponse { + username: username.to_string(), + range: ContributionRange { + from: from_date, + to: to_date, + timezone: "UTC".into(), + }, + summary: ContributionSummary { + total_contributions: calendar.total_contributions, + total_weeks, + max_daily_count, + }, + legend, + months, + cells, + meta: ContributionMeta { + provider: PROVIDER_GITHUB.into(), + cached: false, + cache_ttl_seconds: CACHE_TTL_SECONDS, + fetched_at: fetched_at_str, + schema_version: SCHEMA_VERSION, + }, + } + } +} + +fn parse_ymd(date: &str) -> Option<(u32, u32, u32)> { + if date.len() < 10 { + return None; + } + let y = date.get(0..4)?.parse().ok()?; + let m = date.get(5..7)?.parse().ok()?; + let d = date.get(8..10)?.parse().ok()?; + Some((y, m, d)) +} + +fn get_month_label(month: u32) -> String { + if (1..=12).contains(&month) { + MONTH_LABELS[(month - 1) as usize].to_string() + } else { + String::new() + } +} + +#[allow( + clippy::cast_possible_truncation, + clippy::cast_lossless, + clippy::bool_to_int_with_if, + clippy::unnecessary_cast +)] +fn get_utc_date(sys_time: SystemTime) -> (u32, u32, u32) { + let secs = sys_time + .duration_since(UNIX_EPOCH) + .unwrap_or_default() + .as_secs(); + let z = secs / 86400 + 719_468; + let era = z / 146_097; + let doe = (z - era * 146_097) as u32; + let yoe = (doe - doe / 1460 + doe / 36524 - doe / 146_096) / 365; + let y = (yoe as u64) + (era * 400); + let doy = doe - (365 * yoe + yoe / 4 - yoe / 100); + let mp = (5 * doy + 2) / 153; + let d = doy - (153 * mp + 2) / 5 + 1; + let m = if mp < 10 { mp + 3 } else { mp - 9 }; + let year = y + (if m <= 2 { 1 } else { 0 }); + (year as u32, m as u32, d as u32) +} + +fn format_iso_time(sys_time: SystemTime) -> String { + let secs = sys_time + .duration_since(UNIX_EPOCH) + .unwrap_or_default() + .as_secs(); + let (y, m, d) = get_utc_date(sys_time); + let rem = secs % 86400; + let hh = rem / 3600; + let mm = (rem % 3600) / 60; + let ss = rem % 60; + format!("{y:04}-{m:02}-{d:02}T{hh:02}:{mm:02}:{ss:02}Z") +} + +fn now_unix() -> u64 { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .map_or(0, |d| d.as_secs()) +} diff --git a/src/services/mod.rs b/src/services/mod.rs index 75425ca..09ecda0 100644 --- a/src/services/mod.rs +++ b/src/services/mod.rs @@ -1 +1,2 @@ +pub mod contributions; pub mod ytdlp; diff --git a/src/state.rs b/src/state.rs index e54e8d6..b2cb710 100644 --- a/src/state.rs +++ b/src/state.rs @@ -1,5 +1,6 @@ use crate::config::AppConfig; use crate::middleware::rate_limit::RateLimiters; +use crate::services::contributions::ContributionsService; use crate::services::ytdlp::YtdlpManager; use std::sync::Arc; @@ -9,4 +10,5 @@ pub struct AppState { pub ytdlp_manager: Arc, pub rate_limiters: Arc, pub http_client: reqwest::Client, + pub contributions_service: Arc, } diff --git a/tests/api/common.rs b/tests/api/common.rs index 05ca39f..404dd16 100644 --- a/tests/api/common.rs +++ b/tests/api/common.rs @@ -13,7 +13,7 @@ use nadzu::{ }, models::ytdlp_model::YtdlpDownloadRequest, routes::create_router, - services::ytdlp::YtdlpManager, + services::{contributions::ContributionsService, ytdlp::YtdlpManager}, state::AppState, }; use serde_json::{Value, json}; @@ -51,17 +51,27 @@ pub fn create_test_state_with_options( max_concurrent_downloads: 3, captcha_secret_key: secret_key.map(str::to_string), master_api_key: TEST_MASTER_API_KEY.into(), + github_pat: None, + github_username: None, + github_graphql_url: "https://api.github.com/graphql".into(), }); let ytdlp_manager = Arc::new(YtdlpManager::new(config.clone())); let rate_limiters = Arc::new(RateLimiters::new()); let http_client = reqwest::Client::new(); + let contributions_service = Arc::new(ContributionsService::new( + http_client.clone(), + "fake_pat".into(), + "nxdun".into(), + config.github_graphql_url.clone(), + )); AppState { config, ytdlp_manager, rate_limiters, http_client, + contributions_service, } } diff --git a/tests/api/contributions_tests.rs b/tests/api/contributions_tests.rs new file mode 100644 index 0000000..9e9ff50 --- /dev/null +++ b/tests/api/contributions_tests.rs @@ -0,0 +1,139 @@ +use crate::common::{TEST_MASTER_API_KEY, create_test_state_with_options, get, send_json}; +use axum::http::StatusCode; +use nadzu::models::contributions_model::{ + ContributionMeta, ContributionRange, ContributionSummary, ContributionsResponse, +}; +use nadzu::routes::create_router; +use nadzu::services::contributions::ContributionsService; +use std::sync::Arc; +use wiremock::{Mock, MockServer, ResponseTemplate, matchers::method}; + +fn mock_contributions_response(username: &str) -> ContributionsResponse { + ContributionsResponse { + username: username.to_string(), + range: ContributionRange { + from: "2023-01-01".into(), + to: "2023-01-02".into(), + timezone: "UTC".into(), + }, + summary: ContributionSummary { + total_contributions: 10, + total_weeks: 1, + max_daily_count: 5, + }, + legend: vec![], + months: vec![], + cells: vec![], + meta: ContributionMeta { + provider: "github".into(), + cached: false, + cache_ttl_seconds: 86400, + fetched_at: "2023-01-01T00:00:00Z".into(), + schema_version: 1, + }, + } +} + +#[tokio::test] +async fn get_contributions_returns_seeded_cache() { + let state = create_test_state_with_options(None, None); + let mock_resp = mock_contributions_response("nxdun"); + + // Seed the cache so it doesn't hit the network + state + .contributions_service + .seed_cache("nxdun", mock_resp, 3600); + + let app = create_router(state.clone()).with_state(state); + + let (status, body) = send_json(&app, get("/api/v1/contributions")).await; + + assert_eq!(status, StatusCode::OK); + assert_eq!(body["username"], "nxdun"); + assert_eq!(body["meta"]["cached"], true); +} + +#[tokio::test] +async fn get_contributions_hits_mock_server_when_cache_empty() { + let mock_server = MockServer::start().await; + + // Create state pointing to mock server + let config = nadzu::config::AppConfig { + name: "test".into(), + env: "test".into(), + host: "127.0.0.1".into(), + port: 8080, + allowed_origins: None, + download_dir: "downloads".into(), + ytdlp_path: "yt-dlp".into(), + ytdlp_external_downloader: None, + ytdlp_external_downloader_args: None, + max_concurrent_downloads: 3, + captcha_secret_key: None, + master_api_key: TEST_MASTER_API_KEY.into(), + github_pat: Some("fake_pat".into()), + github_username: Some("nxdun".into()), + github_graphql_url: mock_server.uri(), + }; + + let http_client = reqwest::Client::new(); + let contributions_service = Arc::new(ContributionsService::new( + http_client.clone(), + config.github_pat.clone().unwrap(), + config.github_username.clone().unwrap(), + config.github_graphql_url.clone(), + )); + + let state = nadzu::state::AppState { + config: Arc::new(config.clone()), + ytdlp_manager: Arc::new(nadzu::services::ytdlp::YtdlpManager::new(Arc::new(config))), + rate_limiters: Arc::new(nadzu::middleware::rate_limit::RateLimiters::new()), + http_client, + contributions_service, + }; + + // Mock GitHub GraphQL response + let github_response = serde_json::json!({ + "data": { + "user": { + "contributionsCollection": { + "contributionCalendar": { + "totalContributions": 100, + "weeks": [] + } + } + } + } + }); + + Mock::given(method("POST")) + .respond_with(ResponseTemplate::new(200).set_body_json(github_response)) + .expect(1) + .mount(&mock_server) + .await; + + let app = create_router(state.clone()).with_state(state); + let (status, body) = send_json(&app, get("/api/v1/contributions")).await; + + assert_eq!(status, StatusCode::OK); + assert_eq!(body["username"], "nxdun"); + assert_eq!(body["summary"]["totalContributions"], 100); + assert_eq!(body["meta"]["cached"], false); +} + +#[tokio::test] +async fn get_contributions_rejects_non_default_user() { + let state = create_test_state_with_options(None, None); + let app = create_router(state.clone()).with_state(state); + + let (status, body) = send_json(&app, get("/api/v1/contributions?username=someotheruser")).await; + + assert_eq!(status, StatusCode::UNPROCESSABLE_ENTITY); + assert_eq!(body["error_code"], "VALIDATION_ERROR"); + assert!( + body["message"] + .as_str() + .unwrap() + .contains("Only the default username is allowed") + ); +} diff --git a/tests/api_tests.rs b/tests/api_tests.rs index be2ccd7..05357d5 100644 --- a/tests/api_tests.rs +++ b/tests/api_tests.rs @@ -5,6 +5,8 @@ mod auth_tests; mod captcha_tests; #[path = "api/common.rs"] mod common; +#[path = "api/contributions_tests.rs"] +mod contributions_tests; #[path = "api/cors_tests.rs"] mod cors_tests; #[path = "api/health_tests.rs"] diff --git a/tests/layer_unit_tests.rs b/tests/layer_unit_tests.rs index 54dad77..5bd6476 100644 --- a/tests/layer_unit_tests.rs +++ b/tests/layer_unit_tests.rs @@ -23,6 +23,9 @@ fn test_config(env: &str) -> AppConfig { max_concurrent_downloads: 3, captcha_secret_key: None, master_api_key: "master_key".to_string(), + github_pat: None, + github_username: None, + github_graphql_url: "https://api.github.com/graphql".to_string(), } }