Changes
Build and Push Docker Images / build (push) Successful in 36s

This commit is contained in:
2026-05-06 13:59:59 +03:00
parent 91cdb536b1
commit cd0e32513f
8 changed files with 99 additions and 39 deletions
@@ -0,0 +1,15 @@
using System.Net.Http;
using Refit;
using Api.Models.Requests;
namespace Api.Clients.Api.Contracts;
public interface ICvMatcherApi
{
[Multipart]
[Post("/api/cv/upload")]
Task<HttpResponseMessage> Upload([AliasAs("cv")] StreamPart cv, [AliasAs("gdprConsent")] bool gdprConsent);
[Post("/api/cv/match-job")]
Task<HttpResponseMessage> MatchJob([Body] JobMatchRequest request);
}
+31 -35
View File
@@ -1,36 +1,47 @@
using Api.Models.Requests; using Api.Models.Requests;
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.RateLimiting; using Microsoft.AspNetCore.RateLimiting;
using System.Net.Http.Headers; using Swashbuckle.AspNetCore.Annotations;
using System.Text;
using System.Text.Json;
namespace Api.Controllers; namespace Api.Controllers;
/// <summary>
/// Proxy endpoints for the CV matcher API. These endpoints forward requests to the internal cv-matcher-api.
/// </summary>
[ApiController] [ApiController]
[Route("api/cv-matcher")] [Route("api/cv-matcher")]
[EnableRateLimiting("cv-matcher")] [EnableRateLimiting("cv-matcher")]
public sealed class RagController : ControllerBase public sealed class RagController : ControllerBase
{ {
private readonly IHttpClientFactory _httpClientFactory; private readonly Api.Clients.Api.Contracts.ICvMatcherApi _cvApi;
private readonly IConfiguration _configuration; private readonly IConfiguration _configuration;
private readonly ILogger<RagController> _logger; private readonly ILogger<RagController> _logger;
public RagController( public RagController(
IHttpClientFactory httpClientFactory, Api.Clients.Api.Contracts.ICvMatcherApi cvApi,
IConfiguration configuration, IConfiguration configuration,
ILogger<RagController> logger) ILogger<RagController> logger)
{ {
_httpClientFactory = httpClientFactory; _cvApi = cvApi;
_configuration = configuration; _configuration = configuration;
_logger = logger; _logger = logger;
} }
/// <summary>
/// Upload a CV PDF to the cv-matcher-api.
/// </summary>
/// <param name="cv">The uploaded CV PDF file.</param>
/// <param name="gdprConsent">Whether the user consented to GDPR processing.</param>
/// <param name="ct">Cancellation token.</param>
[HttpPost("upload")] [HttpPost("upload")]
[RequestSizeLimit(8 * 1024 * 1024)] [RequestSizeLimit(8 * 1024 * 1024)]
[ProducesResponseType(StatusCodes.Status200OK)] [ProducesResponseType(StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status400BadRequest)] [ProducesResponseType(StatusCodes.Status400BadRequest)]
[ProducesResponseType(StatusCodes.Status502BadGateway)] [ProducesResponseType(StatusCodes.Status502BadGateway)]
[SwaggerOperation(Summary = "Upload CV", Description = "Proxy upload of a CV PDF to the internal cv-matcher-api.")]
[SwaggerResponse(StatusCodes.Status200OK, "Upload succeeded")]
[SwaggerResponse(StatusCodes.Status400BadRequest, "Missing or invalid input")]
[SwaggerResponse(StatusCodes.Status502BadGateway, "Upstream cv-matcher-api error")]
public async Task<IActionResult> UploadCv( public async Task<IActionResult> UploadCv(
[FromForm(Name = "cv")] IFormFile? cv, [FromForm(Name = "cv")] IFormFile? cv,
[FromForm] bool gdprConsent, [FromForm] bool gdprConsent,
@@ -53,15 +64,9 @@ public sealed class RagController : ControllerBase
_logger.LogInformation("Proxying CV upload to cv-matcher-api. FileName={FileName}, Size={SizeBytes}, GdprConsent={GdprConsent}", _logger.LogInformation("Proxying CV upload to cv-matcher-api. FileName={FileName}, Size={SizeBytes}, GdprConsent={GdprConsent}",
cv.FileName, cv.Length, gdprConsent); cv.FileName, cv.Length, gdprConsent);
using var client = CreateCvMatcherClient(baseUrl); var stream = cv.OpenReadStream();
using var form = new MultipartFormDataContent(); var part = new Refit.StreamPart(stream, cv.FileName, "application/pdf");
await using var stream = cv.OpenReadStream(); using var response = await _cvApi.Upload(part, gdprConsent);
using var fileContent = new StreamContent(stream);
fileContent.Headers.ContentType = new MediaTypeHeaderValue("application/pdf");
form.Add(fileContent, "cv", cv.FileName);
form.Add(new StringContent(gdprConsent.ToString().ToLowerInvariant()), "gdprConsent");
using var response = await client.PostAsync("api/cv/upload", form, ct);
return await ProxyResponseAsync(response, ct); return await ProxyResponseAsync(response, ct);
} }
catch (OperationCanceledException) when (ct.IsCancellationRequested) catch (OperationCanceledException) when (ct.IsCancellationRequested)
@@ -76,10 +81,19 @@ public sealed class RagController : ControllerBase
} }
} }
/// <summary>
/// Proxy a job matching request to the cv-matcher-api.
/// </summary>
/// <param name="request">Job match request payload containing CV document id or job description/url.</param>
/// <param name="ct">Cancellation token.</param>
[HttpPost("match-job")] [HttpPost("match-job")]
[ProducesResponseType(StatusCodes.Status200OK)] [ProducesResponseType(StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status400BadRequest)] [ProducesResponseType(StatusCodes.Status400BadRequest)]
[ProducesResponseType(StatusCodes.Status502BadGateway)] [ProducesResponseType(StatusCodes.Status502BadGateway)]
[SwaggerOperation(Summary = "Match job", Description = "Proxy job matching request to the internal cv-matcher-api.")]
[SwaggerResponse(StatusCodes.Status200OK, "Match succeeded")]
[SwaggerResponse(StatusCodes.Status400BadRequest, "Invalid request")]
[SwaggerResponse(StatusCodes.Status502BadGateway, "Upstream cv-matcher-api error")]
public async Task<IActionResult> MatchJob([FromBody] JobMatchRequest request, CancellationToken ct) public async Task<IActionResult> MatchJob([FromBody] JobMatchRequest request, CancellationToken ct)
{ {
var baseUrl = GetCvMatcherBaseUrl(); var baseUrl = GetCvMatcherBaseUrl();
@@ -96,13 +110,7 @@ public sealed class RagController : ControllerBase
!string.IsNullOrWhiteSpace(request.JobUrl), !string.IsNullOrWhiteSpace(request.JobUrl),
!string.IsNullOrWhiteSpace(request.JobDescription)); !string.IsNullOrWhiteSpace(request.JobDescription));
using var client = CreateCvMatcherClient(baseUrl); using var response = await _cvApi.MatchJob(request);
var json = JsonSerializer.Serialize(request, new JsonSerializerOptions(JsonSerializerDefaults.Web));
using var response = await client.PostAsync(
"api/cv/match-job",
new StringContent(json, Encoding.UTF8, "application/json"),
ct);
return await ProxyResponseAsync(response, ct); return await ProxyResponseAsync(response, ct);
} }
catch (OperationCanceledException) when (ct.IsCancellationRequested) catch (OperationCanceledException) when (ct.IsCancellationRequested)
@@ -119,19 +127,7 @@ public sealed class RagController : ControllerBase
private string GetCvMatcherBaseUrl() => _configuration["CvMatcherApi:BaseUrl"] ?? string.Empty; private string GetCvMatcherBaseUrl() => _configuration["CvMatcherApi:BaseUrl"] ?? string.Empty;
private HttpClient CreateCvMatcherClient(string baseUrl) // Refit client is configured in Program.cs; this helper only reads config for diagnostics
{
var client = _httpClientFactory.CreateClient("CvMatcherApi");
client.BaseAddress = new Uri(baseUrl.TrimEnd('/') + "/");
var key = _configuration["CvMatcherApi:InternalApiKey"];
if (!string.IsNullOrWhiteSpace(key) && !client.DefaultRequestHeaders.Contains("X-Internal-Api-Key"))
{
client.DefaultRequestHeaders.Add("X-Internal-Api-Key", key);
}
return client;
}
private static async Task<ContentResult> ProxyResponseAsync(HttpResponseMessage response, CancellationToken ct) private static async Task<ContentResult> ProxyResponseAsync(HttpResponseMessage response, CancellationToken ct)
{ {
+26 -2
View File
@@ -6,6 +6,7 @@ using Microsoft.AspNetCore.HttpOverrides;
using Serilog; using Serilog;
using System.Reflection; using System.Reflection;
using System.Threading.RateLimiting; using System.Threading.RateLimiting;
using Refit;
// Load .env file if it exists (for local development) // Load .env file if it exists (for local development)
@@ -80,11 +81,34 @@ try
builder.Services.AddHttpClient<ICaptchaVerifier, RecaptchaVerifier>(); builder.Services.AddHttpClient<ICaptchaVerifier, RecaptchaVerifier>();
builder.Services.AddSingleton<IEmailSender, SmtpEmailSender>(); builder.Services.AddSingleton<IEmailSender, SmtpEmailSender>();
builder.Services.AddSingleton<Microsoft.AspNetCore.StaticFiles.IContentTypeProvider, Microsoft.AspNetCore.StaticFiles.FileExtensionContentTypeProvider>(); builder.Services.AddSingleton<Microsoft.AspNetCore.StaticFiles.IContentTypeProvider, Microsoft.AspNetCore.StaticFiles.FileExtensionContentTypeProvider>();
builder.Services.AddHttpClient("CvMatcherApi");
// Refit client for CvMatcher API
builder.Services.AddRefitClient<Api.Clients.Api.Contracts.ICvMatcherApi>()
.ConfigureHttpClient((sp, client) =>
{
var config = sp.GetRequiredService<IConfiguration>();
var baseUrl = config["CvMatcherApi:BaseUrl"] ?? string.Empty;
if (!string.IsNullOrWhiteSpace(baseUrl)) client.BaseAddress = new Uri(baseUrl.TrimEnd('/') + "/");
var key = config["CvMatcherApi:InternalApiKey"];
if (!string.IsNullOrWhiteSpace(key) && !client.DefaultRequestHeaders.Contains("X-Internal-Api-Key"))
{
client.DefaultRequestHeaders.Add("X-Internal-Api-Key", key);
}
});
// Swagger // Swagger
builder.Services.AddEndpointsApiExplorer(); builder.Services.AddEndpointsApiExplorer();
builder.Services.AddSwaggerGen(); builder.Services.AddSwaggerGen(options =>
{
// Include XML comments (enable <GenerateDocumentationFile> in csproj)
var xmlFile = (Assembly.GetExecutingAssembly().GetName().Name ?? "Api") + ".xml";
var xmlPath = Path.Combine(AppContext.BaseDirectory, xmlFile);
if (File.Exists(xmlPath)) options.IncludeXmlComments(xmlPath);
// Enable annotations like [SwaggerOperation], [SwaggerResponse]
options.EnableAnnotations();
});
// If you're behind Caddy / reverse proxy // If you're behind Caddy / reverse proxy
builder.Services.Configure<ForwardedHeadersOptions>(options => builder.Services.Configure<ForwardedHeadersOptions>(options =>
+4
View File
@@ -11,6 +11,8 @@
<DockerDefaultTargetOS>Linux</DockerDefaultTargetOS> <DockerDefaultTargetOS>Linux</DockerDefaultTargetOS>
<DisableStaticWebAssets>true</DisableStaticWebAssets> <DisableStaticWebAssets>true</DisableStaticWebAssets>
<RootNamespace>Api</RootNamespace> <RootNamespace>Api</RootNamespace>
<GenerateDocumentationFile>true</GenerateDocumentationFile>
<NoWarn>$(NoWarn);1591</NoWarn>
</PropertyGroup> </PropertyGroup>
<ItemGroup> <ItemGroup>
@@ -24,6 +26,8 @@
<PackageReference Include="Serilog.Sinks.Email" Version="4.2.1" /> <PackageReference Include="Serilog.Sinks.Email" Version="4.2.1" />
<PackageReference Include="Serilog.Sinks.File" Version="7.0.0" /> <PackageReference Include="Serilog.Sinks.File" Version="7.0.0" />
<PackageReference Include="Swashbuckle.AspNetCore" Version="10.1.7" /> <PackageReference Include="Swashbuckle.AspNetCore" Version="10.1.7" />
<PackageReference Include="Swashbuckle.AspNetCore.Annotations" Version="6.5.0" />
<PackageReference Include="Refit.HttpClientFactory" Version="10.1.6" />
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>
+9 -1
View File
@@ -7,6 +7,8 @@ using Serilog;
using System.Reflection; using System.Reflection;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using Refit; using Refit;
using System.IO;
using Swashbuckle.AspNetCore.Annotations;
using Api.Data.Repositories; using Api.Data.Repositories;
using Api.Data.Repositories.Contracts; using Api.Data.Repositories.Contracts;
using Api.Clients.Api; using Api.Clients.Api;
@@ -99,7 +101,13 @@ try
builder.Services.AddControllers(); builder.Services.AddControllers();
builder.Services.AddEndpointsApiExplorer(); builder.Services.AddEndpointsApiExplorer();
builder.Services.AddSwaggerGen(); builder.Services.AddSwaggerGen(options =>
{
var xmlFile = (Assembly.GetExecutingAssembly().GetName().Name ?? "cv-matcher-api") + ".xml";
var xmlPath = Path.Combine(AppContext.BaseDirectory, xmlFile);
if (File.Exists(xmlPath)) options.IncludeXmlComments(xmlPath);
options.EnableAnnotations();
});
var app = builder.Build(); var app = builder.Build();
+3
View File
@@ -5,6 +5,8 @@
<ImplicitUsings>enable</ImplicitUsings> <ImplicitUsings>enable</ImplicitUsings>
<DockerDefaultTargetOS>Linux</DockerDefaultTargetOS> <DockerDefaultTargetOS>Linux</DockerDefaultTargetOS>
<RootNamespace>Api</RootNamespace> <RootNamespace>Api</RootNamespace>
<GenerateDocumentationFile>true</GenerateDocumentationFile>
<NoWarn>$(NoWarn);1591</NoWarn>
</PropertyGroup> </PropertyGroup>
<ItemGroup> <ItemGroup>
<Content Remove="C:\Users\Gelu\.nuget\packages\microsoft.codeanalysis.workspaces.msbuild\4.14.0\contentFiles\any\any\BuildHost-net472\cs\System.CommandLine.resources.dll" /> <Content Remove="C:\Users\Gelu\.nuget\packages\microsoft.codeanalysis.workspaces.msbuild\4.14.0\contentFiles\any\any\BuildHost-net472\cs\System.CommandLine.resources.dll" />
@@ -70,6 +72,7 @@
<PackageReference Include="Serilog.Sinks.Console" Version="6.1.1" /> <PackageReference Include="Serilog.Sinks.Console" Version="6.1.1" />
<PackageReference Include="Serilog.Sinks.Email" Version="4.2.1" /> <PackageReference Include="Serilog.Sinks.Email" Version="4.2.1" />
<PackageReference Include="Swashbuckle.AspNetCore" Version="10.1.7" /> <PackageReference Include="Swashbuckle.AspNetCore" Version="10.1.7" />
<PackageReference Include="Swashbuckle.AspNetCore.Annotations" Version="6.5.0" />
<PackageReference Include="Refit.HttpClientFactory" Version="10.1.6" /> <PackageReference Include="Refit.HttpClientFactory" Version="10.1.6" />
</ItemGroup> </ItemGroup>
</Project> </Project>
+7 -1
View File
@@ -84,7 +84,13 @@ try
builder.Services.AddControllers(); builder.Services.AddControllers();
builder.Services.AddEndpointsApiExplorer(); builder.Services.AddEndpointsApiExplorer();
builder.Services.AddSwaggerGen(); builder.Services.AddSwaggerGen(options =>
{
var xmlFile = (Assembly.GetExecutingAssembly().GetName().Name ?? "rag-api") + ".xml";
var xmlPath = Path.Combine(AppContext.BaseDirectory, xmlFile);
if (File.Exists(xmlPath)) options.IncludeXmlComments(xmlPath);
options.EnableAnnotations();
});
var app = builder.Build(); var app = builder.Build();
+4
View File
@@ -5,6 +5,8 @@
<ImplicitUsings>enable</ImplicitUsings> <ImplicitUsings>enable</ImplicitUsings>
<DockerDefaultTargetOS>Linux</DockerDefaultTargetOS> <DockerDefaultTargetOS>Linux</DockerDefaultTargetOS>
<RootNamespace>Api</RootNamespace> <RootNamespace>Api</RootNamespace>
<GenerateDocumentationFile>true</GenerateDocumentationFile>
<NoWarn>$(NoWarn);1591</NoWarn>
</PropertyGroup> </PropertyGroup>
<ItemGroup> <ItemGroup>
<Content Remove="C:\Users\Gelu\.nuget\packages\microsoft.codeanalysis.workspaces.msbuild\4.14.0\contentFiles\any\any\BuildHost-net472\cs\System.CommandLine.resources.dll" /> <Content Remove="C:\Users\Gelu\.nuget\packages\microsoft.codeanalysis.workspaces.msbuild\4.14.0\contentFiles\any\any\BuildHost-net472\cs\System.CommandLine.resources.dll" />
@@ -70,5 +72,7 @@
<PackageReference Include="Serilog.Sinks.Console" Version="6.1.1" /> <PackageReference Include="Serilog.Sinks.Console" Version="6.1.1" />
<PackageReference Include="Serilog.Sinks.Email" Version="4.2.1" /> <PackageReference Include="Serilog.Sinks.Email" Version="4.2.1" />
<PackageReference Include="Swashbuckle.AspNetCore" Version="10.1.7" /> <PackageReference Include="Swashbuckle.AspNetCore" Version="10.1.7" />
<PackageReference Include="Swashbuckle.AspNetCore.Annotations" Version="6.5.0" />
<PackageReference Include="Refit.HttpClientFactory" Version="10.1.6" />
</ItemGroup> </ItemGroup>
</Project> </Project>