@@ -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 Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.AspNetCore.RateLimiting;
|
||||
using System.Net.Http.Headers;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using Swashbuckle.AspNetCore.Annotations;
|
||||
|
||||
namespace Api.Controllers;
|
||||
|
||||
/// <summary>
|
||||
/// Proxy endpoints for the CV matcher API. These endpoints forward requests to the internal cv-matcher-api.
|
||||
/// </summary>
|
||||
[ApiController]
|
||||
[Route("api/cv-matcher")]
|
||||
[EnableRateLimiting("cv-matcher")]
|
||||
public sealed class RagController : ControllerBase
|
||||
{
|
||||
private readonly IHttpClientFactory _httpClientFactory;
|
||||
private readonly Api.Clients.Api.Contracts.ICvMatcherApi _cvApi;
|
||||
private readonly IConfiguration _configuration;
|
||||
private readonly ILogger<RagController> _logger;
|
||||
|
||||
public RagController(
|
||||
IHttpClientFactory httpClientFactory,
|
||||
Api.Clients.Api.Contracts.ICvMatcherApi cvApi,
|
||||
IConfiguration configuration,
|
||||
ILogger<RagController> logger)
|
||||
{
|
||||
_httpClientFactory = httpClientFactory;
|
||||
_cvApi = cvApi;
|
||||
_configuration = configuration;
|
||||
_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")]
|
||||
[RequestSizeLimit(8 * 1024 * 1024)]
|
||||
[ProducesResponseType(StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(StatusCodes.Status400BadRequest)]
|
||||
[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(
|
||||
[FromForm(Name = "cv")] IFormFile? cv,
|
||||
[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}",
|
||||
cv.FileName, cv.Length, gdprConsent);
|
||||
|
||||
using var client = CreateCvMatcherClient(baseUrl);
|
||||
using var form = new MultipartFormDataContent();
|
||||
await using var stream = cv.OpenReadStream();
|
||||
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);
|
||||
var stream = cv.OpenReadStream();
|
||||
var part = new Refit.StreamPart(stream, cv.FileName, "application/pdf");
|
||||
using var response = await _cvApi.Upload(part, gdprConsent);
|
||||
return await ProxyResponseAsync(response, ct);
|
||||
}
|
||||
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")]
|
||||
[ProducesResponseType(StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(StatusCodes.Status400BadRequest)]
|
||||
[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)
|
||||
{
|
||||
var baseUrl = GetCvMatcherBaseUrl();
|
||||
@@ -96,13 +110,7 @@ public sealed class RagController : ControllerBase
|
||||
!string.IsNullOrWhiteSpace(request.JobUrl),
|
||||
!string.IsNullOrWhiteSpace(request.JobDescription));
|
||||
|
||||
using var client = CreateCvMatcherClient(baseUrl);
|
||||
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);
|
||||
|
||||
using var response = await _cvApi.MatchJob(request);
|
||||
return await ProxyResponseAsync(response, ct);
|
||||
}
|
||||
catch (OperationCanceledException) when (ct.IsCancellationRequested)
|
||||
@@ -119,19 +127,7 @@ public sealed class RagController : ControllerBase
|
||||
|
||||
private string GetCvMatcherBaseUrl() => _configuration["CvMatcherApi:BaseUrl"] ?? string.Empty;
|
||||
|
||||
private HttpClient CreateCvMatcherClient(string baseUrl)
|
||||
{
|
||||
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;
|
||||
}
|
||||
// Refit client is configured in Program.cs; this helper only reads config for diagnostics
|
||||
|
||||
private static async Task<ContentResult> ProxyResponseAsync(HttpResponseMessage response, CancellationToken ct)
|
||||
{
|
||||
|
||||
+26
-2
@@ -6,6 +6,7 @@ using Microsoft.AspNetCore.HttpOverrides;
|
||||
using Serilog;
|
||||
using System.Reflection;
|
||||
using System.Threading.RateLimiting;
|
||||
using Refit;
|
||||
|
||||
|
||||
// Load .env file if it exists (for local development)
|
||||
@@ -80,11 +81,34 @@ try
|
||||
builder.Services.AddHttpClient<ICaptchaVerifier, RecaptchaVerifier>();
|
||||
builder.Services.AddSingleton<IEmailSender, SmtpEmailSender>();
|
||||
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
|
||||
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
|
||||
builder.Services.Configure<ForwardedHeadersOptions>(options =>
|
||||
|
||||
@@ -11,6 +11,8 @@
|
||||
<DockerDefaultTargetOS>Linux</DockerDefaultTargetOS>
|
||||
<DisableStaticWebAssets>true</DisableStaticWebAssets>
|
||||
<RootNamespace>Api</RootNamespace>
|
||||
<GenerateDocumentationFile>true</GenerateDocumentationFile>
|
||||
<NoWarn>$(NoWarn);1591</NoWarn>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
@@ -24,6 +26,8 @@
|
||||
<PackageReference Include="Serilog.Sinks.Email" Version="4.2.1" />
|
||||
<PackageReference Include="Serilog.Sinks.File" Version="7.0.0" />
|
||||
<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>
|
||||
|
||||
Reference in New Issue
Block a user