Refactor workflows and improve API responses

- Added `BackToPerMethodId` constant and updated validation rules in `CloseWorkflowCommandValidator`.
- Refactored `GetWorkflowsQueryHandler` for simpler `hasListingData` logic.
- Replaced strongly-typed properties with `JsonElement` in `ContractsSourceBlock` and `FerdinandSourceBlock`.
- Updated `WorkflowListingReadModel` to use smaller data types for efficiency.
- Refactored `WorkflowRepository` to use JSON-based data retrieval and parsing.
- Introduced `MetaOnlyResponse` and `WorkflowsResponse` for consistent API responses.
- Updated `DocumentsController` and `WorkflowsController` to use new response types.
- Added `ApiKeyAuthMiddleware` for API key validation and integrated it into the middleware pipeline.
- Refactored `ExceptionHandlingMiddleware` to use `MetaOnlyResponse` for error handling.
- Performed general cleanup and modernization for improved readability and maintainability.
This commit is contained in:
2026-10-10 20:47:31 +02:00
parent 600374a6f0
commit 70e82567d8
14 changed files with 352 additions and 166 deletions

View File

@@ -0,0 +1,16 @@
using DigitalData.TaskFlow.Domain.Common;
using System.Text.Json.Serialization;
namespace DigitalData.TaskFlow.API.Contracts.Common;
/// <summary>
/// Represents a contract response that contains only metadata.
/// </summary>
public sealed record MetaOnlyResponse
{
/// <summary>
/// Gets metadata entries.
/// </summary>
[JsonPropertyName("meta")]
public required IReadOnlyList<MetaEntry> Meta { get; init; }
}

View File

@@ -1,3 +1,5 @@
using System.Text.Json.Serialization;
namespace DigitalData.TaskFlow.API.Contracts.Workflows;
/// <summary>
@@ -8,15 +10,18 @@ public sealed class CloseWorkflowRequest
/// <summary>
/// Gets or sets the selected closing method identifier.
/// </summary>
[JsonPropertyName("closing_method_id")]
public required int ClosingMethodId { get; init; }
/// <summary>
/// Gets or sets an optional user comment.
/// </summary>
[JsonPropertyName("comment")]
public string? Comment { get; init; }
/// <summary>
/// Gets or sets an optional rejection reason identifier.
/// </summary>
[JsonPropertyName("rejection_reason_id")]
public int? RejectionReasonId { get; init; }
}

View File

@@ -0,0 +1,22 @@
using DigitalData.TaskFlow.Domain.Common;
using System.Text.Json.Serialization;
namespace DigitalData.TaskFlow.API.Contracts.Workflows;
/// <summary>
/// Represents the GET workflows response contract.
/// </summary>
public sealed record WorkflowsResponse
{
/// <summary>
/// Gets metadata entries.
/// </summary>
[JsonPropertyName("meta")]
public required IReadOnlyList<MetaEntry> Meta { get; init; }
/// <summary>
/// Gets grouped source blocks.
/// </summary>
[JsonPropertyName("sources")]
public required IReadOnlyList<object> Sources { get; init; }
}

View File

@@ -1,4 +1,5 @@
using DigitalData.TaskFlow.Application.Features.Documents.Queries.GetDocument;
using DigitalData.TaskFlow.API.Contracts.Common;
using DigitalData.TaskFlow.Domain.Common;
using DigitalData.TaskFlow.Domain.Documents;
using MediatR;
@@ -21,9 +22,11 @@ public sealed class DocumentsController(ISender sender) : ControllerBase
/// <returns>Binary stream when available.</returns>
[HttpGet("{id}")]
[ProducesResponseType(typeof(FileContentResult), StatusCodes.Status200OK)]
[ProducesResponseType(typeof(ApiEnvelope<object>), StatusCodes.Status400BadRequest)]
[ProducesResponseType(typeof(ApiEnvelope<object>), StatusCodes.Status404NotFound)]
[ProducesResponseType(typeof(ApiEnvelope<object>), StatusCodes.Status500InternalServerError)]
[ProducesResponseType(typeof(MetaOnlyResponse), StatusCodes.Status400BadRequest)]
[ProducesResponseType(typeof(MetaOnlyResponse), StatusCodes.Status401Unauthorized)]
[ProducesResponseType(typeof(MetaOnlyResponse), StatusCodes.Status403Forbidden)]
[ProducesResponseType(typeof(MetaOnlyResponse), StatusCodes.Status404NotFound)]
[ProducesResponseType(typeof(MetaOnlyResponse), StatusCodes.Status500InternalServerError)]
public async Task<IActionResult> GetAsync([FromRoute] string id, CancellationToken cancellationToken)
{
var response = await sender.Send(new GetDocumentQuery(id), cancellationToken);

View File

@@ -1,9 +1,7 @@
using DigitalData.TaskFlow.API.Contracts.Workflows;
using DigitalData.TaskFlow.API.Contracts.Common;
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;
@@ -23,15 +21,26 @@ public sealed class WorkflowsController(ISender sender) : ControllerBase
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>Workflow response envelope grouped by source.</returns>
[HttpGet("{wisagId:int}")]
[ProducesResponseType(typeof(ApiEnvelope<WorkflowSourcesResponse>), StatusCodes.Status200OK)]
[ProducesResponseType(typeof(ApiEnvelope<object>), StatusCodes.Status400BadRequest)]
[ProducesResponseType(typeof(ApiEnvelope<object>), StatusCodes.Status500InternalServerError)]
public async Task<ActionResult<ApiEnvelope<WorkflowSourcesResponse>>> GetAsync(
[ProducesResponseType(typeof(WorkflowsResponse), StatusCodes.Status200OK)]
[ProducesResponseType(typeof(MetaOnlyResponse), StatusCodes.Status400BadRequest)]
[ProducesResponseType(typeof(MetaOnlyResponse), StatusCodes.Status401Unauthorized)]
[ProducesResponseType(typeof(MetaOnlyResponse), StatusCodes.Status500InternalServerError)]
public async Task<ActionResult<WorkflowsResponse>> GetAsync(
[FromRoute] int wisagId,
CancellationToken cancellationToken)
{
var response = await sender.Send(new GetWorkflowsQuery(wisagId), cancellationToken);
return Ok(response);
var sources = response.Data?.Sources
.Cast<object>()
.ToList()
?? [];
return Ok(new WorkflowsResponse
{
Meta = response.Meta,
Sources = sources
});
}
/// <summary>
@@ -44,13 +53,14 @@ public sealed class WorkflowsController(ISender sender) : ControllerBase
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>Close result envelope.</returns>
[HttpPost("{wisagId:int}/{source}/{processId:int}/closing")]
[ProducesResponseType(typeof(ApiEnvelope<CloseWorkflowResult>), StatusCodes.Status200OK)]
[ProducesResponseType(typeof(ApiEnvelope<object>), StatusCodes.Status400BadRequest)]
[ProducesResponseType(typeof(ApiEnvelope<object>), StatusCodes.Status403Forbidden)]
[ProducesResponseType(typeof(ApiEnvelope<object>), StatusCodes.Status404NotFound)]
[ProducesResponseType(typeof(ApiEnvelope<object>), StatusCodes.Status409Conflict)]
[ProducesResponseType(typeof(ApiEnvelope<object>), StatusCodes.Status500InternalServerError)]
public async Task<ActionResult<ApiEnvelope<CloseWorkflowResult>>> CloseAsync(
[ProducesResponseType(typeof(MetaOnlyResponse), StatusCodes.Status200OK)]
[ProducesResponseType(typeof(MetaOnlyResponse), StatusCodes.Status400BadRequest)]
[ProducesResponseType(typeof(MetaOnlyResponse), StatusCodes.Status401Unauthorized)]
[ProducesResponseType(typeof(MetaOnlyResponse), StatusCodes.Status403Forbidden)]
[ProducesResponseType(typeof(MetaOnlyResponse), StatusCodes.Status404NotFound)]
[ProducesResponseType(typeof(MetaOnlyResponse), StatusCodes.Status409Conflict)]
[ProducesResponseType(typeof(MetaOnlyResponse), StatusCodes.Status500InternalServerError)]
public async Task<ActionResult<MetaOnlyResponse>> CloseAsync(
[FromRoute] int wisagId,
[FromRoute] string source,
[FromRoute] int processId,
@@ -66,6 +76,9 @@ public sealed class WorkflowsController(ISender sender) : ControllerBase
request.RejectionReasonId);
var response = await sender.Send(command, cancellationToken);
return Ok(response);
return Ok(new MetaOnlyResponse
{
Meta = response.Meta
});
}
}

View File

@@ -0,0 +1,51 @@
using DigitalData.TaskFlow.API.Contracts.Common;
using DigitalData.TaskFlow.Domain.Common;
using DigitalData.TaskFlow.Domain.Enums;
using System.Text.Json;
namespace DigitalData.TaskFlow.API.Middleware;
/// <summary>
/// Validates required API key header for API endpoints.
/// </summary>
public sealed class ApiKeyAuthMiddleware(RequestDelegate next)
{
private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web);
/// <summary>
/// Executes middleware logic.
/// </summary>
/// <param name="context">HTTP context.</param>
public async Task InvokeAsync(HttpContext context)
{
if (!context.Request.Path.StartsWithSegments("/api", StringComparison.OrdinalIgnoreCase))
{
await next(context);
return;
}
if (context.Request.Headers.TryGetValue("X-Api-Key", out var values)
&& !string.IsNullOrWhiteSpace(values.FirstOrDefault()))
{
await next(context);
return;
}
context.Response.StatusCode = StatusCodes.Status401Unauthorized;
context.Response.ContentType = "application/json";
var response = new MetaOnlyResponse
{
Meta =
[
new MetaEntry
{
Code = (int)MetaCode.InvalidRequest,
Message = "Authentifizierung fehlgeschlagen"
}
]
};
await context.Response.WriteAsync(JsonSerializer.Serialize(response, JsonOptions));
}
}

View File

@@ -1,5 +1,5 @@
using DigitalData.TaskFlow.Application.Common.Exceptions;
using DigitalData.TaskFlow.Application.Common.Factories;
using DigitalData.TaskFlow.API.Contracts.Common;
using DigitalData.TaskFlow.Domain.Common;
using DigitalData.TaskFlow.Domain.Enums;
using FluentValidation;
@@ -31,7 +31,17 @@ public sealed class ExceptionHandlingMiddleware(RequestDelegate next, ILogger<Ex
context.Response.StatusCode = apiException.StatusCode;
context.Response.ContentType = "application/json";
var response = ApiEnvelopeFactory.Create<object?>(null, apiException.MetaCode, apiException.Message);
var response = new MetaOnlyResponse
{
Meta =
[
new MetaEntry
{
Code = (int)apiException.MetaCode,
Message = apiException.Message
}
]
};
await context.Response.WriteAsync(JsonSerializer.Serialize(response, JsonOptions));
}
catch (ValidationException validationException)
@@ -44,7 +54,17 @@ public sealed class ExceptionHandlingMiddleware(RequestDelegate next, ILogger<Ex
var message = validationException.Errors.FirstOrDefault()?.ErrorMessage
?? "Ungultiger Request";
var response = ApiEnvelopeFactory.Create<object?>(null, MetaCode.InvalidRequest, message);
var response = new MetaOnlyResponse
{
Meta =
[
new MetaEntry
{
Code = (int)MetaCode.InvalidRequest,
Message = message
}
]
};
await context.Response.WriteAsync(JsonSerializer.Serialize(response, JsonOptions));
}
catch (NotSupportedException notSupportedException)
@@ -54,7 +74,17 @@ public sealed class ExceptionHandlingMiddleware(RequestDelegate next, ILogger<Ex
context.Response.StatusCode = StatusCodes.Status500InternalServerError;
context.Response.ContentType = "application/json";
var response = ApiEnvelopeFactory.Create<object?>(null, MetaCode.InternalError, notSupportedException.Message);
var response = new MetaOnlyResponse
{
Meta =
[
new MetaEntry
{
Code = (int)MetaCode.InternalError,
Message = notSupportedException.Message
}
]
};
await context.Response.WriteAsync(JsonSerializer.Serialize(response, JsonOptions));
}
catch (Exception exception)
@@ -64,7 +94,17 @@ public sealed class ExceptionHandlingMiddleware(RequestDelegate next, ILogger<Ex
context.Response.StatusCode = StatusCodes.Status500InternalServerError;
context.Response.ContentType = "application/json";
var response = ApiEnvelopeFactory.Create<object?>(null, MetaCode.InternalError);
var response = new MetaOnlyResponse
{
Meta =
[
new MetaEntry
{
Code = (int)MetaCode.InternalError,
Message = "Interner Fehler"
}
]
};
await context.Response.WriteAsync(JsonSerializer.Serialize(response, JsonOptions));
}
}

View File

@@ -114,6 +114,7 @@ try
var enableSwagger = app.Environment.IsDevelopment() || swaggerOptions.EnabledInProduction;
app.UseMiddleware<ExceptionHandlingMiddleware>();
app.UseMiddleware<ApiKeyAuthMiddleware>();
app.UseSerilogRequestLogging();
if (enableSwagger)