using Api.Clients.Api.Contracts; using Api.Models.Requests; using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.RateLimiting; using Swashbuckle.AspNetCore.Annotations; namespace Api.Controllers; /// /// Proxy endpoints for the CV matcher API. These endpoints forward requests to the internal cv-matcher-api. /// [ApiController] [Route("api/cv-matcher")] [EnableRateLimiting("cv-matcher")] public sealed class CvMatcherController : ControllerBase { private readonly ICvMatcherApi _cvApi; private readonly IConfiguration _configuration; private readonly ILogger _logger; public CvMatcherController( ICvMatcherApi cvApi, IConfiguration configuration, ILogger logger) { _cvApi = cvApi; _configuration = configuration; _logger = logger; } /// /// Upload a CV PDF to the cv-matcher-api. /// /// The uploaded CV PDF file. /// Whether the user consented to GDPR processing. /// Cancellation token. [HttpPost("upload")] [Consumes("multipart/form-data")] [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 UploadCv( [FromForm] UploadCvRequest request, CancellationToken ct) { if (request.Cv is null) { return BadRequest(new { error = "Missing CV PDF." }); } var cv = request.Cv; var gdprConsent = request.GdprConsent; try { _logger.LogInformation("Proxying CV upload to cv-matcher-api. FileName={FileName}, Size={SizeBytes}, GdprConsent={GdprConsent}", cv.FileName, cv.Length, gdprConsent); 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) { _logger.LogWarning("CV upload proxy request was cancelled by the client."); return StatusCode(499, new { error = "Request cancelled." }); } catch (Exception ex) { _logger.LogError(ex, "CV upload proxy request failed."); return StatusCode(StatusCodes.Status502BadGateway, new { error = "CV matcher API request failed." }); } } /// /// Proxy a job matching request to the cv-matcher-api. /// /// Job match request payload containing CV document id or job description/url. /// Cancellation token. [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 MatchJob([FromBody] JobMatchRequest request, CancellationToken ct) { try { _logger.LogInformation("Proxying job match request to cv-matcher-api. CvDocumentId={CvDocumentId}, HasJobUrl={HasJobUrl}, HasJobDescription={HasJobDescription}", request.CvDocumentId, !string.IsNullOrWhiteSpace(request.JobUrl), !string.IsNullOrWhiteSpace(request.JobDescription)); using var response = await _cvApi.MatchJob(request); return await ProxyResponseAsync(response, ct); } catch (OperationCanceledException) when (ct.IsCancellationRequested) { _logger.LogWarning("Job match proxy request was cancelled by the client."); return StatusCode(499, new { error = "Request cancelled." }); } catch (Exception ex) { _logger.LogError(ex, "Job match proxy request failed."); return StatusCode(StatusCodes.Status502BadGateway, new { error = "CV matcher API request failed." }); } } // Refit client is configured in Program.cs; this helper only reads config for diagnostics private static async Task ProxyResponseAsync(HttpResponseMessage response, CancellationToken ct) { var body = await response.Content.ReadAsStringAsync(ct); return new ContentResult { StatusCode = (int)response.StatusCode, Content = body, ContentType = response.Content.Headers.ContentType?.ToString() ?? "application/json" }; } }