Reescrita dos geradores: biblioteca IGenerator, minimal API e novo frontend

- GeradoresService: cada tipo passa a ser um IGenerator (NIF, NIPC, NISS, CC,
  passaporte, IBAN, NIB, cartões, referência MB, pessoa, telefone, código postal,
  email, matrícula, EAN, UUID) registado em GeneratorRegistry; remove DAL/EF
- GeradoresWS: minimal APIs em /api/v1 com OpenAPI + Scalar e health check
- UnitTest: testes de ida e volta, valores conhecidos, detecção e API
- geradoresfe: UI construída a partir dos metadados da API, validador livre,
  exportação CSV/JSON/TXT, nginx com API_UPSTREAM configurável
- Docker, docker-compose e Jenkinsfile actualizados

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-11 23:51:39 +01:00
parent 0f764b1239
commit d7ea8a9b9e
119 changed files with 7764 additions and 2502 deletions

View File

@@ -1,55 +0,0 @@
using Microsoft.AspNetCore.Mvc;
using GeradoresService;
using GeradoresService.DAL;
namespace GeradoresWS.Controllers
{
[ApiController]
[Route("[controller]")]
public class GenerateController : Controller
{
GeradoresContext _context;
public GenerateController(GeradoresContext context)
{
_context = context;
}
#region NIF
[HttpGet("GenerateNIF")]
public string GenerateNIF(NIF.NIFType type)
{
var nif = new NIF(_context);
return nif.Generate(type);
}
[HttpGet("ValidateNIF")]
public bool ValidateNIF(string nif)
{
var teste = new NIF(_context);
return teste.Validate(nif);
}
#endregion
#region NISS
[HttpGet("GenerateNISS")]
public string GenerateNISS()
{
return NISS.Generate();
}
[HttpGet("ValidateNISS")]
public bool ValidateNISS(string nif)
{
return NISS.Validate(nif);
}
#endregion
#region CC
[HttpGet("GenerateCC")]
public string GenerateCC()
{
return CartaoCidadao.Generate();
}
#endregion
}
}

View File

@@ -1,33 +0,0 @@
using Microsoft.AspNetCore.Mvc;
namespace GeradoresWS.Controllers
{
[ApiController]
[Route("[controller]")]
public class WeatherForecastController : ControllerBase
{
private static readonly string[] Summaries = new[]
{
"Freezing", "Bracing", "Chilly", "Cool", "Mild", "Warm", "Balmy", "Hot", "Sweltering", "Scorching"
};
private readonly ILogger<WeatherForecastController> _logger;
public WeatherForecastController(ILogger<WeatherForecastController> logger)
{
_logger = logger;
}
[HttpGet(Name = "GetWeatherForecast")]
public IEnumerable<WeatherForecast> Get()
{
return Enumerable.Range(1, 5).Select(index => new WeatherForecast
{
Date = DateOnly.FromDateTime(DateTime.Now.AddDays(index)),
TemperatureC = Random.Shared.Next(-20, 55),
Summary = Summaries[Random.Shared.Next(Summaries.Length)]
})
.ToArray();
}
}
}

View File

@@ -1,31 +1,26 @@
#See https://aka.ms/customizecontainer to learn how to customize your debug container and how Visual Studio uses this Dockerfile to build your images for faster debugging.
FROM mcr.microsoft.com/dotnet/aspnet:8.0 AS base
WORKDIR /app
EXPOSE 8080
EXPOSE 443
FROM mcr.microsoft.com/dotnet/sdk:8.0 AS build
# Usado pelo Visual Studio (perfil "Docker"). Igual ao Dockerfile da raiz, com o stage "base" que o VS espera.
FROM mcr.microsoft.com/dotnet/sdk:10.0 AS build
WORKDIR /src
COPY ["GeradoresWS/GeradoresWS.csproj", "GeradoresWS/"]
COPY ["GeradoresService/GeradoresService.csproj", "GeradoresService/"]
RUN dotnet restore "GeradoresWS/GeradoresWS.csproj"
COPY . .
WORKDIR "/src/GeradoresWS"
RUN dotnet build "GeradoresWS.csproj" -c debug -o /app/build
COPY GeradoresService/ GeradoresService/
COPY GeradoresWS/ GeradoresWS/
RUN dotnet publish "GeradoresWS/GeradoresWS.csproj" -c Release -o /app/publish --no-restore /p:UseAppHost=false
FROM build AS publish
RUN dotnet publish "GeradoresWS.csproj" -c debug -o /app/publish /p:UseAppHost=false
FROM mcr.microsoft.com/dotnet/aspnet:10.0 AS base
WORKDIR /app
EXPOSE 8080
FROM base AS final
WORKDIR /app
COPY --from=publish /app/publish .
COPY --from=build /app/publish .
# Set environment variables
ENV ASPNETCORE_URLS=http://+:8080
ENV ASPNETCORE_ENVIRONMENT=Production
ENV ASPNETCORE_HTTP_PORTS=8080 \
ASPNETCORE_ENVIRONMENT=Production
EXPOSE 8080
USER $APP_UID
ENTRYPOINT ["dotnet", "GeradoresWS.dll"]
ENTRYPOINT ["dotnet", "GeradoresWS.dll"]

View File

@@ -1,65 +0,0 @@
using Microsoft.OpenApi.Any;
using Microsoft.OpenApi.Models;
using Swashbuckle.AspNetCore.SwaggerGen;
using System.ComponentModel;
namespace Primus.ProfitabilityModel.WebAPI.Filters
{
public class EnumDescriptionFilter : ISchemaFilter
{
public void Apply(OpenApiSchema schema, SchemaFilterContext context)
{
if (context.Type.IsEnum)
{
// Obter os valores do enum
var enumValues = Enum.GetValues(context.Type)
.Cast<Enum>()
.ToList();
// Obter os nomes dos enums
var enumNames = enumValues.Select(e => e.ToString()).ToList();
// Obter as descrições dos enums
var enumDescriptions = enumValues.Select(e => GetEnumDescription(e)).ToList();
// Adicionar as extensões com os nomes e descrições
var enumNamesArray = new OpenApiArray();
foreach (var name in enumNames)
{
enumNamesArray.Add(new OpenApiString(name));
}
var enumDescriptionsArray = new OpenApiArray();
foreach (var description in enumDescriptions)
{
enumDescriptionsArray.Add(new OpenApiString(description));
}
schema.Extensions.Add("x-enumNames", enumDescriptionsArray);
schema.Extensions.Add("x-enumDescriptions", enumDescriptionsArray);
// Adicionar os valores do enum ao campo Enum
schema.Enum = enumNames
.Select(name => new OpenApiString(name))
.Cast<IOpenApiAny>()
.ToList();
}
}
private static string GetEnumDescription(Enum value)
{
// Verificar se o campo é nulo antes de chamar GetCustomAttribute
var field = value.GetType().GetField(value.ToString());
if (field == null)
{
return value.ToString(); // Se o campo for nulo, retorna o nome do valor do enum
}
// Acessa o atributo Description, caso exista
var attribute = (DescriptionAttribute)Attribute.GetCustomAttribute(field, typeof(DescriptionAttribute));
// Retorna a descrição ou o nome do valor do enum, se a descrição não estiver presente
return attribute?.Description ?? value.ToString();
}
}
}

View File

@@ -0,0 +1,101 @@
using System.ComponentModel;
using GeradoresService;
using GeradoresService.Core;
using Microsoft.AspNetCore.Http.HttpResults;
namespace GeradoresWS;
public static class GeneratorEndpoints
{
private static readonly HashSet<string> ReservedQueryKeys = new(StringComparer.OrdinalIgnoreCase) { "count", "seed" };
public static void MapGeneratorEndpoints(this IEndpointRouteBuilder app)
{
var api = app.MapGroup("/api/v1").WithTags("Geradores");
api.MapGet("/generators", (GeneratorRegistry registry) =>
registry.All.Select(GeneratorDto.From).ToList())
.WithName("ListGenerators")
.WithSummary("Lista todos os geradores e as respectivas opções.");
api.MapGet("/generators/{id}", Results<Ok<GeneratorDto>, NotFound> (string id, GeneratorRegistry registry) =>
registry.Find(id) is { } g ? TypedResults.Ok(GeneratorDto.From(g)) : TypedResults.NotFound())
.WithName("GetGenerator")
.WithSummary("Detalhe de um gerador.");
api.MapGet("/generators/{id}/generate", Generate)
.WithName("Generate")
.WithSummary("Gera um ou mais valores.")
.WithDescription("As opções do gerador passam-se na query string, ex.: /generators/nif/generate?count=10&tipo=5");
api.MapGet("/generators/{id}/validate",
Results<Ok<ValidationResult>, NotFound> (string id, [Description("Valor a validar")] string value, GeneratorRegistry registry) =>
registry.Find(id) is { CanValidate: true } g
? TypedResults.Ok(g.Validate(value))
: TypedResults.NotFound())
.WithName("Validate")
.WithSummary("Valida um valor de um tipo específico.");
api.MapGet("/validate", (string value, GeneratorRegistry registry) =>
new DetectResponse(value, registry.Detect(value)
.Select(m => new DetectMatchDto(m.Generator.Id, m.Generator.Name, m.Result))
.ToList()))
.WithName("Detect")
.WithSummary("Validador livre: identifica o tipo do valor e diz se é válido.");
}
private static Results<Ok<GenerateResponse>, NotFound, ValidationProblem> Generate(
string id,
HttpRequest request,
GeneratorRegistry registry,
[Description("Quantidade (1–1000)")] int count = 1,
[Description("Seed para resultados reprodutíveis")] int? seed = null)
{
var generator = registry.Find(id);
if (generator is null)
return TypedResults.NotFound();
var options = request.Query
.Where(q => !ReservedQueryKeys.Contains(q.Key))
.ToDictionary(q => q.Key, q => (string?)q.Value.ToString(), StringComparer.OrdinalIgnoreCase);
var unknown = options.Keys.Where(k => generator.Options.All(o => !string.Equals(o.Key, k, StringComparison.OrdinalIgnoreCase))).ToList();
if (unknown.Count > 0)
return Problem(unknown[0], $"Opção desconhecida. Opções válidas: {string.Join(", ", generator.Options.Select(o => o.Key))}.");
if (count is < 1 or > GeneratorRegistry.MaxBatch)
return Problem("count", $"A quantidade deve estar entre 1 e {GeneratorRegistry.MaxBatch}.");
try
{
var items = registry.Generate(generator, count, options, seed);
return TypedResults.Ok(new GenerateResponse(generator.Id, items));
}
catch (ArgumentException ex)
{
return Problem("options", ex.Message);
}
}
private static ValidationProblem Problem(string key, string message) =>
TypedResults.ValidationProblem(new Dictionary<string, string[]> { [key] = [message] });
}
public sealed record GeneratorDto(
string Id,
string Name,
string Category,
string Description,
bool HasChecksum,
bool CanValidate,
IReadOnlyList<GeneratorOption> Options)
{
public static GeneratorDto From(IGenerator g) =>
new(g.Id, g.Name, g.Category, g.Description, g.HasChecksum, g.CanValidate, g.Options);
}
public sealed record GenerateResponse(string Generator, IReadOnlyList<GeneratedItem> Items);
public sealed record DetectMatchDto(string GeneratorId, string GeneratorName, ValidationResult Result);
public sealed record DetectResponse(string Value, IReadOnlyList<DetectMatchDto> Matches);

View File

@@ -1,17 +1,18 @@
<Project Sdk="Microsoft.NET.Sdk.Web">
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
<TargetFramework>net10.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<UserSecretsId>df357c84-88b1-4100-86d1-889166b333f7</UserSecretsId>
<DockerDefaultTargetOS>Linux</DockerDefaultTargetOS>
<InvariantGlobalization>true</InvariantGlobalization>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="7.0.9" />
<PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="10.0.12" />
<PackageReference Include="Microsoft.VisualStudio.Azure.Containers.Tools.Targets" Version="1.18.1" />
<PackageReference Include="Swashbuckle.AspNetCore" Version="6.5.0" />
<PackageReference Include="Scalar.AspNetCore" Version="2.17.14" />
</ItemGroup>
<ItemGroup>

View File

@@ -1,56 +1,40 @@
using GeradoresService.DAL;
using Microsoft.EntityFrameworkCore;
using Microsoft.OpenApi.Models;
using Primus.ProfitabilityModel.WebAPI.Filters;
using System.Text.Json.Serialization;
using GeradoresService;
using GeradoresWS;
using Scalar.AspNetCore;
var builder = WebApplication.CreateBuilder(args);
// Add services to the container.
builder.Services.AddControllers().AddJsonOptions(options =>
builder.Services.AddSingleton<GeneratorRegistry>();
builder.Services.AddProblemDetails();
builder.Services.AddHealthChecks();
builder.Services.AddOpenApi(options =>
{
options.JsonSerializerOptions.Converters.Add(new JsonStringEnumConverter());
});
// Learn more about configuring Swagger/OpenAPI at https://aka.ms/aspnetcore/swashbuckle
builder.Services.AddEndpointsApiExplorer();
builder.Services.AddSwaggerGen(options =>
{
options.ResolveConflictingActions(apiDescriptions => apiDescriptions.First());
options.SwaggerDoc("v1", new OpenApiInfo
options.AddDocumentTransformer((document, _, _) =>
{
Version = "v1",
Title = "FactoryId",
Contact = new OpenApiContact
{
Name = "Marco Santos"
},
document.Info.Title = "FactoryId API";
document.Info.Description = "Geradores e validadores de identificadores portugueses (NIF, NISS, CC, IBAN, …) para testes.";
document.Info.Contact = new() { Name = "Marco Santos" };
return Task.CompletedTask;
});
// Registra o filtro de esquema que aplica descrições para todos os enums
options.SchemaFilter<EnumDescriptionFilter>();
});
var connectionString = builder.Configuration.GetConnectionString("GeradoresConnection");
builder.Services.AddDbContext<GeradoresContext>(options => options.UseSqlServer(connectionString));
// API pública, só de leitura e sem credenciais: qualquer origem pode fazer GET.
builder.Services.AddCors(options =>
options.AddDefaultPolicy(policy => policy.AllowAnyOrigin().WithMethods("GET").AllowAnyHeader()));
var app = builder.Build();
// Configure the HTTP request pipeline.
app.UseSwagger();
app.UseSwaggerUI();
app.UseHttpsRedirection();
app.UseExceptionHandler();
app.UseStatusCodePages();
app.UseCors();
app.UseAuthorization();
app.MapOpenApi();
app.MapScalarApiReference("/docs", options => options.WithTitle("FactoryId API"));
app.MapGet("/", () => Results.Redirect("/docs")).ExcludeFromDescription();
app.MapHealthChecks("/health");
app.UseCors(options =>
{
options.AllowAnyOrigin();
options.AllowAnyMethod();
options.AllowAnyHeader();
});
app.MapControllers();
app.MapGeneratorEndpoints();
app.Run();
public partial class Program;

View File

@@ -3,7 +3,7 @@
"http": {
"commandName": "Project",
"launchBrowser": true,
"launchUrl": "swagger",
"launchUrl": "docs",
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development"
},
@@ -13,7 +13,7 @@
"https": {
"commandName": "Project",
"launchBrowser": true,
"launchUrl": "swagger",
"launchUrl": "docs",
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development"
},
@@ -23,7 +23,7 @@
"IIS Express": {
"commandName": "IISExpress",
"launchBrowser": true,
"launchUrl": "swagger",
"launchUrl": "docs",
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development"
}
@@ -31,7 +31,7 @@
"Docker": {
"commandName": "Docker",
"launchBrowser": true,
"launchUrl": "{Scheme}://{ServiceHost}:{ServicePort}/swagger",
"launchUrl": "{Scheme}://{ServiceHost}:{ServicePort}/docs",
"environmentVariables": {
"ASPNETCORE_URLS": "http://+:8080",
"ASPNETCORE_ENVIRONMENT": "Development"

View File

@@ -1,13 +0,0 @@
namespace GeradoresWS
{
public class WeatherForecast
{
public DateOnly Date { get; set; }
public int TemperatureC { get; set; }
public int TemperatureF => 32 + (int)(TemperatureC / 0.5556);
public string? Summary { get; set; }
}
}

View File

@@ -5,9 +5,5 @@
"Microsoft.AspNetCore": "Warning"
}
},
"AllowedHosts": "*",
"ConnectionStrings": {
"GeradoresConnection": "server=127.0.0.1;uid=sa;pwd=mssqlAdmin!1;database=Geradores;TrustServerCertificate=True;"
}
"AllowedHosts": "*"
}