diff --git a/src/presentation/DigitalData.TaskFlow.API/Contracts/Workflows/CloseWorkflowRequest.cs b/src/presentation/DigitalData.TaskFlow.API/Contracts/Workflows/CloseWorkflowRequest.cs new file mode 100644 index 0000000..59ea71d --- /dev/null +++ b/src/presentation/DigitalData.TaskFlow.API/Contracts/Workflows/CloseWorkflowRequest.cs @@ -0,0 +1,22 @@ +namespace DigitalData.TaskFlow.API.Contracts.Workflows; + +/// +/// Represents the request payload for closing a workflow. +/// +public sealed class CloseWorkflowRequest +{ + /// + /// Gets or sets the selected closing method identifier. + /// + public required int ClosingMethodId { get; init; } + + /// + /// Gets or sets an optional user comment. + /// + public string? Comment { get; init; } + + /// + /// Gets or sets an optional rejection reason identifier. + /// + public int? RejectionReasonId { get; init; } +} diff --git a/src/presentation/DigitalData.TaskFlow.API/Controllers/DocumentsController.cs b/src/presentation/DigitalData.TaskFlow.API/Controllers/DocumentsController.cs new file mode 100644 index 0000000..df545bb --- /dev/null +++ b/src/presentation/DigitalData.TaskFlow.API/Controllers/DocumentsController.cs @@ -0,0 +1,38 @@ +using DigitalData.TaskFlow.Application.Features.Documents.Queries.GetDocument; +using DigitalData.TaskFlow.Domain.Common; +using DigitalData.TaskFlow.Domain.Documents; +using MediatR; +using Microsoft.AspNetCore.Mvc; + +namespace DigitalData.TaskFlow.API.Controllers; + +/// +/// Provides document binary download endpoints. +/// +[ApiController] +[Route("api/documents")] +public sealed class DocumentsController(ISender sender) : ControllerBase +{ + /// + /// Returns binary document content by document identifier. + /// + /// Document identifier. + /// Cancellation token. + /// Binary stream when available. + [HttpGet("{id}")] + [ProducesResponseType(typeof(FileContentResult), StatusCodes.Status200OK)] + [ProducesResponseType(typeof(ApiEnvelope), StatusCodes.Status400BadRequest)] + [ProducesResponseType(typeof(ApiEnvelope), StatusCodes.Status500InternalServerError)] + public async Task GetAsync([FromRoute] string id, CancellationToken cancellationToken) + { + var response = await sender.Send(new GetDocumentQuery(id), cancellationToken); + var data = response.Data; + + if (data is null) + { + return NotFound(response); + } + + return File(data.Content, data.ContentType, data.FileName); + } +} diff --git a/src/presentation/DigitalData.TaskFlow.API/Controllers/WorkflowsController.cs b/src/presentation/DigitalData.TaskFlow.API/Controllers/WorkflowsController.cs new file mode 100644 index 0000000..c2f4f42 --- /dev/null +++ b/src/presentation/DigitalData.TaskFlow.API/Controllers/WorkflowsController.cs @@ -0,0 +1,68 @@ +using DigitalData.TaskFlow.API.Contracts.Workflows; +using DigitalData.TaskFlow.Application.Features.Workflows.Commands.CloseWorkflow; +using DigitalData.TaskFlow.Application.Features.Workflows.Queries.GetWorkflows; +using DigitalData.TaskFlow.Domain.Closing; +using DigitalData.TaskFlow.Domain.Common; +using DigitalData.TaskFlow.Domain.Workflows; +using MediatR; +using Microsoft.AspNetCore.Mvc; + +namespace DigitalData.TaskFlow.API.Controllers; + +/// +/// Provides workflow read and close endpoints. +/// +[ApiController] +[Route("api/workflows")] +public sealed class WorkflowsController(ISender sender) : ControllerBase +{ + /// + /// Returns open workflows for a WISAG user identifier. + /// + /// WISAG user identifier. + /// Cancellation token. + /// Workflow response envelope grouped by source. + [HttpGet("{wisagId:int}")] + [ProducesResponseType(typeof(ApiEnvelope), StatusCodes.Status200OK)] + [ProducesResponseType(typeof(ApiEnvelope), StatusCodes.Status400BadRequest)] + [ProducesResponseType(typeof(ApiEnvelope), StatusCodes.Status500InternalServerError)] + public async Task>> GetAsync( + [FromRoute] int wisagId, + CancellationToken cancellationToken) + { + var response = await sender.Send(new GetWorkflowsQuery(wisagId), cancellationToken); + return Ok(response); + } + + /// + /// Applies a closing decision to a workflow process. + /// + /// WISAG user identifier. + /// Source system name. + /// Process identifier. + /// Closing request payload. + /// Cancellation token. + /// Close result envelope. + [HttpPost("{wisagId:int}/{source}/{processId:int}/closing")] + [ProducesResponseType(typeof(ApiEnvelope), StatusCodes.Status200OK)] + [ProducesResponseType(typeof(ApiEnvelope), StatusCodes.Status400BadRequest)] + [ProducesResponseType(typeof(ApiEnvelope), StatusCodes.Status500InternalServerError)] + public async Task>> CloseAsync( + [FromRoute] int wisagId, + [FromRoute] string source, + [FromRoute] int processId, + [FromBody] CloseWorkflowRequest request, + CancellationToken cancellationToken) + { + var command = new CloseWorkflowCommand( + wisagId, + source, + processId, + request.ClosingMethodId, + request.Comment, + request.RejectionReasonId); + + var response = await sender.Send(command, cancellationToken); + return Ok(response); + } +} diff --git a/src/presentation/DigitalData.TaskFlow.API/DigitalData.TaskFlow.API.csproj b/src/presentation/DigitalData.TaskFlow.API/DigitalData.TaskFlow.API.csproj index 38ffb2a..89923ca 100644 --- a/src/presentation/DigitalData.TaskFlow.API/DigitalData.TaskFlow.API.csproj +++ b/src/presentation/DigitalData.TaskFlow.API/DigitalData.TaskFlow.API.csproj @@ -8,6 +8,8 @@ + + diff --git a/src/presentation/DigitalData.TaskFlow.API/Middleware/ExceptionHandlingMiddleware.cs b/src/presentation/DigitalData.TaskFlow.API/Middleware/ExceptionHandlingMiddleware.cs new file mode 100644 index 0000000..ca56f87 --- /dev/null +++ b/src/presentation/DigitalData.TaskFlow.API/Middleware/ExceptionHandlingMiddleware.cs @@ -0,0 +1,60 @@ +using DigitalData.TaskFlow.Application.Common.Factories; +using DigitalData.TaskFlow.Domain.Common; +using DigitalData.TaskFlow.Domain.Enums; +using FluentValidation; +using System.Text.Json; + +namespace DigitalData.TaskFlow.API.Middleware; + +/// +/// Handles exceptions globally and returns ticket-compatible API envelopes. +/// +public sealed class ExceptionHandlingMiddleware(RequestDelegate next, ILogger logger) +{ + private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web); + + /// + /// Executes the middleware. + /// + /// HTTP context. + public async Task InvokeAsync(HttpContext context) + { + try + { + await next(context); + } + catch (ValidationException validationException) + { + logger.LogWarning(validationException, "Validation failed."); + + context.Response.StatusCode = StatusCodes.Status400BadRequest; + context.Response.ContentType = "application/json"; + + var message = validationException.Errors.FirstOrDefault()?.ErrorMessage + ?? "Ungultiger Request"; + + var response = ApiEnvelopeFactory.Create(null, MetaCode.InvalidRequest, message); + await context.Response.WriteAsync(JsonSerializer.Serialize(response, JsonOptions)); + } + catch (NotSupportedException notSupportedException) + { + logger.LogWarning(notSupportedException, "Operation not supported yet."); + + context.Response.StatusCode = StatusCodes.Status500InternalServerError; + context.Response.ContentType = "application/json"; + + var response = ApiEnvelopeFactory.Create(null, MetaCode.InternalError, notSupportedException.Message); + await context.Response.WriteAsync(JsonSerializer.Serialize(response, JsonOptions)); + } + catch (Exception exception) + { + logger.LogError(exception, "Unhandled exception."); + + context.Response.StatusCode = StatusCodes.Status500InternalServerError; + context.Response.ContentType = "application/json"; + + var response = ApiEnvelopeFactory.Create(null, MetaCode.InternalError); + await context.Response.WriteAsync(JsonSerializer.Serialize(response, JsonOptions)); + } + } +} diff --git a/src/presentation/DigitalData.TaskFlow.API/Program.cs b/src/presentation/DigitalData.TaskFlow.API/Program.cs index 6749ecf..16690df 100644 --- a/src/presentation/DigitalData.TaskFlow.API/Program.cs +++ b/src/presentation/DigitalData.TaskFlow.API/Program.cs @@ -1,5 +1,9 @@ using DigitalData.TaskFlow.Application; +using DigitalData.TaskFlow.API.Middleware; +using DigitalData.TaskFlow.API.Options; using DigitalData.TaskFlow.Infrastructure; +using Microsoft.OpenApi; +using Scalar.AspNetCore; var builder = WebApplication.CreateBuilder(args); var config = builder.Configuration; @@ -11,15 +15,46 @@ builder.Services.AddInfrastructureServices(config); builder.Services.AddControllers(); // Learn more about configuring Swagger/OpenAPI at https://aka.ms/aspnetcore/swashbuckle builder.Services.AddEndpointsApiExplorer(); -builder.Services.AddSwaggerGen(); +builder.Services.AddSwaggerGen(options => +{ + options.SwaggerDoc("v1", new OpenApiInfo + { + Title = "DigitalData TaskFlow API", + Version = "v1", + Description = "Integration API for taskFLOW workflow and document operations.", + Contact = new OpenApiContact + { + Name = "Digital Data GmbH", + Url = new Uri("https://digitaldata.works/"), + Email = "info-flow@digitaldata.works" + } + }); +}); + +builder.Services + .AddOptions() + .Bind(config.GetSection(SwaggerOptions.SectionName)); var app = builder.Build(); +var swaggerOptions = app.Services + .GetRequiredService>() + .Value; + +var enableSwagger = app.Environment.IsDevelopment() || swaggerOptions.EnabledInProduction; + // Configure the HTTP request pipeline. -if (app.Environment.IsDevelopment()) +app.UseMiddleware(); + +if (enableSwagger) { app.UseSwagger(); app.UseSwaggerUI(); + app.MapScalarApiReference(options => + { + options.Title = "DigitalData TaskFlow API"; + options.Theme = ScalarTheme.BluePlanet; + }); } app.UseHttpsRedirection();