refactor(api): update document endpoints and middleware contracts

This commit is contained in:
2026-10-31 17:09:33 +01:00
parent 9b60f5116b
commit 893b063ae9
13 changed files with 146 additions and 321 deletions

View File

@@ -1,16 +0,0 @@
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,27 +0,0 @@
using System.Text.Json.Serialization;
namespace DigitalData.TaskFlow.API.Contracts.Workflows;
/// <summary>
/// Represents the request payload for closing a workflow.
/// </summary>
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

@@ -1,22 +0,0 @@
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

@@ -0,0 +1,33 @@
using DigitalData.TaskFlow.Application.Features.Documents.Queries;
using MediatR;
using Microsoft.AspNetCore.Mvc;
using System.Text.Json;
namespace DigitalData.TaskFlow.API.Controllers;
/// <summary>
/// Provides workflow read and close endpoints.
/// </summary>
[ApiController]
[Route("api/documents")]
public sealed class DocumentController(ISender sender) : ControllerBase
{
/// <summary>
/// Returns open documents for a WISAG user identifier.
/// </summary>
/// <param name="id">Document identifier.</param>
/// <param name="userId">User identifier.</param>
/// <param name="processId">Process identifier.</param>
/// <param name="asByte">Return document as byte array.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>Document response envelope grouped by source.</returns>
[HttpGet("{id:int}")]
[ProducesResponseType(typeof(JsonElement), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
[ProducesResponseType(StatusCodes.Status502BadGateway)]
[ProducesResponseType(StatusCodes.Status500InternalServerError)]
public async Task<ActionResult<JsonElement>> GetAsync([FromRoute] int id, [FromQuery] int userId, [FromQuery] long processId, CancellationToken cancellationToken = default)
{
return Ok(await sender.Send(new GetDocumentQuery(Id: id, UserId: userId, ProcessId: processId), cancellationToken));
}
}

View File

@@ -1,36 +0,0 @@
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;
using Microsoft.AspNetCore.Mvc;
namespace DigitalData.TaskFlow.API.Controllers;
/// <summary>
/// Provides document binary download endpoints.
/// </summary>
[ApiController]
[Route("api/documents")]
public sealed class DocumentsController(ISender sender) : ControllerBase
{
/// <summary>
/// Returns binary document content by document identifier.
/// </summary>
/// <param name="id">Document identifier.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>Binary stream when available.</returns>
[HttpGet("{id}")]
[ProducesResponseType(typeof(FileContentResult), 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.Status500InternalServerError)]
public async Task<IActionResult> GetAsync([FromRoute] string id, CancellationToken cancellationToken)
{
var response = await sender.Send(new GetDocumentQuery(id), cancellationToken);
var data = response.Data ?? throw new InvalidOperationException("Document payload is unexpectedly null.");
return File(data.Content, data.ContentType, data.FileName);
}
}

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.Application.Features.Workflows.Queries;
using MediatR;
using Microsoft.AspNetCore.Mvc;
using System.Text.Json;
namespace DigitalData.TaskFlow.API.Controllers;
@@ -21,64 +19,12 @@ 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(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)
[ProducesResponseType(typeof(JsonElement), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
[ProducesResponseType(StatusCodes.Status502BadGateway)]
[ProducesResponseType(StatusCodes.Status500InternalServerError)]
public async Task<ActionResult<JsonElement>> GetAsync([FromRoute] int wisagId, CancellationToken cancellationToken)
{
var response = await sender.Send(new GetWorkflowsQuery(wisagId), cancellationToken);
var sources = response.Data?.Sources
.Cast<object>()
.ToList()
?? [];
return Ok(new WorkflowsResponse
{
Meta = response.Meta,
Sources = sources
});
}
/// <summary>
/// Applies a closing decision to a workflow process.
/// </summary>
/// <param name="wisagId">WISAG user identifier.</param>
/// <param name="source">Source system name.</param>
/// <param name="processId">Process identifier.</param>
/// <param name="request">Closing request payload.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>Close result envelope.</returns>
[HttpPost("{wisagId:int}/{source}/{processId:int}/closing")]
[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,
[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(new MetaOnlyResponse
{
Meta = response.Meta
});
return Ok(await sender.Send(new GetWorkflowsQuery(wisagId), cancellationToken));
}
}

View File

@@ -1,4 +1,4 @@
<Project Sdk="Microsoft.NET.Sdk.Web">
<Project Sdk="Microsoft.NET.Sdk.Web">
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
@@ -31,16 +31,12 @@
</ItemGroup>
<ItemGroup>
<Folder Include="Controllers\" />
<None Include="Properties\PublishProfiles\IISProfileNet8.pubxml.user" />
</ItemGroup>
<ItemGroup>
<None Include="Properties\PublishProfiles\IISProfileNet8.pubxml.user" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\..\core\DigitalData.TaskFlow.Application\DigitalData.TaskFlow.Application.csproj" />
<ProjectReference Include="..\..\infrastructure\DigitalData.TaskFlow.Infrastructure\DigitalData.TaskFlow.Infrastructure.csproj" />
<ProjectReference Include="..\..\core\DigitalData.TaskFlow.Application\DigitalData.TaskFlow.Application.csproj" />
<ProjectReference Include="..\..\infrastructure\DigitalData.TaskFlow.Infrastructure\DigitalData.TaskFlow.Infrastructure.csproj" />
</ItemGroup>
</Project>

View File

@@ -1,54 +1,55 @@
using DigitalData.TaskFlow.API.Contracts.Common;
using DigitalData.TaskFlow.API.Options;
using DigitalData.TaskFlow.Domain.Common;
using DigitalData.TaskFlow.Domain.Enums;
using Microsoft.Extensions.Options;
using System.Text.Json;
namespace DigitalData.TaskFlow.API.Middleware;
/// <summary>
/// Validates required API key header for API endpoints.
/// Enforces API key authentication for all requests under the /api path prefix.
/// Expects the key to be supplied via the <c>X-Api-Key</c> request header.
/// </summary>
public sealed class ApiKeyAuthMiddleware(RequestDelegate next, IOptions<ApiKeyAuthOptions> apiKeyOptions)
public sealed class ApiKeyAuthMiddleware(RequestDelegate next, IOptions<ApiKeyAuthOptions> options)
{
private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web);
private readonly string? _configuredApiKey = apiKeyOptions.Value.Key;
/// <summary>
/// Executes middleware logic.
/// </summary>
/// <param name="context">HTTP context.</param>
// Snapshot at startup; options are not expected to change at runtime.
private readonly string? _configuredKey = options.Value.Key;
/// <inheritdoc />
public async Task InvokeAsync(HttpContext context)
{
// Only guard /api/** routes; let non-API paths pass through (e.g. health, swagger).
if (!context.Request.Path.StartsWithSegments("/api", StringComparison.OrdinalIgnoreCase))
{
await next(context);
return;
}
if (context.Request.Headers.TryGetValue("X-Api-Key", out var values)
&& string.Equals(values.FirstOrDefault(), _configuredApiKey, StringComparison.Ordinal))
// Treat a missing or empty configured key as a misconfiguration — deny all traffic.
if (!string.IsNullOrWhiteSpace(_configuredKey)
&& context.Request.Headers.TryGetValue("X-Api-Key", out var provided)
&& string.Equals(provided, _configuredKey, StringComparison.Ordinal))
{
await next(context);
return;
}
context.Response.StatusCode = StatusCodes.Status401Unauthorized;
context.Response.ContentType = "application/json";
await WriteUnauthorizedAsync(context);
}
var response = new MetaOnlyResponse
private static Task WriteUnauthorizedAsync(HttpContext context)
{
context.Response.StatusCode = StatusCodes.Status401Unauthorized;
context.Response.ContentType = "application/problem+json";
var body = new
{
Meta =
[
new MetaEntry
{
Code = (int)MetaCode.InvalidRequest,
Message = "Authentifizierung fehlgeschlagen"
}
]
type = "https://tools.ietf.org/html/rfc7807",
title = "Unauthorized",
status = StatusCodes.Status401Unauthorized,
detail = "A valid API key must be supplied via the X-Api-Key header."
};
await context.Response.WriteAsync(JsonSerializer.Serialize(response, JsonOptions));
return context.Response.WriteAsync(JsonSerializer.Serialize(body, JsonOptions));
}
}

View File

@@ -1,110 +1,82 @@
using DigitalData.TaskFlow.Application.Common.Exceptions;
using DigitalData.TaskFlow.API.Contracts.Common;
using DigitalData.TaskFlow.Domain.Common;
using DigitalData.TaskFlow.Domain.Enums;
using FluentValidation;
using System.Text.Json;
namespace DigitalData.TaskFlow.API.Middleware;
/// <summary>
/// Handles exceptions globally and returns ticket-compatible API envelopes.
/// Handles exceptions globally and returns RFC 7807 (Problem Details) compatible API envelopes.
/// </summary>
public sealed class ExceptionHandlingMiddleware(RequestDelegate next, ILogger<ExceptionHandlingMiddleware> logger)
{
private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web);
/// <summary>
/// Executes the middleware.
///
/// </summary>
/// <param name="context">HTTP context.</param>
/// <param name="context"></param>
/// <returns></returns>
public async Task InvokeAsync(HttpContext context)
{
try
{
await next(context);
}
catch (ApiException apiException)
catch (ValidationException ex)
{
logger.LogWarning(apiException, "API exception intercepted.");
logger.LogWarning(ex, "Validation failed for {Method} {Path}",
context.Request.Method, context.Request.Path);
context.Response.StatusCode = apiException.StatusCode;
context.Response.ContentType = "application/json";
var errors = ex.Errors
.GroupBy(e => e.PropertyName)
.ToDictionary(
g => g.Key,
g => g.Select(e => e.ErrorMessage).ToArray()
);
var response = new MetaOnlyResponse
var response = new
{
Meta =
[
new MetaEntry
{
Code = (int)apiException.MetaCode,
Message = apiException.Message
}
]
type = "https://tools.ietf.org/html/rfc7807",
title = "Validation Error",
status = StatusCodes.Status400BadRequest,
errors
};
context.Response.StatusCode = StatusCodes.Status400BadRequest;
context.Response.ContentType = "application/problem+json";
await context.Response.WriteAsync(JsonSerializer.Serialize(response, JsonOptions));
}
catch (ValidationException validationException)
catch (InvalidWorkflowPayloadException ex)
{
logger.LogWarning(validationException, "Validation failed.");
logger.LogError(ex, "Invalid upstream payload for {Method} {Path}",
context.Request.Method, context.Request.Path);
context.Response.StatusCode = StatusCodes.Status400BadRequest;
context.Response.ContentType = "application/json";
var message = validationException.Errors.FirstOrDefault()?.ErrorMessage
?? "Ungultiger Request";
var response = new MetaOnlyResponse
var response = new
{
Meta =
[
new MetaEntry
{
Code = (int)MetaCode.InvalidRequest,
Message = message
}
]
type = "https://tools.ietf.org/html/rfc7807",
title = "Bad Gateway",
status = StatusCodes.Status502BadGateway,
detail = ex.Message
};
context.Response.StatusCode = StatusCodes.Status502BadGateway;
context.Response.ContentType = "application/problem+json";
await context.Response.WriteAsync(JsonSerializer.Serialize(response, JsonOptions));
}
catch (NotSupportedException notSupportedException)
catch (Exception ex)
{
logger.LogWarning(notSupportedException, "Operation not supported yet.");
logger.LogError(ex, "Unhandled exception for {Method} {Path}",
context.Request.Method, context.Request.Path);
context.Response.StatusCode = StatusCodes.Status500InternalServerError;
context.Response.ContentType = "application/json";
var response = new MetaOnlyResponse
var response = new
{
Meta =
[
new MetaEntry
{
Code = (int)MetaCode.InternalError,
Message = notSupportedException.Message
}
]
type = "https://tools.ietf.org/html/rfc7807",
title = "Internal Server Error",
status = StatusCodes.Status500InternalServerError
};
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 = new MetaOnlyResponse
{
Meta =
[
new MetaEntry
{
Code = (int)MetaCode.InternalError,
Message = "Interner Fehler"
}
]
};
context.Response.StatusCode = StatusCodes.Status500InternalServerError;
context.Response.ContentType = "application/problem+json";
await context.Response.WriteAsync(JsonSerializer.Serialize(response, JsonOptions));
}
}

View File

@@ -70,6 +70,8 @@ try
builder.Services.AddControllers();
builder.Services.AddEndpointsApiExplorer();
var apiKeyConfigured = !string.IsNullOrWhiteSpace(config[$"{ApiKeyAuthOptions.SectionName}:Key"]);
builder.Services.AddSwaggerGen(options =>
{
options.SwaggerDoc("v1", new OpenApiInfo
@@ -77,11 +79,11 @@ try
Title = "DigitalData TaskFlow API",
Version = "v1",
Description = """
Integration API for taskFLOW workflow and document operations.
Integrations-API für taskFLOW Workflow- und Dokumentenoperationen.
---
- [Serilog Log Viewer](/serilog-ui)
- [Serilog Protokoll-Viewer](/serilog-ui)
""",
Contact = new OpenApiContact
{
@@ -90,6 +92,32 @@ try
Email = "info-flow@digitaldata.works"
}
});
if (apiKeyConfigured)
{
options.AddSecurityDefinition("ApiKey", new OpenApiSecurityScheme
{
Type = SecuritySchemeType.ApiKey,
In = ParameterLocation.Header,
Name = "X-Api-Key",
Description = "Optionaler API-Schlüssel. Leer lassen, falls nicht erforderlich."
});
options.AddSecurityRequirement(new OpenApiSecurityRequirement
{
{
new OpenApiSecurityScheme
{
Reference = new OpenApiReference
{
Type = ReferenceType.SecurityScheme,
Id = "ApiKey"
}
},
[]
}
});
}
});
builder.Services

View File

@@ -4,8 +4,8 @@
"windowsAuthentication": false,
"anonymousAuthentication": true,
"iisExpress": {
"applicationUrl": "http://localhost:3557",
"sslPort": 44339
"applicationUrl": "http://localhost:63206",
"sslPort": 44315
}
},
"profiles": {
@@ -14,7 +14,7 @@
"dotnetRunMessages": true,
"launchBrowser": true,
"launchUrl": "swagger",
"applicationUrl": "http://localhost:5298",
"applicationUrl": "http://localhost:5062",
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development"
}
@@ -24,7 +24,7 @@
"dotnetRunMessages": true,
"launchBrowser": true,
"launchUrl": "swagger",
"applicationUrl": "https://localhost:7050;http://localhost:5299",
"applicationUrl": "https://localhost:7131;http://localhost:5062",
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development"
}

View File

@@ -1,44 +0,0 @@
# DigitalData.TaskFlow.API
Dieses Projekt stellt die HTTP-Schnittstelle fuer externe Konsumenten bereit.
## Zweck
Die API-Schicht nimmt HTTP-Requests entgegen, leitet diese an die Application-Schicht weiter und liefert standardisierte Responses zurueck.
## Aktueller Inhalt
- `Program.cs`
- Startpunkt der ASP.NET-Core-Anwendung.
- Registriert Application- und Infrastructure-Dienste.
- Enthält Grundkonfiguration fuer Controller und OpenAPI/Swagger.
- `appsettings.json`
- Allgemeine Runtime-Konfiguration (Logging, ConnectionString, MediatR-Lizenz, Swagger-Optionen).
- `Controllers/`
- Zielordner fuer HTTP-Controller (aktuell noch leer).
- `Options/`
- API-spezifische Konfigurationsmodelle (z. B. `SwaggerOptions`).
## Geplante Inhalte (naechste Phasen)
- Controller fuer:
- `GET /workflows/{wisagid}`
- `POST /workflows/{wisagid}/{source}/{processid}/closing`
- `GET /documents/{id}`
- Middleware fuer:
- FluentValidation-Fehlerbehandlung
- Generelle Exception-Behandlung
- OpenAPI-Dokumentation:
- Swagger in Development, optional in Production
- Firmen-/Produkt-Metadaten
- Scalar als alternative UI
## Architektur-Regeln
- Keine direkte SQL- oder EF-Core-Logik im Controller.
- Transport-spezifische Aufgaben bleiben in API; fachliche Verarbeitung erfolgt in Application.
- Einheitliches Response-Schema gemaess Ticket-Vorgabe (`meta.code`, `meta.message`).
## Rolle im Gesamtsystem
Die API ist das Eingangstor fuer externe Systeme (z. B. fabrik19) und sorgt fuer stabile, versionierbare und gut dokumentierte Endpunkte.

View File

@@ -16,16 +16,10 @@
"ApiKey": {
"Key": null
},
"MediatR": {
"LuckyPennySoftLicenseKey": "eyJhbGciOiJSUzI1NiIsImtpZCI6Ikx1Y2t5UGVubnlTb2Z0d2FyZUxpY2Vuc2VLZXkvYmJiMTNhY2I1OTkwNGQ4OWI0Y2IxYzg1ZjA4OGNjZjkiLCJ0eXAiOiJKV1QifQ.eyJpc3MiOiJodHRwczovL2x1Y2t5cGVubnlzb2Z0d2FyZS5jb20iLCJhdWQiOiJMdWNreVBlbm55U29mdHdhcmUiLCJleHAiOiIxODE2MTI4MDAwIiwiaWF0IjoiMTc4NDYyNDU1NyIsImFjY291bnRfaWQiOiIwMTk4M2M1OWU0YjM3MjhlYmZkMzEwM2MyYTQ4NmU4NSIsImN1c3RvbWVyX2lkIjoiMDE5ODNjNTllNGIzNzI4ZWJmZDMxMDNjMmE0ODZlODUiLCJzdWJfaWQiOiItIiwiZWRpdGlvbiI6IjAiLCJ0eXBlIjoiMiJ9.IUUO926m9crYGYxMjjKD_n9BnUm-EDyjFIn0YmMUCo7C-QTwvB8WhXP8veTSFsBq-leIIDJ4jyl7Pgc_7ciwg1XhUSIs4mkQroEUaSFCGOxw7Pi41WM8MK5YFSaqLTYYXec9zxgiJbGzABbh3CHTSup3okGnVm_CMoPEs91l2c0A6N1JyZy74urd_tF0KGVKf0MOvzdlQIWLQ8o73S4pTv2N-F6UlzI0fdMtTHMLNNQyr0NdWdnuBk_jMBXO-gy5RE_oCRfMTTYRX2n3XLK6pTfXE0Ct338o9F5sH8Ph2lTXSu56cpdsfZOQZGqCH0LoFp1Dd7RJgIgNmBiTGfvDnA"
},
"LuckyPennySoftLicenseKey": "eyJhbGciOiJSUzI1NiIsImtpZCI6Ikx1Y2t5UGVubnlTb2Z0d2FyZUxpY2Vuc2VLZXkvYmJiMTNhY2I1OTkwNGQ4OWI0Y2IxYzg1ZjA4OGNjZjkiLCJ0eXAiOiJKV1QifQ.eyJpc3MiOiJodHRwczovL2x1Y2t5cGVubnlzb2Z0d2FyZS5jb20iLCJhdWQiOiJMdWNreVBlbm55U29mdHdhcmUiLCJleHAiOiIxODE2MTI4MDAwIiwiaWF0IjoiMTc4NDYyNDU1NyIsImFjY291bnRfaWQiOiIwMTk4M2M1OWU0YjM3MjhlYmZkMzEwM2MyYTQ4NmU4NSIsImN1c3RvbWVyX2lkIjoiMDE5ODNjNTllNGIzNzI4ZWJmZDMxMDNjMmE0ODZlODUiLCJzdWJfaWQiOiItIiwiZWRpdGlvbiI6IjAiLCJ0eXBlIjoiMiJ9.IUUO926m9crYGYxMjjKD_n9BnUm-EDyjFIn0YmMUCo7C-QTwvB8WhXP8veTSFsBq-leIIDJ4jyl7Pgc_7ciwg1XhUSIs4mkQroEUaSFCGOxw7Pi41WM8MK5YFSaqLTYYXec9zxgiJbGzABbh3CHTSup3okGnVm_CMoPEs91l2c0A6N1JyZy74urd_tF0KGVKf0MOvzdlQIWLQ8o73S4pTv2N-F6UlzI0fdMtTHMLNNQyr0NdWdnuBk_jMBXO-gy5RE_oCRfMTTYRX2n3XLK6pTfXE0Ct338o9F5sH8Ph2lTXSu56cpdsfZOQZGqCH0LoFp1Dd7RJgIgNmBiTGfvDnA",
"Swagger": {
"EnabledInProduction": false
},
"Database": {
"CommandTimeoutSeconds": 30,
"WorkflowListingObjectType": "Ferdinand"
},
"Application": {
"LogDirectory": "logs"
},