@@ -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);
|
||||||
|
}
|
||||||
@@ -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
@@ -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 =>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|
||||||
|
|||||||
@@ -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
@@ -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();
|
||||||
|
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
Reference in New Issue
Block a user