Compare commits

...

2 Commits

Author SHA1 Message Date
965b693a19 tech stats
All checks were successful
API and ETL Build / build_api (push) Successful in 37s
API and ETL Build / build_etl (push) Successful in 39s
2025-06-19 21:41:16 -03:00
ecbf2f07d6 rate limiting e cpf masking 2025-06-19 19:56:17 -03:00
10 changed files with 147 additions and 11 deletions

View File

@ -8,6 +8,7 @@ namespace OpenCand.API.Config
public const string DefaultPolicy = "DefaultPolicy";
public const string CandidatoSearchPolicy = "CandidatoSearchPolicy";
public const string CpfRevealPolicy = "CpfRevealPolicy";
public const string EstatisticaPolicy = "EstatisticaPolicy";
public static void ConfigureRateLimiting(this IServiceCollection services)
{
@ -50,6 +51,15 @@ namespace OpenCand.API.Config
options.QueueLimit = 0; // No burst
});
// CPF Reveal policy: 25 requests per minute with 10 burst
options.AddFixedWindowLimiter(policyName: EstatisticaPolicy, options =>
{
options.PermitLimit = 25;
options.Window = TimeSpan.FromMinutes(1);
options.QueueProcessingOrder = QueueProcessingOrder.OldestFirst;
options.QueueLimit = 10; // No burst
});
options.OnRejected = async (context, token) =>
{
context.HttpContext.Response.StatusCode = 429;

View File

@ -5,6 +5,7 @@ using OpenCand.API.Config;
using OpenCand.API.Model;
using OpenCand.API.Services;
using OpenCand.Core.Models;
using OpenCand.Core.Utils;
namespace OpenCand.API.Controllers
{
@ -27,7 +28,11 @@ namespace OpenCand.API.Controllers
throw new ArgumentException("Query parameter 'q' cannot be null/empty.", nameof(q));
}
return await openCandService.SearchCandidatosAsync(q);
var result = await openCandService.SearchCandidatosAsync(q);
result.Candidatos.ForEach(c => c.Cpf = CpfMasking.MaskCpf(c.Cpf));
return result;
}
[HttpGet("random")]
@ -42,7 +47,10 @@ namespace OpenCand.API.Controllers
[HttpGet("{id}")]
public async Task<Candidato> GetCandidatoById([FromRoute] Guid id)
{
return await openCandService.GetCandidatoAsync(id);
var result = await openCandService.GetCandidatoAsync(id);
result.Cpf = CpfMasking.MaskCpf(result.Cpf);
return result;
}
[HttpGet("{id}/bens")]

View File

@ -6,7 +6,7 @@ using OpenCand.API.Services;
namespace OpenCand.API.Controllers
{
[EnableRateLimiting(RateLimitingConfig.DefaultPolicy)]
[EnableRateLimiting(RateLimitingConfig.EstatisticaPolicy)]
public class EstatisticaController : BaseController
{
private readonly EstatisticaService estatisticaService;

View File

@ -27,6 +27,12 @@ namespace OpenCand.API.Controllers
{
return await openCandService.GetDataAvailabilityStatsAsync();
}
[HttpGet("tech")]
public async Task<DatabaseTechStats> GetDatabaseTechStats()
{
return await openCandService.GetDatabaseTechStatsAsync();
}
}
}

View File

@ -67,7 +67,7 @@ namespace OpenCand.API.Repository
{
result.Partidos = (await connection.QueryAsync<string>(@"SELECT DISTINCT sigla FROM partido ORDER BY sigla ASC;")).AsList();
result.SiglasUF = (await connection.QueryAsync<string>(@"SELECT DISTINCT siglauf FROM candidato_mapping ORDER BY siglauf ASC;")).AsList();
result.Anos = (await connection.QueryAsync<int>(@"SELECT DISTINCT ano FROM candidato_mapping ORDER BY ano ASC;")).AsList();
result.Anos = (await connection.QueryAsync<int>(@"SELECT DISTINCT ano FROM candidato_mapping ORDER BY ano DESC;")).AsList();
result.Cargos = (await connection.QueryAsync<string>(@"SELECT DISTINCT cargo FROM candidato_mapping ORDER BY cargo ASC;")).AsList();
}

View File

@ -81,5 +81,63 @@ namespace OpenCand.API.Repository
return result ?? new DataAvailabilityStats();
}
public async Task<DatabaseTechStats> GetDatabaseTechStatsAsync()
{
string cacheKey = GenerateCacheKey("DatabaseTechStats");
var result = await GetOrSetCacheAsync(cacheKey, async () =>
{
using (var connection = new NpgsqlConnection(ConnectionString))
{
var stats = new DatabaseTechStats();
// Get table stats using reltuples for entries
var tableStats = await connection.QueryAsync<TableStats>(@"
SELECT
pt.schemaname||'.'||pt.tablename as Name,
pg_total_relation_size(pt.schemaname||'.'||pt.tablename) as TotalSize,
COALESCE(c.reltuples,0)::bigint as Entries
FROM pg_tables pt
JOIN pg_class c ON c.relname = pt.tablename AND c.relkind = 'r'
WHERE pt.schemaname = 'public'
ORDER BY pg_total_relation_size(pt.schemaname||'.'||pt.tablename) DESC;");
var tableStatsList = tableStats.ToList();
stats.Tables = tableStatsList;
stats.TotalSize = tableStatsList.Sum(t => t.TotalSize);
stats.TotalEntries = tableStatsList.Sum(t => t.Entries);
// Get materialized view stats using reltuples for entries
var materializedViewStats = await connection.QueryAsync<TableStats>(@"
SELECT
pmv.schemaname||'.'||pmv.matviewname as Name,
pg_total_relation_size(pmv.schemaname||'.'||pmv.matviewname) as TotalSize,
COALESCE(c.reltuples,0)::bigint as Entries
FROM pg_matviews pmv
JOIN pg_class c ON c.relname = pmv.matviewname AND c.relkind = 'm'
WHERE pmv.schemaname = 'public'
ORDER BY pg_total_relation_size(pmv.schemaname||'.'||pmv.matviewname) DESC;");
stats.MaterializedViews = materializedViewStats.ToList();
// Get index stats
var indexStats = await connection.QueryFirstOrDefaultAsync<IndexStats>(@"
SELECT
COUNT(*) as Amount,
SUM(pg_relation_size(indexrelid)) as Size
FROM pg_stat_user_indexes
WHERE schemaname = 'public';");
stats.Indexes = indexStats ?? new IndexStats();
return stats;
}
});
return result ?? new DatabaseTechStats();
}
}
}

View File

@ -66,6 +66,7 @@ namespace OpenCand.API.Services
await PerformPreLoad("GetOpenCandStatsAsync", estatisticaService.GetMaioresEnriquecimentos);
await PerformPreLoad("GetOpenCandStatsAsync", openCandService.GetOpenCandStatsAsync);
await PerformPreLoad("GetDatabaseTechStatsAsync", openCandService.GetDatabaseTechStatsAsync);
await PerformPreLoad("GetDataAvailabilityStatsAsync", openCandService.GetDataAvailabilityStatsAsync);
logger.LogInformation("Single-call endpoints preload completed");

View File

@ -47,6 +47,16 @@ namespace OpenCand.API.Services
return stats;
}
public async Task<DatabaseTechStats> GetDatabaseTechStatsAsync()
{
var stats = await openCandRepository.GetDatabaseTechStatsAsync();
stats.Tables = stats.Tables.OrderBy(t => t.Name).ToList();
stats.MaterializedViews = stats.MaterializedViews.OrderBy(mv => mv.Name).ToList();
return stats;
}
public async Task<CandidatoSearchResult> SearchCandidatosAsync(string query)
{
return new CandidatoSearchResult()

View File

@ -11,12 +11,34 @@
public class DataAvailabilityStats
{
public List<int> Candidatos { get; set; }
public List<int> BemCandidatos { get; set; }
public List<int> DespesaCandidatos { get; set; }
public List<int> ReceitaCandidatos { get; set; }
public List<int> RedeSocialCandidatos { get; set; }
public List<int> FotosCandidatos { get; set; }
public List<int> Candidatos { get; set; } = new List<int>();
public List<int> BemCandidatos { get; set; } = new List<int>();
public List<int> DespesaCandidatos { get; set; } = new List<int>();
public List<int> ReceitaCandidatos { get; set; } = new List<int>();
public List<int> RedeSocialCandidatos { get; set; } = new List<int>();
public List<int> FotosCandidatos { get; set; } = new List<int>();
}
public class DatabaseTechStats
{
public List<TableStats> Tables { get; set; } = new List<TableStats>();
public List<TableStats> MaterializedViews { get; set; } = new List<TableStats>();
public IndexStats Indexes { get; set; } = new IndexStats();
public long TotalSize { get; set; }
public long TotalEntries { get; set; }
}
public class TableStats
{
public string Name { get; set; } = string.Empty;
public long TotalSize { get; set; }
public long Entries { get; set; }
}
public class IndexStats
{
public int Amount { get; set; }
public long Size { get; set; }
}
}

View File

@ -0,0 +1,21 @@
namespace OpenCand.Core.Utils
{
public static class CpfMasking
{
/// <summary>
/// Masks a CPF number by replacing the middle 3 digits with '*'
/// </summary>
/// <param name="cpf">The CPF number to mask.</param>
/// <returns>The masked CPF number.</returns>
public static string MaskCpf(string cpf)
{
if (string.IsNullOrEmpty(cpf) || cpf.Length != 11)
{
return cpf;
}
// Mask the middle 3 digits
return $"{cpf.Substring(0, 3)}***{cpf.Substring(6)}";
}
}
}