From 61a8997605a98b23a8982e68ca25b64229c96349 Mon Sep 17 00:00:00 2001 From: Nadu_Dev Date: Fri, 24 Apr 2026 11:48:39 +0530 Subject: [PATCH 01/12] fix: update GHCR_PAT variable description for clarity and add to cloud-init template --- infra/common/cloud-init.template | 1 + infra/digitalocean/accounts/naduns-team/variables.tf | 1 + 2 files changed, 2 insertions(+) diff --git a/infra/common/cloud-init.template b/infra/common/cloud-init.template index f14980e..4cd16b1 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 From 137c473830ee5491887970cc3df0192b023fb2f6 Mon Sep 17 00:00:00 2001 From: Nadu_Dev Date: Fri, 24 Apr 2026 11:49:05 +0530 Subject: [PATCH 02/12] fix: add github_pat and github_username fields to AppConfig in test configuration --- tests/layer_unit_tests.rs | 2 ++ 1 file changed, 2 insertions(+) diff --git a/tests/layer_unit_tests.rs b/tests/layer_unit_tests.rs index 54dad77..d50c31e 100644 --- a/tests/layer_unit_tests.rs +++ b/tests/layer_unit_tests.rs @@ -23,6 +23,8 @@ 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, } } From eb8758bd50d5c72fb232a8c0914af9ee84ae2b6c Mon Sep 17 00:00:00 2001 From: Nadu_Dev Date: Fri, 24 Apr 2026 11:49:18 +0530 Subject: [PATCH 03/12] feat: add contributions request templates for default and specific users --- .../Contributions (Default User).request.yaml | 22 +++++++++++++++++++ ...Contributions (Specific User).request.yaml | 22 +++++++++++++++++++ 2 files changed, 44 insertions(+) create mode 100644 postman/collections/Nadzu API/Contributions (Default User).request.yaml create mode 100644 postman/collections/Nadzu API/Contributions (Specific User).request.yaml 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..9589706 --- /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 \ No newline at end of file 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..d1bc7cf --- /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 \ No newline at end of file From 1a29691d487a2ca4429126c412b3caaa1989842a Mon Sep 17 00:00:00 2001 From: Nadu_Dev Date: Fri, 24 Apr 2026 11:50:22 +0530 Subject: [PATCH 04/12] feat: integrate contributions service and update configuration for GitHub credentials --- src/app.rs | 12 ++++++++++++ src/config.rs | 4 ++++ src/controllers/api/v1/mod.rs | 1 + src/models/mod.rs | 1 + src/routes/api/v1/mod.rs | 8 +++++++- src/services/mod.rs | 1 + src/state.rs | 2 ++ 7 files changed, 28 insertions(+), 1 deletion(-) diff --git a/src/app.rs b/src/app.rs index 9b507d5..042879a 100644 --- a/src/app.rs +++ b/src/app.rs @@ -52,11 +52,23 @@ 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()), + )); + 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..ee1583f 100644 --- a/src/config.rs +++ b/src/config.rs @@ -38,6 +38,8 @@ 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, } impl AppConfig { @@ -59,6 +61,8 @@ 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"), } } 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/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/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/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, } From 89ed6220a81538d2f16a5a59b03c358431d7fe4a Mon Sep 17 00:00:00 2001 From: Nadu_Dev Date: Fri, 24 Apr 2026 11:50:37 +0530 Subject: [PATCH 05/12] feat: implement contributions service and related models, routes, and controller --- .../api/v1/contributions_controller.rs | 31 ++ src/models/contributions_model.rs | 74 +++ src/routes/api/v1/contributions_routes.rs | 7 + src/services/contributions.rs | 456 ++++++++++++++++++ 4 files changed, 568 insertions(+) create mode 100644 src/controllers/api/v1/contributions_controller.rs create mode 100644 src/models/contributions_model.rs create mode 100644 src/routes/api/v1/contributions_routes.rs create mode 100644 src/services/contributions.rs diff --git a/src/controllers/api/v1/contributions_controller.rs b/src/controllers/api/v1/contributions_controller.rs new file mode 100644 index 0000000..70a4e54 --- /dev/null +++ b/src/controllers/api/v1/contributions_controller.rs @@ -0,0 +1,31 @@ +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.unwrap_or_else(|| { + state + .contributions_service + .get_default_username() + .to_string() + }); + + let resp = state + .contributions_service + .get_contributions(&username) + .await?; + + Ok(Json(resp)) +} 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/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/services/contributions.rs b/src/services/contributions.rs new file mode 100644 index 0000000..a1475a7 --- /dev/null +++ b/src/services/contributions.rs @@ -0,0 +1,456 @@ +use dashmap::DashMap; +use reqwest::Client; +use serde::Deserialize; +use serde_json::json; +use std::time::{Duration, SystemTime, UNIX_EPOCH}; + +use crate::{ + error::AppError, + models::contributions_model::{ + ContributionCell, ContributionLegend, ContributionMeta, ContributionMonth, + ContributionRange, ContributionSummary, ContributionsResponse, + }, +}; + +const GITHUB_GRAPHQL_URL: &str = "https://api.github.com/graphql"; +const CACHE_TTL_SECONDS: u32 = 86400; +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", +]; + +#[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, + colors: Vec, + 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, + color: String, +} + +pub struct ContributionsService { + http_client: Client, + pat: String, + default_username: String, + // In-memory cache mapping username -> (Response, expires_at in SystemTime) + cache: DashMap, +} + +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()) + .finish_non_exhaustive() + } +} + +impl ContributionsService { + pub fn new(http_client: Client, pat: String, default_username: String) -> Self { + Self { + http_client, + pat, + default_username, + cache: DashMap::new(), + } + } + + pub fn get_default_username(&self) -> &str { + &self.default_username + } + + pub async fn get_contributions( + &self, + username: &str, + ) -> Result { + let now = SystemTime::now(); + let cache_key = username.to_string(); + + 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, now).await; + + match resp_result { + Ok(new_resp) => { + let expires_at = now + Duration::from_secs(CACHE_TTL_SECONDS.into()); + 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 + colors + weeks { + contributionDays { + date + weekday + contributionCount + contributionLevel + color + } + } + } + } + } + } + "; + + let payload = json!({ + "query": query, + "variables": { "username": username } + }); + + let resp = self + .http_client + .post(GITHUB_GRAPHQL_URL) + .bearer_auth(&self.pat) + .header("User-Agent", USER_AGENT) + .json(&payload) + .send() + .await + .map_err(|e| AppError::Internal(anyhow::anyhow!("Network 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: day.color.clone(), + 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; + + let mut level_colors: [Option; 5] = Default::default(); + + for cell in &cells { + let l = cell.level as usize; + if l < 5 { + if level_colors[l].is_none() { + level_colors[l] = Some(cell.color.clone()); + } + if 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: level_colors[0] + .clone() + .or_else(|| calendar.colors.first().cloned()) + .unwrap_or_default(), + }, + ContributionLegend { + level: 1, + label: "Low".into(), + min: level_mins[1], + max: level_maxs[1], + color: level_colors[1] + .clone() + .or_else(|| calendar.colors.get(1).cloned()) + .unwrap_or_default(), + }, + ContributionLegend { + level: 2, + label: "Medium".into(), + min: level_mins[2], + max: level_maxs[2], + color: level_colors[2] + .clone() + .or_else(|| calendar.colors.get(2).cloned()) + .unwrap_or_default(), + }, + ContributionLegend { + level: 3, + label: "High".into(), + min: level_mins[3], + max: level_maxs[3], + color: level_colors[3] + .clone() + .or_else(|| calendar.colors.get(3).cloned()) + .unwrap_or_default(), + }, + ContributionLegend { + level: 4, + label: "Very high".into(), + min: level_mins[4], + max: level_maxs[4], + color: level_colors[4] + .clone() + .or_else(|| calendar.colors.get(4).cloned()) + .unwrap_or_default(), + }, + ]; + + 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") +} From 6254fd80782e613a2ec7b9f448a9538e61c37004 Mon Sep 17 00:00:00 2001 From: Nadu_Dev Date: Fri, 24 Apr 2026 11:52:09 +0530 Subject: [PATCH 06/12] feat: add contributions service initialization in test state with options --- tests/api/common.rs | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/tests/api/common.rs b/tests/api/common.rs index 05ca39f..699727d 100644 --- a/tests/api/common.rs +++ b/tests/api/common.rs @@ -51,17 +51,26 @@ 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, }); 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(nadzu::services::contributions::ContributionsService::new( + http_client.clone(), + "fake_pat".into(), + "nxdun".into(), + )); AppState { config, ytdlp_manager, rate_limiters, http_client, + contributions_service, } } From 555c58efa7f9e6de6343fe2662db4f82dafd3c88 Mon Sep 17 00:00:00 2001 From: Nadu_Dev Date: Sat, 25 Apr 2026 09:14:28 +0530 Subject: [PATCH 07/12] feat: enhance contributions service with color mapping and validation for username --- .../api/v1/contributions_controller.rs | 20 ++- src/services/contributions.rs | 128 +++++++++++++----- 2 files changed, 105 insertions(+), 43 deletions(-) diff --git a/src/controllers/api/v1/contributions_controller.rs b/src/controllers/api/v1/contributions_controller.rs index 70a4e54..22d80dd 100644 --- a/src/controllers/api/v1/contributions_controller.rs +++ b/src/controllers/api/v1/contributions_controller.rs @@ -15,12 +15,20 @@ pub async fn get_contributions( State(state): State, Query(query): Query, ) -> Result, AppError> { - let username = query.username.unwrap_or_else(|| { - state - .contributions_service - .get_default_username() - .to_string() - }); + 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 diff --git a/src/services/contributions.rs b/src/services/contributions.rs index a1475a7..e3ba95a 100644 --- a/src/services/contributions.rs +++ b/src/services/contributions.rs @@ -23,6 +23,14 @@ 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, @@ -51,7 +59,6 @@ struct GithubContributionsCollection { #[serde(rename_all = "camelCase")] struct GithubContributionCalendar { total_contributions: u32, - colors: Vec, weeks: Vec, } @@ -68,7 +75,6 @@ struct GithubContributionDay { weekday: u8, // 0 = Sunday contribution_count: u32, contribution_level: String, - color: String, } pub struct ContributionsService { @@ -109,6 +115,17 @@ impl ContributionsService { let now = SystemTime::now(); 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 { @@ -149,14 +166,12 @@ impl ContributionsService { contributionsCollection { contributionCalendar { totalContributions - colors weeks { contributionDays { date weekday contributionCount contributionLevel - color } } } @@ -279,7 +294,7 @@ impl ContributionsService { weekday_label, count: day.contribution_count, level, - color: day.color.clone(), + color: CONTRIBUTION_COLORS[level as usize].to_string(), is_future, is_in_current_month, }); @@ -293,21 +308,14 @@ impl ContributionsService { level_mins[0] = 0; level_maxs[0] = 0; - let mut level_colors: [Option; 5] = Default::default(); - for cell in &cells { let l = cell.level as usize; - if l < 5 { - if level_colors[l].is_none() { - level_colors[l] = Some(cell.color.clone()); + if l < 5 && l > 0 { + if cell.count < level_mins[l] { + level_mins[l] = cell.count; } - if l > 0 { - if cell.count < level_mins[l] { - level_mins[l] = cell.count; - } - if cell.count > level_maxs[l] { - level_maxs[l] = cell.count; - } + if cell.count > level_maxs[l] { + level_maxs[l] = cell.count; } } } @@ -324,50 +332,35 @@ impl ContributionsService { label: "No contributions".into(), min: level_mins[0], max: level_maxs[0], - color: level_colors[0] - .clone() - .or_else(|| calendar.colors.first().cloned()) - .unwrap_or_default(), + color: CONTRIBUTION_COLORS[0].to_string(), }, ContributionLegend { level: 1, label: "Low".into(), min: level_mins[1], max: level_maxs[1], - color: level_colors[1] - .clone() - .or_else(|| calendar.colors.get(1).cloned()) - .unwrap_or_default(), + color: CONTRIBUTION_COLORS[1].to_string(), }, ContributionLegend { level: 2, label: "Medium".into(), min: level_mins[2], max: level_maxs[2], - color: level_colors[2] - .clone() - .or_else(|| calendar.colors.get(2).cloned()) - .unwrap_or_default(), + color: CONTRIBUTION_COLORS[2].to_string(), }, ContributionLegend { level: 3, label: "High".into(), min: level_mins[3], max: level_maxs[3], - color: level_colors[3] - .clone() - .or_else(|| calendar.colors.get(3).cloned()) - .unwrap_or_default(), + color: CONTRIBUTION_COLORS[3].to_string(), }, ContributionLegend { level: 4, label: "Very high".into(), min: level_mins[4], max: level_maxs[4], - color: level_colors[4] - .clone() - .or_else(|| calendar.colors.get(4).cloned()) - .unwrap_or_default(), + color: CONTRIBUTION_COLORS[4].to_string(), }, ]; @@ -454,3 +447,64 @@ fn format_iso_time(sys_time: SystemTime) -> String { let ss = rem % 60; format!("{y:04}-{m:02}-{d:02}T{hh:02}:{mm:02}:{ss:02}Z") } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_transform_calendar_colors() { + let calendar = GithubContributionCalendar { + total_contributions: 10, + weeks: vec![GithubWeek { + contribution_days: vec![ + GithubContributionDay { + date: "2023-01-01".into(), + weekday: 0, + contribution_count: 0, + contribution_level: "NONE".into(), + }, + GithubContributionDay { + date: "2023-01-02".into(), + weekday: 1, + contribution_count: 1, + contribution_level: "FIRST_QUARTILE".into(), + }, + GithubContributionDay { + date: "2023-01-03".into(), + weekday: 2, + contribution_count: 5, + contribution_level: "SECOND_QUARTILE".into(), + }, + GithubContributionDay { + date: "2023-01-04".into(), + weekday: 3, + contribution_count: 10, + contribution_level: "THIRD_QUARTILE".into(), + }, + GithubContributionDay { + date: "2023-01-05".into(), + weekday: 4, + contribution_count: 20, + contribution_level: "FOURTH_QUARTILE".into(), + }, + ], + }], + }; + + let resp = + ContributionsService::transform_calendar("testuser", &calendar, SystemTime::now()); + + assert_eq!(resp.cells[0].color, CONTRIBUTION_COLORS[0]); + assert_eq!(resp.cells[1].color, CONTRIBUTION_COLORS[1]); + assert_eq!(resp.cells[2].color, CONTRIBUTION_COLORS[2]); + assert_eq!(resp.cells[3].color, CONTRIBUTION_COLORS[3]); + assert_eq!(resp.cells[4].color, CONTRIBUTION_COLORS[4]); + + assert_eq!(resp.legend[0].color, CONTRIBUTION_COLORS[0]); + assert_eq!(resp.legend[1].color, CONTRIBUTION_COLORS[1]); + assert_eq!(resp.legend[2].color, CONTRIBUTION_COLORS[2]); + assert_eq!(resp.legend[3].color, CONTRIBUTION_COLORS[3]); + assert_eq!(resp.legend[4].color, CONTRIBUTION_COLORS[4]); + } +} From aa9e679f8add6bf3da2b3249530ccc972ab16149 Mon Sep 17 00:00:00 2001 From: Nadu_Dev Date: Sat, 25 Apr 2026 09:51:32 +0530 Subject: [PATCH 08/12] fix: correct syntax for GITHUB_PAT assignment in cloud-init template --- infra/common/cloud-init.template | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/infra/common/cloud-init.template b/infra/common/cloud-init.template index 4cd16b1..788153a 100644 --- a/infra/common/cloud-init.template +++ b/infra/common/cloud-init.template @@ -134,7 +134,7 @@ write_files: ALLOWED_ORIGINS=${ALLOWED_ORIGINS} WARP_LICENSE_KEY=${WARP_LICENSE_KEY} MASTER_API_KEY=${MASTER_API_KEY} - GITHUB_PAT =${GHCR_PAT} + GITHUB_PAT=${GHCR_PAT} - path: /root/.ghcr_token permissions: '0600' From b718893293cf65133d16639e0475405e22a376dd Mon Sep 17 00:00:00 2001 From: Nadu_Dev Date: Sat, 25 Apr 2026 09:51:41 +0530 Subject: [PATCH 09/12] feat: update dependencies and remove async-trait from Cargo.toml --- Cargo.lock | 117 ++++++++++++++++++++++++++++++++++++++++++++++++++++- Cargo.toml | 10 ++--- 2 files changed, 120 insertions(+), 7 deletions(-) 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" From 9423a8e8883c550574bc136c7488ac71d933d2f6 Mon Sep 17 00:00:00 2001 From: Nadu_Dev Date: Sat, 25 Apr 2026 09:52:18 +0530 Subject: [PATCH 10/12] feat: add GitHub GraphQL URL configuration and update contributions service to use it --- src/app.rs | 1 + src/config.rs | 2 + src/services/contributions.rs | 121 ++++++++++++--------------- tests/api/common.rs | 15 ++-- tests/api/contributions_tests.rs | 139 +++++++++++++++++++++++++++++++ tests/api_tests.rs | 2 + tests/layer_unit_tests.rs | 1 + 7 files changed, 204 insertions(+), 77 deletions(-) create mode 100644 tests/api/contributions_tests.rs diff --git a/src/app.rs b/src/app.rs index 042879a..72ee8b5 100644 --- a/src/app.rs +++ b/src/app.rs @@ -61,6 +61,7 @@ pub async fn run() { .github_username .clone() .unwrap_or_else(|| "nxdun".to_string()), + config.github_graphql_url.clone(), )); let state = AppState { diff --git a/src/config.rs b/src/config.rs index ee1583f..727f861 100644 --- a/src/config.rs +++ b/src/config.rs @@ -40,6 +40,7 @@ pub struct AppConfig { pub master_api_key: String, pub github_pat: Option, pub github_username: Option, + pub github_graphql_url: String, } impl AppConfig { @@ -63,6 +64,7 @@ impl AppConfig { }), 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/services/contributions.rs b/src/services/contributions.rs index e3ba95a..0c502d2 100644 --- a/src/services/contributions.rs +++ b/src/services/contributions.rs @@ -2,7 +2,9 @@ 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, @@ -12,8 +14,8 @@ use crate::{ }, }; -const GITHUB_GRAPHQL_URL: &str = "https://api.github.com/graphql"; 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"; @@ -81,8 +83,8 @@ pub struct ContributionsService { http_client: Client, pat: String, default_username: String, - // In-memory cache mapping username -> (Response, expires_at in SystemTime) - cache: DashMap, + graphql_url: String, + cache: Arc>, } impl std::fmt::Debug for ContributionsService { @@ -90,20 +92,50 @@ impl std::fmt::Debug for ContributionsService { 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) -> Self { + 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_secs(600)); // 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, - cache: DashMap::new(), + 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 } @@ -112,7 +144,7 @@ impl ContributionsService { &self, username: &str, ) -> Result { - let now = SystemTime::now(); + let now = now_unix(); let cache_key = username.to_string(); if username.trim().is_empty() { @@ -135,12 +167,15 @@ impl ContributionsService { } } - let resp_result = self.fetch_and_process(username, now).await; + let resp_result = self.fetch_and_process(username, SystemTime::now()).await; match resp_result { Ok(new_resp) => { - let expires_at = now + Duration::from_secs(CACHE_TTL_SECONDS.into()); - self.cache.insert(cache_key, (new_resp.clone(), expires_at)); + 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) => { @@ -187,13 +222,14 @@ impl ContributionsService { let resp = self .http_client - .post(GITHUB_GRAPHQL_URL) + .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 error: {e}")))?; + .map_err(|e| AppError::Internal(anyhow::anyhow!("Network or timeout error: {e}")))?; if !resp.status().is_success() { return Err(AppError::Internal(anyhow::anyhow!( @@ -448,63 +484,8 @@ fn format_iso_time(sys_time: SystemTime) -> String { format!("{y:04}-{m:02}-{d:02}T{hh:02}:{mm:02}:{ss:02}Z") } -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn test_transform_calendar_colors() { - let calendar = GithubContributionCalendar { - total_contributions: 10, - weeks: vec![GithubWeek { - contribution_days: vec![ - GithubContributionDay { - date: "2023-01-01".into(), - weekday: 0, - contribution_count: 0, - contribution_level: "NONE".into(), - }, - GithubContributionDay { - date: "2023-01-02".into(), - weekday: 1, - contribution_count: 1, - contribution_level: "FIRST_QUARTILE".into(), - }, - GithubContributionDay { - date: "2023-01-03".into(), - weekday: 2, - contribution_count: 5, - contribution_level: "SECOND_QUARTILE".into(), - }, - GithubContributionDay { - date: "2023-01-04".into(), - weekday: 3, - contribution_count: 10, - contribution_level: "THIRD_QUARTILE".into(), - }, - GithubContributionDay { - date: "2023-01-05".into(), - weekday: 4, - contribution_count: 20, - contribution_level: "FOURTH_QUARTILE".into(), - }, - ], - }], - }; - - let resp = - ContributionsService::transform_calendar("testuser", &calendar, SystemTime::now()); - - assert_eq!(resp.cells[0].color, CONTRIBUTION_COLORS[0]); - assert_eq!(resp.cells[1].color, CONTRIBUTION_COLORS[1]); - assert_eq!(resp.cells[2].color, CONTRIBUTION_COLORS[2]); - assert_eq!(resp.cells[3].color, CONTRIBUTION_COLORS[3]); - assert_eq!(resp.cells[4].color, CONTRIBUTION_COLORS[4]); - - assert_eq!(resp.legend[0].color, CONTRIBUTION_COLORS[0]); - assert_eq!(resp.legend[1].color, CONTRIBUTION_COLORS[1]); - assert_eq!(resp.legend[2].color, CONTRIBUTION_COLORS[2]); - assert_eq!(resp.legend[3].color, CONTRIBUTION_COLORS[3]); - assert_eq!(resp.legend[4].color, CONTRIBUTION_COLORS[4]); - } +fn now_unix() -> u64 { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .map_or(0, |d| d.as_secs()) } diff --git a/tests/api/common.rs b/tests/api/common.rs index 699727d..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}; @@ -53,17 +53,18 @@ pub fn create_test_state_with_options( 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(nadzu::services::contributions::ContributionsService::new( - http_client.clone(), - "fake_pat".into(), - "nxdun".into(), - )); + let contributions_service = Arc::new(ContributionsService::new( + http_client.clone(), + "fake_pat".into(), + "nxdun".into(), + config.github_graphql_url.clone(), + )); AppState { config, 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 d50c31e..5bd6476 100644 --- a/tests/layer_unit_tests.rs +++ b/tests/layer_unit_tests.rs @@ -25,6 +25,7 @@ fn test_config(env: &str) -> AppConfig { master_api_key: "master_key".to_string(), github_pat: None, github_username: None, + github_graphql_url: "https://api.github.com/graphql".to_string(), } } From a4a56fe8e2379b12a4052b7452f7148d73c3f9d0 Mon Sep 17 00:00:00 2001 From: Nadu_Dev Date: Sat, 25 Apr 2026 09:56:05 +0530 Subject: [PATCH 11/12] fix: ensure proper formatting of order property in contributions request YAML files --- .../Nadzu API/Contributions (Default User).request.yaml | 2 +- .../Nadzu API/Contributions (Specific User).request.yaml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/postman/collections/Nadzu API/Contributions (Default User).request.yaml b/postman/collections/Nadzu API/Contributions (Default User).request.yaml index 9589706..09df63c 100644 --- a/postman/collections/Nadzu API/Contributions (Default User).request.yaml +++ b/postman/collections/Nadzu API/Contributions (Default User).request.yaml @@ -19,4 +19,4 @@ scripts: pm.expect(jsonData.summary).to.have.property('totalWeeks'); }); language: text/javascript -order: 3000 \ No newline at end of file +order: 3000 diff --git a/postman/collections/Nadzu API/Contributions (Specific User).request.yaml b/postman/collections/Nadzu API/Contributions (Specific User).request.yaml index d1bc7cf..7a3cd2d 100644 --- a/postman/collections/Nadzu API/Contributions (Specific User).request.yaml +++ b/postman/collections/Nadzu API/Contributions (Specific User).request.yaml @@ -19,4 +19,4 @@ scripts: pm.expect(jsonData.summary).to.have.property('totalWeeks'); }); language: text/javascript -order: 3001 \ No newline at end of file +order: 3001 From 9cf1f447a07eda4f1a8d7d0893beab80a334fccf Mon Sep 17 00:00:00 2001 From: Nadu_Dev Date: Sat, 25 Apr 2026 09:58:08 +0530 Subject: [PATCH 12/12] fix: correct interval duration for cache cleaning in ContributionsService --- src/services/contributions.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/services/contributions.rs b/src/services/contributions.rs index 0c502d2..bbc386c 100644 --- a/src/services/contributions.rs +++ b/src/services/contributions.rs @@ -108,7 +108,7 @@ impl ContributionsService { let cache_weak = Arc::downgrade(&cache); tokio::spawn(async move { - let mut interval = tokio::time::interval(Duration::from_secs(600)); // Clean every 10 mins + 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() {