10 Commits

Author SHA1 Message Date
a924e32291 Bump version to 2.4.0-beta in ReC.API.csproj
Updated the project version, assembly version, file version, and informational version from 2.3.0-beta to 2.4.0-beta in the ReC.API.csproj file. This prepares the project for the next beta release.
2026-04-17 15:14:32 +02:00
28a4146069 Auto-detect Content-Type and Accept headers if enabled
Add AutoDetectHeaders option to RecAction config and handler. When enabled, automatically set Content-Type based on body content (JSON or XML) and default Accept to application/json if missing. Log warnings when headers are auto-detected. Improves robustness and makes header detection configurable.
2026-04-17 15:13:33 +02:00
17d40817f2 Update version to 2.3.0-beta
Bump Version, AssemblyVersion, FileVersion, and InformationalVersion to 2.3.0-beta in ReC.API.csproj.
2026-04-17 12:42:04 +02:00
330443d2c9 Add config options for EF Core logging and error details
EnableSensitiveDataLogging and EnableDetailedErrors are now configurable via appsettings.json under the "EfCore" section. Program.cs reads these values from configuration instead of hardcoding them, allowing runtime control of EF Core logging and error detail behavior.
2026-04-17 12:41:45 +02:00
6ca876c762 Improve HTTP header handling and error resilience
Enhance InvokeRecActionViewCommandHandler to robustly handle invalid or non-strict HTTP header values. "Content-Type" and "Authorization" headers are now set using strict parsing with fallback to TryAddWithoutValidation on failure, logging warnings for easier debugging. General headers and API key headers also use this strict-then-fallback approach, making HTTP request construction more tolerant of malformed values and reducing runtime errors.
2026-04-17 12:17:41 +02:00
e89af1cbcd Improve HTTP header handling and add logging to handler
Inject optional ILogger into InvokeRecActionViewCommandHandler for enhanced logging. When adding HTTP headers, catch FormatException and log a warning with relevant context, then fall back to TryAddWithoutValidation. This increases robustness and observability for malformed or non-standard headers.
2026-04-17 12:01:15 +02:00
761fd208e5 Bump version to 2.2.1-beta
Updated Version, AssemblyVersion, and FileVersion in ReC.API.csproj from 2.2.0-beta/2.2.0.0 to 2.2.1-beta/2.2.1.0. No other changes made.
2026-04-17 00:34:40 +02:00
d149cbea3a Improve error handling in query behaviors for SQL execution
Wrap ExecuteScalarAsync in try-catch blocks in BodyQueryBehavior and HeaderQueryBehavior. Throw DataIntegrityException with detailed context if SQL execution fails, aiding in diagnosing malformed or problematic stored SQL queries.
2026-04-17 00:34:01 +02:00
bb2dd4d63b Stricter error handling in BodyQuery and HeaderQuery behaviors
Throw DataIntegrityException when body or header queries return
null, no result, or invalid JSON. Remove ILogger and related
logging from HeaderQueryBehavior for simplification. This change
ensures data integrity issues are surfaced immediately.
2026-04-17 00:23:36 +02:00
4bde1d090f Refactor query behaviors to process action collections
Refactored BodyQueryBehavior and HeaderQueryBehavior to operate on collections of RecActionViewDto instead of single instances. Moved SQL execution and property assignment logic into private helper methods using ADO.NET commands. Improved null checks and logging, and updated type constraints to reflect the new usage. Behaviors now return the modified collection after processing.
2026-04-17 00:15:52 +02:00
7 changed files with 160 additions and 50 deletions

View File

@@ -48,10 +48,13 @@ try
?? throw new InvalidOperationException("Connection string is not found."); ?? throw new InvalidOperationException("Connection string is not found.");
var logger = provider.GetRequiredService<ILogger<RecDbContext>>(); var logger = provider.GetRequiredService<ILogger<RecDbContext>>();
var enableSensitiveDataLogging = config.GetValue("EfCore:EnableSensitiveDataLogging", true);
var enableDetailedErrors = config.GetValue("EfCore:EnableDetailedErrors", false);
opt.UseSqlServer(cnnStr) opt.UseSqlServer(cnnStr)
.LogTo(log => logger.LogInformation("{log}", log), LogLevel.Trace) .LogTo(log => logger.LogInformation("{log}", log), LogLevel.Trace)
.EnableSensitiveDataLogging() .EnableSensitiveDataLogging(enableSensitiveDataLogging)
.EnableDetailedErrors(); .EnableDetailedErrors(enableDetailedErrors);
}); });
}); });

View File

@@ -10,10 +10,10 @@
<Product>ReC.API</Product> <Product>ReC.API</Product>
<PackageIcon>Assets\icon.ico</PackageIcon> <PackageIcon>Assets\icon.ico</PackageIcon>
<PackageTags>digital data rest-caller rec api</PackageTags> <PackageTags>digital data rest-caller rec api</PackageTags>
<Version>2.2.0-beta</Version> <Version>2.4.0-beta</Version>
<AssemblyVersion>2.2.0.0</AssemblyVersion> <AssemblyVersion>2.4.0.0</AssemblyVersion>
<FileVersion>2.2.0.0</FileVersion> <FileVersion>2.4.0.0</FileVersion>
<InformationalVersion>2.2.0-beta</InformationalVersion> <InformationalVersion>2.4.0-beta</InformationalVersion>
<Copyright>Copyright © 2025 Digital Data GmbH. All rights reserved.</Copyright> <Copyright>Copyright © 2025 Digital Data GmbH. All rights reserved.</Copyright>
<GenerateDocumentationFile>true</GenerateDocumentationFile> <GenerateDocumentationFile>true</GenerateDocumentationFile>
<NoWarn>$(NoWarn);1591</NoWarn> <NoWarn>$(NoWarn);1591</NoWarn>

View File

@@ -5,9 +5,13 @@
}, },
"AllowedHosts": "*", "AllowedHosts": "*",
"LuckyPennySoftwareLicenseKey": "eyJhbGciOiJSUzI1NiIsImtpZCI6Ikx1Y2t5UGVubnlTb2Z0d2FyZUxpY2Vuc2VLZXkvYmJiMTNhY2I1OTkwNGQ4OWI0Y2IxYzg1ZjA4OGNjZjkiLCJ0eXAiOiJKV1QifQ.eyJpc3MiOiJodHRwczovL2x1Y2t5cGVubnlzb2Z0d2FyZS5jb20iLCJhdWQiOiJMdWNreVBlbm55U29mdHdhcmUiLCJleHAiOiIxNzg0ODUxMjAwIiwiaWF0IjoiMTc1MzM2MjQ5MSIsImFjY291bnRfaWQiOiIwMTk4M2M1OWU0YjM3MjhlYmZkMzEwM2MyYTQ4NmU4NSIsImN1c3RvbWVyX2lkIjoiY3RtXzAxazB5NmV3MmQ4YTk4Mzg3aDJnbTRuOWswIiwic3ViX2lkIjoiLSIsImVkaXRpb24iOiIwIiwidHlwZSI6IjIifQ.ZqsFG7kv_-xGfxS6ACk3i0iuNiVUXX2AvPI8iAcZ6-z2170lGv__aO32tWpQccD9LCv5931lBNLWSblKS0MT3gOt-5he2TEftwiSQGFwoIBgtOHWsNRMinUrg2trceSp3IhyS3UaMwnxZDrCvx4-0O-kpOzVpizeHUAZNr5U7oSCWO34bpKdae6grtM5e3f93Z1vs7BW_iPgItd-aLvPwApbaG9VhmBTKlQ7b4Jh64y7UXJ9mKP7Qb_Oa97oEg0oY5DPHOWTZWeE1EzORgVr2qkK2DELSHuZ_EIUhODojkClPNAKtvEl_qEjpq0HZCIvGwfCCRlKlSkQqIeZdFkiXg", "LuckyPennySoftwareLicenseKey": "eyJhbGciOiJSUzI1NiIsImtpZCI6Ikx1Y2t5UGVubnlTb2Z0d2FyZUxpY2Vuc2VLZXkvYmJiMTNhY2I1OTkwNGQ4OWI0Y2IxYzg1ZjA4OGNjZjkiLCJ0eXAiOiJKV1QifQ.eyJpc3MiOiJodHRwczovL2x1Y2t5cGVubnlzb2Z0d2FyZS5jb20iLCJhdWQiOiJMdWNreVBlbm55U29mdHdhcmUiLCJleHAiOiIxNzg0ODUxMjAwIiwiaWF0IjoiMTc1MzM2MjQ5MSIsImFjY291bnRfaWQiOiIwMTk4M2M1OWU0YjM3MjhlYmZkMzEwM2MyYTQ4NmU4NSIsImN1c3RvbWVyX2lkIjoiY3RtXzAxazB5NmV3MmQ4YTk4Mzg3aDJnbTRuOWswIiwic3ViX2lkIjoiLSIsImVkaXRpb24iOiIwIiwidHlwZSI6IjIifQ.ZqsFG7kv_-xGfxS6ACk3i0iuNiVUXX2AvPI8iAcZ6-z2170lGv__aO32tWpQccD9LCv5931lBNLWSblKS0MT3gOt-5he2TEftwiSQGFwoIBgtOHWsNRMinUrg2trceSp3IhyS3UaMwnxZDrCvx4-0O-kpOzVpizeHUAZNr5U7oSCWO34bpKdae6grtM5e3f93Z1vs7BW_iPgItd-aLvPwApbaG9VhmBTKlQ7b4Jh64y7UXJ9mKP7Qb_Oa97oEg0oY5DPHOWTZWeE1EzORgVr2qkK2DELSHuZ_EIUhODojkClPNAKtvEl_qEjpq0HZCIvGwfCCRlKlSkQqIeZdFkiXg",
"EfCore": {
"EnableSensitiveDataLogging": true,
"EnableDetailedErrors": false
},
"RecAction": { "RecAction": {
"AddedWho": "ReC.API", "UseHttp1ForNtlm": false,
"UseHttp1ForNtlm": false "AutoDetectHeaders": false
}, },
// Bad request SqlException numbers numbers can be updated at runtime; no restart required. // Bad request SqlException numbers numbers can be updated at runtime; no restart required.
"SqlException": { "SqlException": {

View File

@@ -1,23 +1,54 @@
using MediatR; using MediatR;
using ReC.Application.Common.Dto;
using ReC.Application.Common.Interfaces;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using ReC.Application.Common.Dto;
using ReC.Application.Common.Exceptions;
using ReC.Application.Common.Interfaces;
namespace ReC.Application.Common.Behaviors.Action; namespace ReC.Application.Common.Behaviors.Action;
public class BodyQueryBehavior<TRequest, TResponse>(IRecDbContext dbContext) : IPipelineBehavior<TRequest, TResponse> public class BodyQueryBehavior<TRequest, TResponse>(IRecDbContext dbContext) : IPipelineBehavior<TRequest, TResponse>
where TRequest : RecActionViewDto where TRequest : notnull
where TResponse : notnull where TResponse : IEnumerable<RecActionViewDto>
{ {
public async Task<TResponse> Handle(TRequest action, RequestHandlerDelegate<TResponse> next, CancellationToken cancel) public async Task<TResponse> Handle(TRequest request, RequestHandlerDelegate<TResponse> next, CancellationToken cancel)
{ {
if (action.BodyQuery is null) var actions = await next(cancel);
return await next(cancel);
var result = await dbContext.BodyQueryResults.FromSqlRaw(action.BodyQuery).SingleOrDefaultAsync(cancel); foreach (var action in actions)
await SetBody(action, cancel);
action.Body = result?.RawBody; return actions;
}
return await next(cancel); private async Task SetBody(RecActionViewDto action, CancellationToken cancel)
{
if (action.BodyQuery is not string bodyQuery)
return;
await using var command = dbContext.Database.GetDbConnection().CreateCommand();
command.CommandText = bodyQuery;
await dbContext.Database.OpenConnectionAsync(cancel);
try
{
object? scalar;
try
{
scalar = await command.ExecuteScalarAsync(cancel);
}
catch (Exception ex)
{
throw new DataIntegrityException(
$"Body query execution failed. The stored SQL may be malformed. ActionId: {action.Id}, ProfileId: {action.ProfileId}, Error: {ex.Message}");
}
action.Body = scalar as string
?? throw new DataIntegrityException(
$"Body query returned no result or a null value. ActionId: {action.Id}, ProfileId: {action.ProfileId}");
}
finally
{
await dbContext.Database.CloseConnectionAsync();
}
} }
} }

View File

@@ -1,43 +1,61 @@
using MediatR; using MediatR;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
using ReC.Application.Common.Dto; using ReC.Application.Common.Dto;
using ReC.Application.Common.Exceptions;
using ReC.Application.Common.Interfaces; using ReC.Application.Common.Interfaces;
using System.Text.Json; using System.Text.Json;
namespace ReC.Application.Common.Behaviors.Action; namespace ReC.Application.Common.Behaviors.Action;
public class HeaderQueryBehavior<TRequest, TResponse>(IRecDbContext dbContext, ILogger<HeaderQueryBehavior<TRequest, TResponse>>? logger = null) : IPipelineBehavior<TRequest, TResponse> public class HeaderQueryBehavior<TRequest, TResponse>(IRecDbContext dbContext) : IPipelineBehavior<TRequest, TResponse>
where TRequest : RecActionViewDto where TRequest : notnull
where TResponse : notnull where TResponse : IEnumerable<RecActionViewDto>
{ {
public async Task<TResponse> Handle(TRequest action, RequestHandlerDelegate<TResponse> next, CancellationToken cancel) public async Task<TResponse> Handle(TRequest request, RequestHandlerDelegate<TResponse> next, CancellationToken cancel)
{ {
if (action.HeaderQuery is null) var actions = await next(cancel);
return await next(cancel);
var result = await dbContext.HeaderQueryResults.FromSqlRaw(action.HeaderQuery).SingleOrDefaultAsync(cancel); foreach (var action in actions)
await SetHeader(action, cancel);
if (result?.RawHeader is null) return actions;
{
logger?.LogWarning("Header query did not return a result or returned a null REQUEST_HEADER. Profile ID: {ProfileId}, Action ID: {Id}", action.ProfileId, action.Id);
return await next(cancel);
} }
var headerDict = JsonSerializer.Deserialize<Dictionary<string, JsonElement>>(result.RawHeader); private async Task SetHeader(RecActionViewDto action, CancellationToken cancel)
if(headerDict is null)
{ {
logger?.LogWarning( if (action.HeaderQuery is not string headerQuery)
"Header JSON deserialization returned null. RawHeader: {RawHeader}, ProfileId: {ProfileId}, Id: {Id}", return;
result.RawHeader, action.ProfileId, action.Id);
return await next(cancel); await using var command = dbContext.Database.GetDbConnection().CreateCommand();
command.CommandText = headerQuery;
await dbContext.Database.OpenConnectionAsync(cancel);
try
{
object? scalar;
try
{
scalar = await command.ExecuteScalarAsync(cancel);
} }
catch (Exception ex)
{
throw new DataIntegrityException(
$"Header query execution failed. The stored SQL may be malformed. ActionId: {action.Id}, ProfileId: {action.ProfileId}, Error: {ex.Message}");
}
if (scalar is not string rawHeader)
throw new DataIntegrityException(
$"Header query returned no result or a null value. ActionId: {action.Id}, ProfileId: {action.ProfileId}");
var headerDict = JsonSerializer.Deserialize<Dictionary<string, JsonElement>>(rawHeader)
?? throw new DataIntegrityException(
$"Header query returned invalid JSON. ActionId: {action.Id}, ProfileId: {action.ProfileId}");
action.Headers = headerDict.ToDictionary(header => header.Key, kvp => kvp.Value.ToString()); action.Headers = headerDict.ToDictionary(header => header.Key, kvp => kvp.Value.ToString());
}
return await next(cancel); finally
{
await dbContext.Database.CloseConnectionAsync();
}
} }
} }

View File

@@ -3,4 +3,5 @@
public class RecActionOptions public class RecActionOptions
{ {
public bool UseHttp1ForNtlm { get; set; } = false; public bool UseHttp1ForNtlm { get; set; } = false;
public bool AutoDetectHeaders { get; set; } = false;
} }

View File

@@ -1,5 +1,6 @@
using MediatR; using MediatR;
using Microsoft.Extensions.Configuration; using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options; using Microsoft.Extensions.Options;
using ReC.Application.Common.Constants; using ReC.Application.Common.Constants;
using ReC.Application.Common.Dto; using ReC.Application.Common.Dto;
@@ -35,7 +36,8 @@ public class InvokeRecActionViewCommandHandler(
IOptions<RecActionOptions> options, IOptions<RecActionOptions> options,
ISender sender, ISender sender,
IHttpClientFactory clientFactory, IHttpClientFactory clientFactory,
IConfiguration? config = null IConfiguration? config = null,
ILogger<InvokeRecActionViewCommandHandler>? logger = null
) : IRequestHandler<InvokeRecActionViewCommand> ) : IRequestHandler<InvokeRecActionViewCommand>
{ {
private readonly RecActionOptions _options = options.Value; private readonly RecActionOptions _options = options.Value;
@@ -57,11 +59,47 @@ public class InvokeRecActionViewCommandHandler(
using var httpReq = CreateHttpRequestMessage(restType, action.EndpointUri); using var httpReq = CreateHttpRequestMessage(restType, action.EndpointUri);
if (action.Body is not null) if (action.Body is not null)
{
httpReq.Content = new StringContent(action.Body); httpReq.Content = new StringContent(action.Body);
var contentType = action.Headers?.FirstOrDefault(h => h.Key.Equals("Content-Type", StringComparison.OrdinalIgnoreCase));
if (contentType is not null && !string.IsNullOrWhiteSpace(contentType.Value.Value))
try { httpReq.Content.Headers.ContentType = MediaTypeHeaderValue.Parse(contentType.Value.Value); }
catch (FormatException ex)
{
logger?.LogWarning(ex, "Content-Type '{Value}' could not be parsed with strict validation, falling back to TryAddWithoutValidation. ActionId: {ActionId}, ProfileId: {ProfileId}", contentType.Value.Value, action.Id, action.ProfileId);
httpReq.Content.Headers.TryAddWithoutValidation("Content-Type", contentType.Value.Value);
}
else if (_options.AutoDetectHeaders)
{
var body = action.Body.TrimStart();
if (body.StartsWith('{') || body.StartsWith('['))
{
httpReq.Content.Headers.ContentType = new MediaTypeHeaderValue("application/json") { CharSet = "utf-8" };
logger?.LogWarning("Content-Type header was not specified. Auto-detected 'application/json; charset=utf-8' based on body content. ActionId: {ActionId}, ProfileId: {ProfileId}", action.Id, action.ProfileId);
}
else if (body.StartsWith('<'))
{
httpReq.Content.Headers.ContentType = new MediaTypeHeaderValue("application/xml") { CharSet = "utf-8" };
logger?.LogWarning("Content-Type header was not specified. Auto-detected 'application/xml; charset=utf-8' based on body content. ActionId: {ActionId}, ProfileId: {ProfileId}", action.Id, action.ProfileId);
}
}
}
if (action.Headers is not null) if (action.Headers is not null)
foreach (var header in action.Headers) foreach (var header in action.Headers.Where(h => !h.Key.Equals("Content-Type", StringComparison.OrdinalIgnoreCase)))
httpReq.Headers.Add(header.Key, header.Value); try { httpReq.Headers.Add(header.Key, header.Value); }
catch (FormatException ex)
{
logger?.LogWarning(ex, "Header '{Key}' could not be added with strict validation, falling back to TryAddWithoutValidation. ActionId: {ActionId}, ProfileId: {ProfileId}", header.Key, action.Id, action.ProfileId);
httpReq.Headers.TryAddWithoutValidation(header.Key, header.Value);
}
if (_options.AutoDetectHeaders && !httpReq.Headers.Contains("Accept"))
{
httpReq.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json"));
logger?.LogWarning("Accept header was not specified. Defaulting to 'application/json'. ActionId: {ActionId}, ProfileId: {ProfileId}", action.Id, action.ProfileId);
}
switch (action.EndpointAuthType) switch (action.EndpointAuthType)
{ {
@@ -74,7 +112,12 @@ public class InvokeRecActionViewCommandHandler(
switch (action.EndpointAuthApiKeyAddTo) switch (action.EndpointAuthApiKeyAddTo)
{ {
case ApiKeyLocation.Header: case ApiKeyLocation.Header:
httpReq.Headers.Add(apiKey, apiValue); try { httpReq.Headers.Add(apiKey, apiValue); }
catch (FormatException ex)
{
logger?.LogWarning(ex, "ApiKey header '{Key}' could not be added with strict validation, falling back to TryAddWithoutValidation. ActionId: {ActionId}, ProfileId: {ProfileId}", apiKey, action.Id, action.ProfileId);
httpReq.Headers.TryAddWithoutValidation(apiKey, apiValue);
}
break; break;
case ApiKeyLocation.Query: case ApiKeyLocation.Query:
var uriBuilder = new UriBuilder(httpReq.RequestUri!); var uriBuilder = new UriBuilder(httpReq.RequestUri!);
@@ -97,14 +140,24 @@ public class InvokeRecActionViewCommandHandler(
case EndpointAuthType.JwtBearer: case EndpointAuthType.JwtBearer:
case EndpointAuthType.OAuth2: case EndpointAuthType.OAuth2:
if (action.EndpointAuthToken is string authToken) if (action.EndpointAuthToken is string authToken)
httpReq.Headers.Authorization = new AuthenticationHeaderValue("Bearer", authToken); try { httpReq.Headers.Authorization = new AuthenticationHeaderValue("Bearer", authToken); }
catch (FormatException ex)
{
logger?.LogWarning(ex, "Bearer token could not be set with strict validation, falling back to TryAddWithoutValidation. ActionId: {ActionId}, ProfileId: {ProfileId}", action.Id, action.ProfileId);
httpReq.Headers.TryAddWithoutValidation("Authorization", $"Bearer {authToken}");
}
break; break;
case EndpointAuthType.BasicAuth: case EndpointAuthType.BasicAuth:
if (action.EndpointAuthUsername is string authUsername && action.EndpointAuthPassword is string authPassword) if (action.EndpointAuthUsername is string authUsername && action.EndpointAuthPassword is string authPassword)
{ {
var basicAuth = Convert.ToBase64String(Encoding.UTF8.GetBytes($"{authUsername}:{authPassword}")); var basicAuth = Convert.ToBase64String(Encoding.UTF8.GetBytes($"{authUsername}:{authPassword}"));
httpReq.Headers.Authorization = new AuthenticationHeaderValue("Basic", basicAuth); try { httpReq.Headers.Authorization = new AuthenticationHeaderValue("Basic", basicAuth); }
catch (FormatException ex)
{
logger?.LogWarning(ex, "Basic auth could not be set with strict validation, falling back to TryAddWithoutValidation. ActionId: {ActionId}, ProfileId: {ProfileId}", action.Id, action.ProfileId);
httpReq.Headers.TryAddWithoutValidation("Authorization", $"Basic {basicAuth}");
}
} }
break; break;