Add complete XML doc and Swagger annotations to all controller endpoints

Every public action now has <summary>, <param>, and <returns> XML docs
plus matching SwaggerOperation/SwaggerResponse attributes with typed response
descriptions. Class-level summaries added to CvController, JobSearchController,
and RagController. Explanatory inline comments removed from FileDownloadController
per project conventions.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-05-22 20:47:47 +03:00
parent 6deb8dd4c8
commit 1fcf1e1470
7 changed files with 192 additions and 69 deletions
+13 -4
View File
@@ -29,8 +29,10 @@ namespace Api.Controllers
/// <summary>
/// Returns the public reCAPTCHA site key used by the client to render the widget.
/// </summary>
/// <returns>200 OK with the configured public site key as a plain string.</returns>
[HttpGet]
[SwaggerOperation(Summary = "Get captcha site key")]
[SwaggerOperation(Summary = "Get captcha public key", Description = "Returns the public reCAPTCHA site key required by the frontend to render the challenge widget.")]
[SwaggerResponse(StatusCodes.Status200OK, "Public site key returned")]
[ProducesResponseType(StatusCodes.Status200OK)]
public IActionResult GetSiteKey()
{
@@ -38,13 +40,20 @@ namespace Api.Controllers
}
/// <summary>
/// Verify a captcha token and return the verification verdict.
/// Verifies a reCAPTCHA token submitted by the client and returns the full verification verdict.
/// </summary>
/// <param name="req">The verification request containing the token and optional expected action name.</param>
/// <param name="ct">Cancellation token.</param>
/// <returns>
/// 200 OK with the full captcha verdict when verification passes;
/// 400 Bad Request with an <see cref="ErrorResponse"/> if the token is missing or verification fails.
/// </returns>
[HttpPost("verify")]
[SwaggerOperation(Summary = "Verify captcha token")]
[SwaggerOperation(Summary = "Verify captcha token", Description = "Verifies a reCAPTCHA token and returns the provider verdict including the score.")]
[SwaggerResponse(StatusCodes.Status200OK, "Token verified successfully")]
[SwaggerResponse(StatusCodes.Status400BadRequest, "Token missing or verification failed", typeof(ErrorResponse))]
[ProducesResponseType(StatusCodes.Status200OK)]
[ProducesResponseType(typeof(ErrorResponse), StatusCodes.Status400BadRequest)]
[SwaggerResponse(StatusCodes.Status400BadRequest, "Captcha verification failed or token missing", typeof(ErrorResponse))]
public async Task<IActionResult> Verify([FromBody] CaptchaVerifyRequest req, CancellationToken ct)
{
if (req is null || string.IsNullOrWhiteSpace(req.Token))
+33 -5
View File
@@ -48,10 +48,18 @@ public sealed class CvMatcherController : ControllerBase
}
/// <summary>
/// Upload a CV PDF to the cv-matcher-api.
/// Proxies a CV PDF upload to the internal cv-matcher-api for indexing.
/// Validates the reCAPTCHA token and GDPR consent before forwarding.
/// Caches the uploaded file locally so it can be attached to the match result email.
/// </summary>
/// <param name="request">The uploaded CV request.</param>
/// <param name="request">Multipart form containing the CV PDF, captcha token, and GDPR consent flag.</param>
/// <param name="ct">Cancellation token.</param>
/// <returns>
/// 200 OK with the document ID and cache status from cv-matcher-api;
/// 400 Bad Request if the file is missing or captcha verification fails;
/// 499 if the client cancelled the request;
/// 502 Bad Gateway if the upstream cv-matcher-api call fails.
/// </returns>
[HttpPost("upload")]
[RequestSizeLimit(8 * 1024 * 1024)]
[ProducesResponseType(StatusCodes.Status200OK)]
@@ -109,10 +117,18 @@ public sealed class CvMatcherController : ControllerBase
}
/// <summary>
/// Proxy a job matching request to the cv-matcher-api.
/// Proxies a CV-to-job match request to the internal cv-matcher-api.
/// Validates the reCAPTCHA token, then forwards the request and emails the scored result to the user.
/// When an email is provided, also creates a one-time job-search token and appends the search link to the email.
/// </summary>
/// <param name="request">Job match request payload containing CV document id or job description/url.</param>
/// <param name="request">Match request containing the CV document ID, a job URL or inline description, and an optional recipient email.</param>
/// <param name="ct">Cancellation token.</param>
/// <returns>
/// 200 OK with the <see cref="JobMatchResponse"/> score, strengths, and gaps;
/// 400 Bad Request if captcha verification fails;
/// 499 if the client cancelled the request;
/// 502 Bad Gateway if the upstream cv-matcher-api call fails.
/// </returns>
[HttpPost("match-job")]
[ProducesResponseType(StatusCodes.Status200OK)]
[ProducesResponseType(typeof(ErrorResponse), StatusCodes.Status400BadRequest)]
@@ -182,8 +198,20 @@ public sealed class CvMatcherController : ControllerBase
}
}
/// <summary>
/// Validates a one-time job-search token and kicks off the background job search.
/// Returns a self-contained HTML page intended to be opened directly in the browser via the link in the match email.
/// </summary>
/// <param name="t">The one-time UUID token from the job-search link query string.</param>
/// <param name="ct">Cancellation token.</param>
/// <returns>
/// 200 OK with an HTML page indicating whether the search was started, the token was already used, expired, or invalid.
/// Always returns 200 — error states are communicated via the HTML page content, not the HTTP status code.
/// </returns>
[HttpGet("job-search/start")]
[SwaggerOperation(Summary = "Start job search", Description = "Validates the one-time token and starts the background job search. Returns a simple HTML confirmation page.")]
[SwaggerOperation(Summary = "Start job search", Description = "Validates the one-time token and starts the background job search. Returns a self-contained HTML confirmation page.")]
[SwaggerResponse(StatusCodes.Status200OK, "HTML page returned for all token states (started, already used, expired, invalid)")]
[ProducesResponseType(StatusCodes.Status200OK)]
public async Task<IActionResult> StartJobSearch([FromQuery] string t, CancellationToken ct)
{
try
+2 -37
View File
@@ -66,7 +66,6 @@ namespace Api.Controllers
{
try
{
// Use default file name from settings if not provided
if (string.IsNullOrWhiteSpace(fileName))
{
fileName = _fileStorageSettings.DefaultFileName;
@@ -80,43 +79,30 @@ namespace Api.Controllers
_logger.LogInformation("Using default file name from settings: {FileName}", fileName);
}
// Get the file storage path (relative to solution folder)
var fileStoragePath = _fileStorageSettings.Path;
// If path is not absolute, make it relative to the solution root
if (!Path.IsPathRooted(fileStoragePath))
{
var solutionRoot = Directory.GetCurrentDirectory();
// Go up from api folder to solution root if needed
if (solutionRoot.EndsWith("api", StringComparison.OrdinalIgnoreCase))
{
solutionRoot = Directory.GetParent(solutionRoot)?.FullName ?? solutionRoot;
}
fileStoragePath = Path.Combine(solutionRoot, fileStoragePath);
}
// Sanitize fileName to prevent directory traversal attacks
var sanitizedFileName = Path.GetFileName(fileName);
var filePath = Path.Combine(fileStoragePath, sanitizedFileName);
// Verify file exists
if (!System.IO.File.Exists(filePath))
{
_logger.LogWarning("File not found: {FilePath}", filePath);
return NotFound(new ErrorResponse { Error = "File not found", Code = "file_not_found" });
}
var fileInfo = new FileInfo(filePath);
var fileLength = fileInfo.Length;
var fileLength = new FileInfo(filePath).Length;
// Determine content type
if (!_contentTypeProvider.TryGetContentType(filePath, out var contentType))
{
contentType = "application/octet-stream";
}
// Send email notification asynchronously (fire and forget with error handling)
// This is done before streaming to ensure notification is sent for both full and range downloads
var userIp = HttpContext.Connection.RemoteIpAddress?.ToString();
_ = Task.Run(async () =>
{
@@ -130,19 +116,13 @@ namespace Api.Controllers
}
});
// Check if this is a range request
var rangeHeader = Request.Headers[HeaderNames.Range].ToString();
if (!string.IsNullOrEmpty(rangeHeader))
{
return await HandleRangeRequest(filePath, fileLength, contentType, rangeHeader, sanitizedFileName);
}
// Full file download
_logger.LogInformation("Starting full file download: {FileName} ({FileSize} bytes)", sanitizedFileName, fileLength);
var stream = new FileStream(filePath, FileMode.Open, FileAccess.Read, FileShare.Read, BufferSize, useAsync: true);
Response.Headers.Append(HeaderNames.AcceptRanges, "bytes");
Response.Headers.Append(HeaderNames.ContentLength, fileLength.ToString());
@@ -167,34 +147,25 @@ namespace Api.Controllers
{
try
{
// Parse range header (format: "bytes=start-end")
var range = rangeHeader.Replace("bytes=", "").Split('-');
long startByte = 0;
long endByte = fileLength - 1;
if (!string.IsNullOrEmpty(range[0]))
{
startByte = long.Parse(range[0]);
}
if (range.Length > 1 && !string.IsNullOrEmpty(range[1]))
{
endByte = long.Parse(range[1]);
}
// Validate range
if (startByte > endByte || startByte >= fileLength)
{
_logger.LogWarning("Invalid range request: {Range} for file size {FileLength}", rangeHeader, fileLength);
return StatusCode(StatusCodes.Status416RangeNotSatisfiable);
}
// Adjust end byte if it exceeds file length
if (endByte >= fileLength)
{
endByte = fileLength - 1;
}
var contentLength = endByte - startByte + 1;
@@ -202,20 +173,16 @@ namespace Api.Controllers
"Range request for {FileName}: bytes {Start}-{End}/{Total} ({ContentLength} bytes)",
fileName, startByte, endByte, fileLength, contentLength);
// Open file stream and seek to start position
var stream = new FileStream(filePath, FileMode.Open, FileAccess.Read, FileShare.Read, BufferSize, useAsync: true);
stream.Seek(startByte, SeekOrigin.Begin);
// Set response headers for partial content
Response.StatusCode = StatusCodes.Status206PartialContent;
Response.Headers.Append(HeaderNames.AcceptRanges, "bytes");
Response.Headers.Append(HeaderNames.ContentRange, $"bytes {startByte}-{endByte}/{fileLength}");
Response.Headers.Append(HeaderNames.ContentLength, contentLength.ToString());
Response.ContentType = contentType;
// Stream the requested range
await StreamRangeAsync(stream, Response.Body, contentLength);
await stream.DisposeAsync();
return new EmptyResult();
@@ -241,9 +208,7 @@ namespace Api.Controllers
var bytesRead = await source.ReadAsync(buffer.AsMemory(0, bytesToReadThisIteration));
if (bytesRead == 0)
{
break; // End of stream
}
break;
await destination.WriteAsync(buffer.AsMemory(0, bytesRead));
totalBytesRead += bytesRead;
+4 -2
View File
@@ -17,9 +17,11 @@ namespace Api.Controllers
public sealed class HealthController : ControllerBase
{
/// <summary>
/// Returns the deployed API version.
/// Returns the deployed API version baked into the assembly at build time.
/// The version format is <c>1.0.0-build.{yyyyMMddHHmmss}</c> as defined in <c>api.csproj</c>.
/// Used by the web frontend to display the running build in the page footer.
/// </summary>
/// <returns>200 OK with JSON payload: { "version": "1.0.0-build.20250522103045" }</returns>
/// <returns>200 OK with JSON payload: <c>{ "version": "1.0.0-build.20250522103045" }</c>.</returns>
// GET api/health/version
[HttpGet("version")]
[SwaggerOperation(Summary = "API version", Description = "Returns the deployed API assembly version.")]