59 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
6681e56afc Refactor entity selection to use EntityType enum
Replaced string-based entity identifiers in CRUD procedure and command classes with a strongly-typed EntityType enum. Updated all relevant handlers and records to use the new enum property, improving type safety and maintainability. Added necessary using directives and updated documentation comments to reflect these changes.
2026-04-16 17:14:29 +02:00
d61f5ce885 Update validators to use IsInEnum for Entity property
Refactored DeleteObjectProcedureValidator, InsertObjectProcedureValidator, and UpdateObjectProcedureValidator to use .IsInEnum() for validating the Entity property. This replaces custom or hardcoded checks with enum-based validation, improving consistency, maintainability, and robustness across all validators.
2026-04-16 17:13:00 +02:00
6374a5c257 Add EntityType enum and support in StoredProcedureBuilder
Introduced the EntityType enum to represent target entities for stored procedure operations, along with XML documentation and mapping comments. Added EntityTypeExtensions with a ToDbString method for consistent DB string conversion. Updated StoredProcedureBuilder to support adding EntityType parameters, improving type safety and maintainability. Minor formatting and using directive adjustments included.
2026-04-16 17:12:19 +02:00
3b4954d387 Remove FakeRequest class and ResultType enum
Deleted FakeRequest.cs and ResultType.cs, removing the FakeRequest class (with Body and Header properties) and the ResultType enum (Full, OnlyHeader, OnlyBody). These types are no longer needed in the codebase.
2026-04-16 14:55:10 +02:00
42d586604e Add UpdateObjectProcedureValidator with validation rules
Introduced UpdateObjectProcedureValidator using FluentValidation to enforce constraints on UpdateObjectProcedure fields, including ID checks and maximum length restrictions on various properties and nested objects.
2026-04-16 14:50:00 +02:00
4088a52196 Add FluentValidation for ReadResultViewQuery filters
Introduced ReadResultViewQueryValidator to enforce that at least one filter (Id, ActionId, ProfileId, or BatchId) is provided. Also validates that numeric IDs are greater than 0 and BatchId is not empty when present.
2026-04-16 14:48:09 +02:00
58b3c8ec95 Add validator for ReadRecActionViewQuery ProfileId
Introduced ReadRecActionViewQueryValidator using FluentValidation to ensure ProfileId, if provided, is greater than 0. Returns a specific validation message if the rule is violated.
2026-04-16 14:46:07 +02:00
68b3eb53c0 Add ReadProfileViewQueryValidator for Id validation
Introduced ReadProfileViewQueryValidator using FluentValidation to ensure the Id property, if provided, is greater than 0. Includes a custom error message for invalid Id values.
2026-04-16 14:39:26 +02:00
0d9489203f Add InsertResultCommandValidator with validation rules
Introduce InsertResultCommandValidator using FluentValidation to enforce required and value constraints on ActionId and References.BatchId properties, including custom error messages.
2026-04-16 14:38:45 +02:00
0a564d8aa8 Add DeleteObjectProcedureValidator with validation rules
Introduced DeleteObjectProcedureValidator using FluentValidation to ensure Start is greater than 0 and End is greater than or equal to Start, with custom error messages for each rule.
2026-04-16 14:36:43 +02:00
f5b2db0296 Update tests to expect ValidationException for BatchId dupes
Refactored InvokeBatchDuplicateGuardTests to expect and assert
FluentValidation's ValidationException instead of the custom
BadRequestException when a duplicate BatchId is submitted.
Assertions and comments were updated accordingly, and the
DigitalData.Core.Exceptions import was removed. Test logic
remains unchanged.
2026-04-16 14:15:21 +02:00
7a22024624 Remove batch result check from rec action invocation
Removed the check that blocked rec action invocation if results
already existed for a batch. Also updated using directives for
exception handling and logging.
2026-04-16 14:14:19 +02:00
c9cd92a55a Add validator for InvokeBatchRecActionViewsCommand
Introduced InvokeBatchRecActionViewsCommandValidator using FluentValidation. This validator ensures BatchId is provided and checks asynchronously via MediatR that no results exist for the given BatchId before allowing the command to proceed. Provides a clear validation message if results are already present.
2026-04-16 14:13:06 +02:00
93adaba322 Update error message to omit result count in exception
Removed the count of existing results from the BadRequestException
message when invoking rec actions for a batch. The error now simply
states that results are already associated with the batch.
2026-04-16 11:28:46 +02:00
c16cb2a1c4 Bump version to 2.2.0-beta in ReC.API.csproj
Updated project version fields in ReC.API.csproj from 2.1.0-beta to 2.2.0-beta, including Version, AssemblyVersion, FileVersion, and InformationalVersion.
2026-04-16 10:55:59 +02:00
c20162e051 Update build package output path to M: drive
Changed DesktopBuildPackageLocation in IISProfile.pubxml to output the build package to the M:\App&Service directory instead of the P: drive. This ensures published packages are stored in the new target location.
2026-04-16 10:53:30 +02:00
70c2f7342d Refactor result view query to support LastBatch retrieval
Replaced Last with LastBatch in ReadResultViewQuery to enable fetching all results from the most recent batch. Updated handler logic and tests accordingly, and added GetLastBatchEntitiesAsync to retrieve entities by latest BatchId.
2026-04-16 10:49:15 +02:00
a10f917084 Add tests for batch duplicate guard in rec action invoke
Added InvokeBatchDuplicateGuardTests to verify that invoking batch rec actions with an existing BatchId throws BadRequestException, while using a new BatchId does not trigger the duplicate guard. Tests use MediatR ISender and real database data for integration coverage. Added necessary using directives.
2026-04-16 10:40:34 +02:00
e1c3f74cd4 Prevent rec action invocation if batch has results
Add validation in InvokeRecActionViewsCommandHandler to check
for existing results before invoking rec actions for a batch.
Throw BadRequestException if results are found to avoid
duplicate processing. Add necessary using statements for
exceptions and queries.
2026-04-16 10:22:31 +02:00
e45aeea2b9 Add BatchId filter to ReadResultViewQuery and handler
Added an optional BatchId property to ReadResultViewQuery to enable filtering by BatchId. Updated ReadResultViewQueryHandler to apply this filter when BatchId is provided. Also adjusted the order of IncludeAction logic for clarity.
2026-04-16 10:20:57 +02:00
38f91aae84 Add AnyResultViewQuery to check for matching ResultView
Introduced AnyResultViewQuery and its handler to determine if any ResultView entity exists matching optional filters (Id, ActionId, ProfileId, BatchId). The handler builds the query dynamically and uses AnyAsync for efficient existence checks.
2026-04-16 10:20:18 +02:00
9bb5c97df6 Require non-null References for batch rec action invoke
Enforce non-nullable References in RecActionController and InvokeBatchRecActionViewsCommand. Update tests to always provide References and add missing using directive. Improves type safety and ensures consistent reference handling.
2026-04-16 10:02:34 +02:00
d8c7499436 Add BatchId and AddedWho to DTO; make BatchId nullable
Added BatchId and AddedWho properties to ResultViewDto. Changed BatchId in ResultView from required to nullable to align with DTO and support optional batch IDs.
2026-04-16 09:58:46 +02:00
6d86760354 Make BatchId in ResultView a required string
Changed the BatchId property in the ResultView class from a nullable string to a required non-nullable string. This ensures that BatchId must always be provided when creating a ResultView instance, improving data integrity.
2026-04-16 09:55:57 +02:00
6b1daf77cb Make References required in InsertResultCommand
Changed the References property in InsertResultCommand from a nullable type to required. References must now always be provided and cannot be null when creating an InsertResultCommand instance. This enforces stricter data integrity for command creation.
2026-04-16 09:55:26 +02:00
d3d5ebac61 Rename InvokeReferencesDto to InvokeReferences project-wide
Replaced all usages of InvokeReferencesDto with InvokeReferences across controllers, commands, and DTOs. This change standardizes the reference type naming by removing the "Dto" suffix, with no changes to the structure or behavior.
2026-04-16 09:51:15 +02:00
b1924f2a4a Make References and BatchId required for RecAction invoke
Updated InvokeRecActionViewCommand and InvokeReferencesDto to require non-null References and BatchId. Updated RecActionApi.InvokeAsync to require InvokeReferences and added an overload accepting batchId for convenience. This enforces stricter input validation and aligns client and backend requirements.
2026-04-16 09:50:52 +02:00
c27ed1e744 Change RecStatus enum type from short to byte
Reduced the underlying type of the RecStatus enum from short to byte to decrease memory usage. No changes were made to the enum values or their definitions.
2026-04-15 17:02:28 +02:00
9eb54565cb Make Info non-nullable in InsertResultCommand
Changed the Info property from short? to short in InsertResultCommand,
making it a required field and ensuring it cannot be null. This enforces
that all InsertResultCommand instances must provide a value for Info.
2026-04-15 16:57:45 +02:00
05dfb6f424 Update pRESULT_STATUS_ID to TinyInt in procedure handlers
Changed SQL parameter type for pRESULT_STATUS_ID from SmallInt to TinyInt in both InsertObjectProcedureHandler and UpdateObjectProcedureHandler to align with database schema and ensure type consistency.
2026-04-15 16:40:16 +02:00
cf6c90ad05 Auto-set SqlDbType.DateTime for DateTime parameters
Automatically assigns SqlDbType.DateTime to parameters when the value is a DateTime and no dbType is specified. This ensures correct SQL type mapping for DateTime values in stored procedures.
2026-04-15 15:31:52 +02:00
4a9c4341c2 Add BatchId to InvokeReferences; clean up property layout
Added BatchId property to InvokeReferences for batch identification.
Refactored Reference3-5 property declarations for clarity and
consistency by removing unnecessary line breaks.
2026-04-15 14:36:36 +02:00
ead12b6095 Add pRESULT_BATCH_ID parameter to update procedure
Added support for the pRESULT_BATCH_ID parameter in the database command, sourcing its value from request.Result.References?.BatchId. This enables batch ID information to be included in update operations when available.
2026-04-15 14:35:56 +02:00
3c7fcb71c0 Add pRESULT_BATCH_ID parameter to insert procedure
Added support for the pRESULT_BATCH_ID parameter in the database command, allowing the batch ID from request.Result?.References?.BatchId to be included when inserting objects. This enables tracking and referencing of batch operations.
2026-04-15 14:35:37 +02:00
0b01b4a658 Add BatchId property to InvokeReferencesDto
Added an optional BatchId property to the InvokeReferencesDto record, enabling support for batch identification alongside existing reference fields.
2026-04-15 14:34:44 +02:00
8d511ec81a Add BatchId property to ResultView class
Added the BatchId property to the ResultView class, mapping it to the "BATCH_ID" column in the database. This property is of type string? and allows tracking of batch identifiers in result records.
2026-04-15 14:33:54 +02:00
685c5abca7 Remove HTTP status formatting/parsing from ResultView
Removed FormatHttpStatusInfo, ParseHttpStatusInfo, and the related Regex from the ResultView class. These methods are no longer needed and have been deleted to simplify the codebase.
2026-04-15 14:33:06 +02:00
b7aea848a9 Add InvokeReferences support to InvokeAsync method
Added InvokeReferences class for passing optional reference values (Reference1–Reference5) when invoking a profile. Updated InvokeAsync to accept and serialize these references in the POST request. Improved XML docs to reflect the new parameter.
2026-04-15 13:57:42 +02:00
e5eb0f19e7 Add optional references to RecAction invoke endpoint
The Invoke action in RecActionController now accepts an optional InvokeReferencesDto from the request body. This enables clients to provide reference values that are passed through to all result records. The method signature, XML documentation, and command dispatch have been updated to support this enhancement.
2026-04-15 13:37:40 +02:00
859ff5902e Include References in InsertResultCommand for all results
Add request.References to InsertResultCommand in both
PreprocessingBehavior and PostprocessingBehavior, ensuring
references are recorded with both success and error results.
2026-04-15 13:33:39 +02:00
42789567f0 Refactor UpdateResultDto to use InvokeReferencesDto
Replaced five separate reference string properties in UpdateResultDto with a single References property of type InvokeReferencesDto for improved structure and maintainability. Added the necessary using directive for the new type.
2026-04-15 13:32:17 +02:00
46eef255ca Add References support to batch rec action commands
Added an optional References property to InvokeBatchRecActionViewsCommand. Updated the handler to pass this property to each individual InvokeRecActionViewCommand. Also included the ReC.Domain.Constants namespace for required types.
2026-04-15 13:31:54 +02:00
aae42949b6 Refactor InsertResultCommand references into DTO
Replaced Reference1–5 string properties in InsertResultCommand with a single References property of type InvokeReferencesDto? for better maintainability. Added necessary using directive for the new DTO.
2026-04-15 13:14:17 +02:00
bdf273c8e1 Add support for references in InvokeRecAction commands
Introduced the InvokeReferencesDto record to encapsulate up to five optional reference strings. Updated InvokeRecActionViewCommand to include an optional References property. Modified InvokeRecActionViewCommandHandler to pass these references to InsertResultCommand, ensuring reference data is associated with command execution and results.
2026-04-15 12:57:13 +02:00
ba8ab28b03 Refactor: group Result reference fields into References obj
Refactored InsertObjectProcedureHandler and UpdateObjectProcedureHandler to access RESULT_REFERENCE1-5 via the new Result.References object instead of individual fields. This improves encapsulation and organizes related reference data within the Result model.
2026-04-15 12:54:51 +02:00
4cc8d22756 Update InsertResultCommand Info property to use integer
Changed the Info property in InsertResultCommand initialization from a string ("200") to an integer (200) to match the expected data type. This ensures type consistency in the test setup.
2026-04-15 11:48:38 +02:00
2a4378eb9a Set Info to numeric HTTP status code in result object
Changed the Info property to store the raw (short) HTTP status code
from response.StatusCode instead of using the formatted output from
ResultView.FormatHttpStatusInfo. This provides a simpler, numeric
representation of the HTTP status in the result.
2026-04-15 11:48:20 +02:00
cb5bbfb722 Change Info to short? and update DB param to pRESULT_INFO_ID
Changed UpdateResultDto.Info from string? to short? for type safety. Updated UpdateObjectProcedureHandler to use pRESULT_INFO_ID with SqlDbType.SmallInt, reflecting the new type.
2026-04-15 11:46:40 +02:00
2736a78d4f Change Info from string to short and update parameter name
Changed the Info property in InsertResultCommand from string? to short?.
Renamed the related parameter in InsertObjectProcedureHandler from pRESULT_INFO to pRESULT_INFO_ID and set its type to SqlDbType.SmallInt to match the new property type.
2026-04-15 11:45:46 +02:00
55 changed files with 746 additions and 186 deletions

View File

@@ -14,13 +14,18 @@ public class RecActionController(IMediator mediator) : ControllerBase
/// Invokes a batch of RecActions for a given profile. /// Invokes a batch of RecActions for a given profile.
/// </summary> /// </summary>
/// <param name="profileId">The identifier of the profile whose RecActions should be invoked.</param> /// <param name="profileId">The identifier of the profile whose RecActions should be invoked.</param>
/// <param name="references">Optional reference values that are passed through to all result records.</param>
/// <param name="cancel">A token to cancel the operation.</param> /// <param name="cancel">A token to cancel the operation.</param>
/// <returns>An HTTP 202 Accepted response indicating the process has been started.</returns> /// <returns>An HTTP 202 Accepted response indicating the process has been started.</returns>
[HttpPost("invoke/{profileId}")] [HttpPost("invoke/{profileId}")]
[ProducesResponseType(StatusCodes.Status202Accepted)] [ProducesResponseType(StatusCodes.Status202Accepted)]
public async Task<IActionResult> Invoke([FromRoute] long profileId, CancellationToken cancel) public async Task<IActionResult> Invoke([FromRoute] long profileId, [FromBody] InvokeReferences references, CancellationToken cancel = default)
{ {
await mediator.Send(new InvokeBatchRecActionViewsCommand { ProfileId = profileId }, cancel); await mediator.Send(new InvokeBatchRecActionViewsCommand
{
ProfileId = profileId,
References = references
}, cancel);
return Accepted(); return Accepted();
} }

View File

@@ -1,8 +0,0 @@
namespace ReC.API.Models;
public class FakeRequest
{
public Dictionary<string, object>? Body { get; init; }
public Dictionary<string, object>? Header { get; init; }
}

View File

@@ -1,17 +0,0 @@
namespace ReC.API.Models;
public enum ResultType
{
/// <summary>
/// Return the full result object.
/// </summary>
Full,
/// <summary>
/// Return only the header part of the result.
/// </summary>
OnlyHeader,
/// <summary>
/// Return only the body part of the result.
/// </summary>
OnlyBody
}

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

@@ -9,7 +9,7 @@
<LaunchSiteAfterPublish>true</LaunchSiteAfterPublish> <LaunchSiteAfterPublish>true</LaunchSiteAfterPublish>
<ExcludeApp_Data>false</ExcludeApp_Data> <ExcludeApp_Data>false</ExcludeApp_Data>
<ProjectGuid>420218ad-3c27-4003-9a84-36c92352f175</ProjectGuid> <ProjectGuid>420218ad-3c27-4003-9a84-36c92352f175</ProjectGuid>
<DesktopBuildPackageLocation>P:\Install .Net\0 DD - Smart UP\ReC\API\$(Version)\Rec.API.zip</DesktopBuildPackageLocation> <DesktopBuildPackageLocation>M:\App&amp;Service\0 DD - Smart UP\ReC\API\$(Version)\Rec.API.zip</DesktopBuildPackageLocation>
<PackageAsSingleFile>true</PackageAsSingleFile> <PackageAsSingleFile>true</PackageAsSingleFile>
<DeployIisAppPath>Rec.API</DeployIisAppPath> <DeployIisAppPath>Rec.API</DeployIisAppPath>
<_TargetId>IISWebDeployPackage</_TargetId> <_TargetId>IISWebDeployPackage</_TargetId>

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.1.0-beta</Version> <Version>2.4.0-beta</Version>
<AssemblyVersion>2.1.0.0</AssemblyVersion> <AssemblyVersion>2.4.0.0</AssemblyVersion>
<FileVersion>2.1.0.0</FileVersion> <FileVersion>2.4.0.0</FileVersion>
<InformationalVersion>2.1.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;
}
private async Task SetHeader(RecActionViewDto action, CancellationToken cancel)
{
if (action.HeaderQuery is not string headerQuery)
return;
await using var command = dbContext.Database.GetDbConnection().CreateCommand();
command.CommandText = headerQuery;
await dbContext.Database.OpenConnectionAsync(cancel);
try
{ {
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); 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}");
}
return await next(cancel); 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());
} }
finally
var headerDict = JsonSerializer.Deserialize<Dictionary<string, JsonElement>>(result.RawHeader);
if(headerDict is null)
{ {
logger?.LogWarning( await dbContext.Database.CloseConnectionAsync();
"Header JSON deserialization returned null. RawHeader: {RawHeader}, ProfileId: {ProfileId}, Id: {Id}",
result.RawHeader, action.ProfileId, action.Id);
return await next(cancel);
} }
action.Headers = headerDict.ToDictionary(header => header.Key, kvp => kvp.Value.ToString());
return await next(cancel);
} }
} }

View File

@@ -26,7 +26,8 @@ public class PostprocessingBehavior(IRecDbContext context, ISender sender) : IPi
Status = RecStatus.OK, Status = RecStatus.OK,
ActionId = request.Action.Id, ActionId = request.Action.Id,
InfoDetail = info, InfoDetail = info,
Type = ResultType.Post Type = ResultType.Post,
References = request.References
}, cancel); }, cancel);
} }
} }
@@ -39,7 +40,8 @@ public class PostprocessingBehavior(IRecDbContext context, ISender sender) : IPi
Status = RecStatus.Error, Status = RecStatus.Error,
ActionId = request.Action.Id, ActionId = request.Action.Id,
Error = error, Error = error,
Type = ResultType.Post Type = ResultType.Post,
References = request.References
}, cancel); }, cancel);
if (request.Action.ErrorAction == ErrorAction.Stop) if (request.Action.ErrorAction == ErrorAction.Stop)

View File

@@ -23,7 +23,8 @@ public class PreprocessingBehavior(IRecDbContext context, ISender sender) : IPip
Status = RecStatus.OK, Status = RecStatus.OK,
ActionId = request.Action.Id, ActionId = request.Action.Id,
InfoDetail = JsonSerializer.Serialize(result), InfoDetail = JsonSerializer.Serialize(result),
Type = ResultType.Pre Type = ResultType.Pre,
References = request.References
}, cancel); }, cancel);
} }
} }
@@ -34,7 +35,8 @@ public class PreprocessingBehavior(IRecDbContext context, ISender sender) : IPip
Status = RecStatus.Error, Status = RecStatus.Error,
ActionId = request.Action.Id, ActionId = request.Action.Id,
Error = ex.ToString(), Error = ex.ToString(),
Type = ResultType.Pre Type = ResultType.Pre,
References = request.References
}, cancel); }, cancel);
if (request.Action.ErrorAction == ErrorAction.Stop) if (request.Action.ErrorAction == ErrorAction.Stop)

View File

@@ -30,6 +30,8 @@ public record ResultViewDto
public string? Error { get; set; } public string? Error { get; set; }
public string? BatchId { get; set; }
public string? AddedWho { get; init; } public string? AddedWho { get; init; }
public DateTime? AddedWhen { get; init; } public DateTime? AddedWhen { get; init; }

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

@@ -11,9 +11,9 @@ namespace ReC.Application.Common.Procedures.DeleteProcedure;
public record DeleteObjectProcedure : IRequest<int> public record DeleteObjectProcedure : IRequest<int>
{ {
/// <summary> /// <summary>
/// Target entity: ACTION, ENDPOINT, ENDPOINT_AUTH, ENDPOINT_PARAMS, PROFILE, RESULT /// Target entity for the delete operation.
/// </summary> /// </summary>
public string Entity { get; set; } = null!; public required EntityType Entity { get; set; }
/// <summary> /// <summary>
/// Start GUID/ID (inclusive) /// Start GUID/ID (inclusive)

View File

@@ -0,0 +1,51 @@
namespace ReC.Application.Common.Procedures;
/// <summary>
/// Represents the target entity type for stored procedure operations (Insert, Update, Delete).
/// </summary>
public enum EntityType
{
/// <summary>
/// A scheduled or configured action within a profile that invokes an endpoint (maps to TBREC_CFG_ACTION).
/// </summary>
Action,
/// <summary>
/// A REST endpoint URI configuration (maps to TBREC_CFG_ENDPOINT).
/// </summary>
Endpoint,
/// <summary>
/// Authentication credentials for an endpoint such as API key, Bearer token, or NTLM (maps to TBREC_CFG_ENDPOINT_AUTH).
/// </summary>
EndpointAuth,
/// <summary>
/// Key-value parameters attached to an endpoint (maps to TBREC_CFG_ENDPOINT_PARAMS).
/// </summary>
EndpointParams,
/// <summary>
/// A profile that groups one or more actions and defines execution settings (maps to TBREC_CFG_PROFILE).
/// </summary>
Profile,
/// <summary>
/// The outcome of an action invocation including HTTP status, headers, body, and error details (maps to TBREC_OUT_RESULT).
/// </summary>
Result
}
public static class EntityTypeExtensions
{
public static string ToDbString(this EntityType entityType) => entityType switch
{
EntityType.Action => "ACTION",
EntityType.Endpoint => "ENDPOINT",
EntityType.EndpointAuth => "ENDPOINT_AUTH",
EntityType.EndpointParams => "ENDPOINT_PARAMS",
EntityType.Profile => "PROFILE",
EntityType.Result => "RESULT",
_ => throw new ArgumentOutOfRangeException(nameof(entityType), $"Not expected entity type value: {entityType}")
};
}

View File

@@ -18,9 +18,9 @@ namespace ReC.Application.Common.Procedures.InsertProcedure;
public record InsertObjectProcedure : IRequest<long> public record InsertObjectProcedure : IRequest<long>
{ {
/// <summary> /// <summary>
/// Target entity: ACTION, ENDPOINT, ENDPOINT_AUTH, ENDPOINT_PARAMS, PROFILE, RESULT /// Target entity for the insert operation.
/// </summary> /// </summary>
public string Entity { get; set; } = null!; public required EntityType Entity { get; set; }
//TODO: update to set in authentication middleware or similar, and remove from procedure properties //TODO: update to set in authentication middleware or similar, and remove from procedure properties
internal string? AddedWho { get; private set; } = "ReC.API"; internal string? AddedWho { get; private set; } = "ReC.API";
@@ -76,18 +76,19 @@ public class InsertObjectProcedureHandler(IRepository repo, IOptionsMonitor<SqlE
.Add("pPROFILE_LOG_LEVEL_ID", request.Profile?.LogLevelId, SqlDbType.TinyInt) .Add("pPROFILE_LOG_LEVEL_ID", request.Profile?.LogLevelId, SqlDbType.TinyInt)
.Add("pPROFILE_LANGUAGE_ID", request.Profile?.LanguageId, SqlDbType.SmallInt) .Add("pPROFILE_LANGUAGE_ID", request.Profile?.LanguageId, SqlDbType.SmallInt)
.Add("pRESULT_ACTION_ID", request.Result?.ActionId) .Add("pRESULT_ACTION_ID", request.Result?.ActionId)
.Add("pRESULT_STATUS_ID", request.Result?.Status, SqlDbType.SmallInt) .Add("pRESULT_STATUS_ID", request.Result?.Status, SqlDbType.TinyInt)
.Add("pRESULT_TYPE_ID", request.Result?.Type is not null ? (byte)request.Result.Type : null, SqlDbType.TinyInt) .Add("pRESULT_TYPE_ID", request.Result?.Type is not null ? (byte)request.Result.Type : null, SqlDbType.TinyInt)
.Add("pRESULT_HEADER", request.Result?.Header) .Add("pRESULT_HEADER", request.Result?.Header)
.Add("pRESULT_BODY", request.Result?.Body) .Add("pRESULT_BODY", request.Result?.Body)
.Add("pRESULT_INFO", request.Result?.Info) .Add("pRESULT_INFO_ID", request.Result?.Info, SqlDbType.SmallInt)
.Add("pRESULT_INFO_DETAIL", request.Result?.InfoDetail) .Add("pRESULT_INFO_DETAIL", request.Result?.InfoDetail)
.Add("pRESULT_ERROR", request.Result?.Error) .Add("pRESULT_ERROR", request.Result?.Error)
.Add("pRESULT_REFERENCE1", request.Result?.Reference1) .Add("pRESULT_BATCH_ID", request.Result?.References?.BatchId)
.Add("pRESULT_REFERENCE2", request.Result?.Reference2) .Add("pRESULT_REFERENCE1", request.Result?.References?.Reference1)
.Add("pRESULT_REFERENCE3", request.Result?.Reference3) .Add("pRESULT_REFERENCE2", request.Result?.References?.Reference2)
.Add("pRESULT_REFERENCE4", request.Result?.Reference4) .Add("pRESULT_REFERENCE3", request.Result?.References?.Reference3)
.Add("pRESULT_REFERENCE5", request.Result?.Reference5) .Add("pRESULT_REFERENCE4", request.Result?.References?.Reference4)
.Add("pRESULT_REFERENCE5", request.Result?.References?.Reference5)
.Add("pENDPOINT_PARAMS_ACTIVE", request.EndpointParams?.Active) .Add("pENDPOINT_PARAMS_ACTIVE", request.EndpointParams?.Active)
.Add("pENDPOINT_PARAMS_DESCRIPTION", request.EndpointParams?.Description) .Add("pENDPOINT_PARAMS_DESCRIPTION", request.EndpointParams?.Description)
.Add("pENDPOINT_PARAMS_GROUP_ID", request.EndpointParams?.GroupId, SqlDbType.SmallInt) .Add("pENDPOINT_PARAMS_GROUP_ID", request.EndpointParams?.GroupId, SqlDbType.SmallInt)

View File

@@ -1,4 +1,5 @@
using Microsoft.Data.SqlClient; using Microsoft.Data.SqlClient;
using Microsoft.EntityFrameworkCore.Metadata.Internal;
using System.Data; using System.Data;
using System.Text; using System.Text;
@@ -19,6 +20,9 @@ internal sealed class StoredProcedureBuilder(string procedureName, string? retur
_execSql.AppendLine($"{_separator}@{name} = @{name}"); _execSql.AppendLine($"{_separator}@{name} = @{name}");
_separator = ','; _separator = ',';
if (!dbType.HasValue && value is DateTime)
dbType = SqlDbType.DateTime;
if (dbType.HasValue) if (dbType.HasValue)
_parameters.Add(new SqlParameter($"@{name}", dbType.Value) { Value = value }); _parameters.Add(new SqlParameter($"@{name}", dbType.Value) { Value = value });
else else
@@ -27,6 +31,22 @@ internal sealed class StoredProcedureBuilder(string procedureName, string? retur
return this; return this;
} }
public StoredProcedureBuilder Add(string name, EntityType entityType)
{
var entityTypeStr = entityType switch
{
EntityType.Action => "ACTION",
EntityType.Endpoint => "ENDPOINT",
EntityType.EndpointAuth => "ENDPOINT_AUTH",
EntityType.EndpointParams => "ENDPOINT_PARAMS",
EntityType.Profile => "PROFILE",
EntityType.Result => "RESULT",
_ => throw new ArgumentOutOfRangeException(nameof(entityType), $"Not expected entity type value: {entityType}")
};
return Add(name, entityTypeStr);
}
public StoredProcedureBuilder AddOutput(string name, SqlDbType dbType) public StoredProcedureBuilder AddOutput(string name, SqlDbType dbType)
{ {
_execSql.AppendLine($"{_separator}@{name} = @{name} OUTPUT"); _execSql.AppendLine($"{_separator}@{name} = @{name} OUTPUT");

View File

@@ -1,4 +1,6 @@
namespace ReC.Application.Common.Procedures.UpdateProcedure.Dto; using ReC.Application.RecActions.Commands;
namespace ReC.Application.Common.Procedures.UpdateProcedure.Dto;
public record UpdateResultDto public record UpdateResultDto
{ {
@@ -6,12 +8,8 @@ public record UpdateResultDto
public short? StatusId { get; set; } public short? StatusId { get; set; }
public string? Header { get; set; } public string? Header { get; set; }
public string? Body { get; set; } public string? Body { get; set; }
public string? Info { get; set; } public short? Info { get; set; }
public string? InfoDetail { get; set; } public string? InfoDetail { get; set; }
public string? Error { get; set; } public string? Error { get; set; }
public string? Reference1 { get; set; } public InvokeReferences? References { get; set; }
public string? Reference2 { get; set; }
public string? Reference3 { get; set; }
public string? Reference4 { get; set; }
public string? Reference5 { get; set; }
} }

View File

@@ -13,9 +13,9 @@ namespace ReC.Application.Common.Procedures.UpdateProcedure;
public record UpdateObjectProcedure : IRequest<int> public record UpdateObjectProcedure : IRequest<int>
{ {
/// <summary> /// <summary>
/// Target entity: ACTION, ENDPOINT, ENDPOINT_AUTH, ENDPOINT_PARAMS, PROFILE, RESULT /// Target entity for the update operation.
/// </summary> /// </summary>
public string Entity { get; set; } = null!; public required EntityType Entity { get; set; }
/// <summary> /// <summary>
/// Target GUID to update (required) /// Target GUID to update (required)
@@ -86,17 +86,18 @@ public class UpdateObjectProcedureHandler(IRepository repo, IOptionsMonitor<SqlE
.Add("pPROFILE_LAST_RUN", request.Profile.LastRun) .Add("pPROFILE_LAST_RUN", request.Profile.LastRun)
.Add("pPROFILE_LAST_RESULT", request.Profile.LastResult) .Add("pPROFILE_LAST_RESULT", request.Profile.LastResult)
.Add("pRESULT_ACTION_ID", request.Result.ActionId) .Add("pRESULT_ACTION_ID", request.Result.ActionId)
.Add("pRESULT_STATUS_ID", request.Result.StatusId, SqlDbType.SmallInt) .Add("pRESULT_STATUS_ID", request.Result.StatusId, SqlDbType.TinyInt)
.Add("pRESULT_HEADER", request.Result.Header) .Add("pRESULT_HEADER", request.Result.Header)
.Add("pRESULT_BODY", request.Result.Body) .Add("pRESULT_BODY", request.Result.Body)
.Add("pRESULT_INFO", request.Result.Info) .Add("pRESULT_INFO_ID", request.Result.Info, SqlDbType.SmallInt)
.Add("pRESULT_INFO_DETAIL", request.Result.InfoDetail) .Add("pRESULT_INFO_DETAIL", request.Result.InfoDetail)
.Add("pRESULT_ERROR", request.Result.Error) .Add("pRESULT_ERROR", request.Result.Error)
.Add("pRESULT_REFERENCE1", request.Result.Reference1) .Add("pRESULT_BATCH_ID", request.Result.References?.BatchId)
.Add("pRESULT_REFERENCE2", request.Result.Reference2) .Add("pRESULT_REFERENCE1", request.Result.References?.Reference1)
.Add("pRESULT_REFERENCE3", request.Result.Reference3) .Add("pRESULT_REFERENCE2", request.Result.References?.Reference2)
.Add("pRESULT_REFERENCE4", request.Result.Reference4) .Add("pRESULT_REFERENCE3", request.Result.References?.Reference3)
.Add("pRESULT_REFERENCE5", request.Result.Reference5); .Add("pRESULT_REFERENCE4", request.Result.References?.Reference4)
.Add("pRESULT_REFERENCE5", request.Result.References?.Reference5);
try try
{ {

View File

@@ -0,0 +1,22 @@
using FluentValidation;
using ReC.Application.Common.Procedures.DeleteProcedure;
namespace ReC.Application.Common.Validations;
public class DeleteObjectProcedureValidator : AbstractValidator<DeleteObjectProcedure>
{
public DeleteObjectProcedureValidator()
{
RuleFor(x => x.Entity)
.IsInEnum()
.WithMessage("ENTITY must be a valid EntityType value.");
RuleFor(x => x.Start)
.GreaterThan(0)
.WithMessage("Start GUID/ID must be greater than 0.");
RuleFor(x => x.End)
.GreaterThanOrEqualTo(x => x.Start)
.WithMessage("End GUID/ID must be greater than or equal to Start GUID/ID.");
}
}

View File

@@ -7,11 +7,9 @@ public class InsertObjectProcedureValidator : AbstractValidator<InsertObjectProc
{ {
public InsertObjectProcedureValidator() public InsertObjectProcedureValidator()
{ {
// ENTITY must be one of the allowed values
RuleFor(x => x.Entity) RuleFor(x => x.Entity)
.NotEmpty() .IsInEnum()
.Must(e => e is "ACTION" or "ENDPOINT" or "ENDPOINT_AUTH" or "ENDPOINT_PARAMS" or "PROFILE" or "RESULT") .WithMessage("ENTITY must be a valid EntityType value.");
.WithMessage("ENTITY must be one of: ACTION, ENDPOINT, ENDPOINT_AUTH, ENDPOINT_PARAMS, PROFILE, RESULT.");
// ACTION validation // ACTION validation
When(x => x.Action != null, () => When(x => x.Action != null, () =>

View File

@@ -0,0 +1,21 @@
using FluentValidation;
using ReC.Application.Results.Commands;
namespace ReC.Application.Common.Validations;
public class InsertResultCommandValidator : AbstractValidator<InsertResultCommand>
{
public InsertResultCommandValidator()
{
RuleFor(x => x.ActionId)
.NotNull()
.WithMessage("ActionId is required.")
.GreaterThan(0L)
.When(x => x.ActionId.HasValue)
.WithMessage("ActionId must be greater than 0.");
RuleFor(x => x.References.BatchId)
.NotEmpty()
.WithMessage("BatchId is required.");
}
}

View File

@@ -0,0 +1,22 @@
using FluentValidation;
using MediatR;
using ReC.Application.RecActions.Commands;
using ReC.Application.Results.Queries;
namespace ReC.Application.Common.Validations;
public class InvokeBatchRecActionViewsCommandValidator : AbstractValidator<InvokeBatchRecActionViewsCommand>
{
public InvokeBatchRecActionViewsCommandValidator(ISender sender)
{
RuleFor(x => x.References.BatchId)
.NotEmpty()
.WithMessage("BatchId is required.")
.MustAsync(async (batchId, cancel) =>
{
var any = await sender.Send(new AnyResultViewQuery(BatchId: batchId), cancel);
return !any;
})
.WithMessage(x => $"Cannot invoke rec actions for batch '{x.References.BatchId}' because there are already results associated with it.");
}
}

View File

@@ -0,0 +1,15 @@
using FluentValidation;
using ReC.Application.Profile.Queries;
namespace ReC.Application.Common.Validations;
public class ReadProfileViewQueryValidator : AbstractValidator<ReadProfileViewQuery>
{
public ReadProfileViewQueryValidator()
{
RuleFor(x => x.Id)
.GreaterThan(0)
.When(x => x.Id.HasValue)
.WithMessage("Id must be greater than 0.");
}
}

View File

@@ -0,0 +1,15 @@
using FluentValidation;
using ReC.Application.RecActions.Queries;
namespace ReC.Application.Common.Validations;
public class ReadRecActionViewQueryValidator : AbstractValidator<ReadRecActionViewQuery>
{
public ReadRecActionViewQueryValidator()
{
RuleFor(x => x.ProfileId)
.GreaterThan(0)
.When(x => x.ProfileId.HasValue)
.WithMessage("ProfileId must be greater than 0.");
}
}

View File

@@ -0,0 +1,34 @@
using FluentValidation;
using ReC.Application.Results.Queries;
namespace ReC.Application.Common.Validations;
public class ReadResultViewQueryValidator : AbstractValidator<ReadResultViewQuery>
{
public ReadResultViewQueryValidator()
{
RuleFor(x => x)
.Must(x => x.Id.HasValue || x.ActionId.HasValue || x.ProfileId.HasValue || x.BatchId is not null)
.WithMessage("At least one filter (Id, ActionId, ProfileId or BatchId) must be provided.");
RuleFor(x => x.Id)
.GreaterThan(0)
.When(x => x.Id.HasValue)
.WithMessage("Id must be greater than 0.");
RuleFor(x => x.ActionId)
.GreaterThan(0)
.When(x => x.ActionId.HasValue)
.WithMessage("ActionId must be greater than 0.");
RuleFor(x => x.ProfileId)
.GreaterThan(0)
.When(x => x.ProfileId.HasValue)
.WithMessage("ProfileId must be greater than 0.");
RuleFor(x => x.BatchId)
.NotEmpty()
.When(x => x.BatchId is not null)
.WithMessage("BatchId must not be empty when provided.");
}
}

View File

@@ -0,0 +1,52 @@
using FluentValidation;
using ReC.Application.Common.Procedures.UpdateProcedure;
namespace ReC.Application.Common.Validations;
public class UpdateObjectProcedureValidator : AbstractValidator<UpdateObjectProcedure>
{
public UpdateObjectProcedureValidator()
{
RuleFor(x => x.Entity)
.IsInEnum()
.WithMessage("ENTITY must be a valid EntityType value.");
RuleFor(x => x.Id)
.GreaterThan(0)
.WithMessage("Target GUID/ID must be greater than 0.");
RuleFor(x => x.ChangedWho)
.MaximumLength(50)
.When(x => x.ChangedWho != null);
When(x => x.Endpoint is { Description: not null }, () =>
{
RuleFor(x => x.Endpoint.Description)
.MaximumLength(250);
});
When(x => x.EndpointAuth is { Description: not null }, () =>
{
RuleFor(x => x.EndpointAuth.Description)
.MaximumLength(250);
});
When(x => x.Profile is { Name: not null }, () =>
{
RuleFor(x => x.Profile.Name)
.MaximumLength(50);
});
When(x => x.Profile is { Mandantor: not null }, () =>
{
RuleFor(x => x.Profile.Mandantor)
.MaximumLength(50);
});
When(x => x.Profile is { Description: not null }, () =>
{
RuleFor(x => x.Profile.Description)
.MaximumLength(250);
});
}
}

View File

@@ -1,5 +1,6 @@
using MediatR; using MediatR;
using ReC.Application.Common.Procedures.DeleteProcedure; using ReC.Application.Common.Procedures.DeleteProcedure;
using ReC.Application.Common.Procedures;
namespace ReC.Application.EndpointAuth.Commands; namespace ReC.Application.EndpointAuth.Commands;
@@ -27,7 +28,7 @@ public class DeleteEndpointAuthProcedureHandler(ISender sender) : IRequestHandle
{ {
return await sender.Send(new DeleteObjectProcedure return await sender.Send(new DeleteObjectProcedure
{ {
Entity = "ENDPOINT_AUTH", Entity = EntityType.EndpointAuth,
Start = request.Start, Start = request.Start,
End = request.End, End = request.End,
Force = request.Force Force = request.Force

View File

@@ -1,5 +1,6 @@
using MediatR; using MediatR;
using ReC.Application.Common.Procedures.InsertProcedure; using ReC.Application.Common.Procedures.InsertProcedure;
using ReC.Application.Common.Procedures;
namespace ReC.Application.EndpointAuth.Commands; namespace ReC.Application.EndpointAuth.Commands;
@@ -24,7 +25,7 @@ public class InsertEndpointAuthProcedureHandler(ISender sender) : IRequestHandle
{ {
return await sender.Send(new InsertObjectProcedure return await sender.Send(new InsertObjectProcedure
{ {
Entity = "ENDPOINT_AUTH", Entity = EntityType.EndpointAuth,
EndpointAuth = request EndpointAuth = request
}, cancel); }, cancel);
} }

View File

@@ -1,6 +1,7 @@
using MediatR; using MediatR;
using ReC.Application.Common.Procedures.UpdateProcedure; using ReC.Application.Common.Procedures.UpdateProcedure;
using ReC.Application.Common.Procedures.UpdateProcedure.Dto; using ReC.Application.Common.Procedures.UpdateProcedure.Dto;
using ReC.Application.Common.Procedures;
namespace ReC.Application.EndpointAuth.Commands; namespace ReC.Application.EndpointAuth.Commands;
@@ -17,7 +18,7 @@ public class UpdateEndpointAuthProcedureHandler(ISender sender) : IRequestHandle
{ {
return await sender.Send(new UpdateObjectProcedure return await sender.Send(new UpdateObjectProcedure
{ {
Entity = "ENDPOINT_AUTH", Entity = EntityType.EndpointAuth,
Id = request.Id, Id = request.Id,
EndpointAuth = request.Data EndpointAuth = request.Data
}, cancel); }, cancel);

View File

@@ -1,5 +1,6 @@
using MediatR; using MediatR;
using ReC.Application.Common.Procedures.DeleteProcedure; using ReC.Application.Common.Procedures.DeleteProcedure;
using ReC.Application.Common.Procedures;
namespace ReC.Application.EndpointParams.Commands; namespace ReC.Application.EndpointParams.Commands;
@@ -27,7 +28,7 @@ public class DeleteEndpointParamsProcedureHandler(ISender sender) : IRequestHand
{ {
return await sender.Send(new DeleteObjectProcedure return await sender.Send(new DeleteObjectProcedure
{ {
Entity = "ENDPOINT_PARAMS", Entity = EntityType.EndpointParams,
Start = request.Start, Start = request.Start,
End = request.End, End = request.End,
Force = request.Force Force = request.Force

View File

@@ -1,5 +1,6 @@
using MediatR; using MediatR;
using ReC.Application.Common.Procedures.InsertProcedure; using ReC.Application.Common.Procedures.InsertProcedure;
using ReC.Application.Common.Procedures;
namespace ReC.Application.EndpointParams.Commands; namespace ReC.Application.EndpointParams.Commands;
@@ -19,7 +20,7 @@ public class InsertEndpointParamsProcedureHandler(ISender sender) : IRequestHand
{ {
return await sender.Send(new InsertObjectProcedure return await sender.Send(new InsertObjectProcedure
{ {
Entity = "ENDPOINT_PARAMS", Entity = EntityType.EndpointParams,
EndpointParams = request EndpointParams = request
}, cancel); }, cancel);
} }

View File

@@ -1,6 +1,7 @@
using MediatR; using MediatR;
using ReC.Application.Common.Procedures.UpdateProcedure; using ReC.Application.Common.Procedures.UpdateProcedure;
using ReC.Application.Common.Procedures.UpdateProcedure.Dto; using ReC.Application.Common.Procedures.UpdateProcedure.Dto;
using ReC.Application.Common.Procedures;
namespace ReC.Application.EndpointParams.Commands; namespace ReC.Application.EndpointParams.Commands;
@@ -17,7 +18,7 @@ public class UpdateEndpointParamsProcedureHandler(ISender sender) : IRequestHand
{ {
return await sender.Send(new UpdateObjectProcedure return await sender.Send(new UpdateObjectProcedure
{ {
Entity = "ENDPOINT_PARAMS", Entity = EntityType.EndpointParams,
Id = request.Id, Id = request.Id,
EndpointParams = request.Data EndpointParams = request.Data
}, cancel); }, cancel);

View File

@@ -1,5 +1,6 @@
using MediatR; using MediatR;
using ReC.Application.Common.Procedures.DeleteProcedure; using ReC.Application.Common.Procedures.DeleteProcedure;
using ReC.Application.Common.Procedures;
namespace ReC.Application.Endpoints.Commands; namespace ReC.Application.Endpoints.Commands;
@@ -27,7 +28,7 @@ public class DeleteEndpointProcedureHandler(ISender sender) : IRequestHandler<De
{ {
return await sender.Send(new DeleteObjectProcedure return await sender.Send(new DeleteObjectProcedure
{ {
Entity = "ENDPOINT", Entity = EntityType.Endpoint,
Start = request.Start, Start = request.Start,
End = request.End, End = request.End,
Force = request.Force Force = request.Force

View File

@@ -1,5 +1,6 @@
using MediatR; using MediatR;
using ReC.Application.Common.Procedures.InsertProcedure; using ReC.Application.Common.Procedures.InsertProcedure;
using ReC.Application.Common.Procedures;
namespace ReC.Application.Endpoints.Commands; namespace ReC.Application.Endpoints.Commands;
@@ -16,7 +17,7 @@ public class InsertEndpointProcedureHandler(ISender sender) : IRequestHandler<In
{ {
return await sender.Send(new InsertObjectProcedure return await sender.Send(new InsertObjectProcedure
{ {
Entity = "ENDPOINT", Entity = EntityType.Endpoint,
Endpoint = request Endpoint = request
}, cancel); }, cancel);
} }

View File

@@ -1,6 +1,7 @@
using MediatR; using MediatR;
using ReC.Application.Common.Procedures.UpdateProcedure; using ReC.Application.Common.Procedures.UpdateProcedure;
using ReC.Application.Common.Procedures.UpdateProcedure.Dto; using ReC.Application.Common.Procedures.UpdateProcedure.Dto;
using ReC.Application.Common.Procedures;
namespace ReC.Application.Endpoints.Commands; namespace ReC.Application.Endpoints.Commands;
@@ -17,7 +18,7 @@ public class UpdateEndpointProcedureHandler(ISender sender) : IRequestHandler<Up
{ {
return await sender.Send(new UpdateObjectProcedure return await sender.Send(new UpdateObjectProcedure
{ {
Entity = "ENDPOINT", Entity = EntityType.Endpoint,
Id = request.Id, Id = request.Id,
Endpoint = request.Data Endpoint = request.Data
}, cancel); }, cancel);

View File

@@ -1,5 +1,6 @@
using MediatR; using MediatR;
using ReC.Application.Common.Procedures.DeleteProcedure; using ReC.Application.Common.Procedures.DeleteProcedure;
using ReC.Application.Common.Procedures;
namespace ReC.Application.Profile.Commands; namespace ReC.Application.Profile.Commands;
@@ -27,7 +28,7 @@ public class DeleteProfileProcedureHandler(ISender sender) : IRequestHandler<Del
{ {
return await sender.Send(new DeleteObjectProcedure return await sender.Send(new DeleteObjectProcedure
{ {
Entity = "PROFILE", Entity = EntityType.Profile,
Start = request.Start, Start = request.Start,
End = request.End, End = request.End,
Force = request.Force Force = request.Force

View File

@@ -1,5 +1,6 @@
using MediatR; using MediatR;
using ReC.Application.Common.Procedures.InsertProcedure; using ReC.Application.Common.Procedures.InsertProcedure;
using ReC.Application.Common.Procedures;
namespace ReC.Application.Profile.Commands; namespace ReC.Application.Profile.Commands;
@@ -20,7 +21,7 @@ public class InsertProfileProcedureHandler(ISender sender) : IRequestHandler<Ins
{ {
return await sender.Send(new InsertObjectProcedure return await sender.Send(new InsertObjectProcedure
{ {
Entity = "PROFILE", Entity = EntityType.Profile,
Profile = request Profile = request
}, cancel); }, cancel);
} }

View File

@@ -1,6 +1,7 @@
using MediatR; using MediatR;
using ReC.Application.Common.Procedures.UpdateProcedure; using ReC.Application.Common.Procedures.UpdateProcedure;
using ReC.Application.Common.Procedures.UpdateProcedure.Dto; using ReC.Application.Common.Procedures.UpdateProcedure.Dto;
using ReC.Application.Common.Procedures;
namespace ReC.Application.Profile.Commands; namespace ReC.Application.Profile.Commands;
@@ -17,7 +18,7 @@ public class UpdateProfileProcedureHandler(ISender sender) : IRequestHandler<Upd
{ {
return await sender.Send(new UpdateObjectProcedure return await sender.Send(new UpdateObjectProcedure
{ {
Entity = "PROFILE", Entity = EntityType.Profile,
Id = request.Id, Id = request.Id,
Profile = request.Data Profile = request.Data
}, cancel); }, cancel);

View File

@@ -1,5 +1,6 @@
using MediatR; using MediatR;
using ReC.Application.Common.Procedures.DeleteProcedure; using ReC.Application.Common.Procedures.DeleteProcedure;
using ReC.Application.Common.Procedures;
namespace ReC.Application.RecActions.Commands; namespace ReC.Application.RecActions.Commands;
@@ -27,7 +28,7 @@ public class DeleteActionProcedureHandler(ISender sender) : IRequestHandler<Dele
{ {
return await sender.Send(new DeleteObjectProcedure return await sender.Send(new DeleteObjectProcedure
{ {
Entity = "ACTION", Entity = EntityType.Action,
Start = request.Start, Start = request.Start,
End = request.End, End = request.End,
Force = request.Force Force = request.Force

View File

@@ -1,6 +1,7 @@
using MediatR; using MediatR;
using ReC.Application.Common.Procedures.InsertProcedure; using ReC.Application.Common.Procedures.InsertProcedure;
using ReC.Domain.Constants; using ReC.Domain.Constants;
using ReC.Application.Common.Procedures;
namespace ReC.Application.RecActions.Commands; namespace ReC.Application.RecActions.Commands;
@@ -27,7 +28,7 @@ public class InsertActionProcedureHandler(ISender sender) : IRequestHandler<Inse
{ {
return await sender.Send(new InsertObjectProcedure return await sender.Send(new InsertObjectProcedure
{ {
Entity = "ACTION", Entity = EntityType.Action,
Action = request Action = request
}, cancel); }, cancel);
} }

View File

@@ -9,6 +9,7 @@ namespace ReC.Application.RecActions.Commands;
public record InvokeBatchRecActionViewsCommand : IRequest public record InvokeBatchRecActionViewsCommand : IRequest
{ {
public long ProfileId { get; init; } public long ProfileId { get; init; }
public required InvokeReferences References { get; init; }
} }
public class InvokeRecActionViewsCommandHandler(ISender sender, ILogger<InvokeRecActionViewsCommandHandler>? logger = null) : IRequestHandler<InvokeBatchRecActionViewsCommand> public class InvokeRecActionViewsCommandHandler(ISender sender, ILogger<InvokeRecActionViewsCommandHandler>? logger = null) : IRequestHandler<InvokeBatchRecActionViewsCommand>
@@ -21,7 +22,11 @@ public class InvokeRecActionViewsCommandHandler(ISender sender, ILogger<InvokeRe
{ {
try try
{ {
await sender.Send(new InvokeRecActionViewCommand() { Action = action }, cancel); await sender.Send(new InvokeRecActionViewCommand()
{
Action = action,
References = request.References
}, cancel);
} }
catch (RecActionException ex) catch (RecActionException ex)
{ {

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;
@@ -18,13 +19,25 @@ namespace ReC.Application.RecActions.Commands;
public record InvokeRecActionViewCommand : IRequest public record InvokeRecActionViewCommand : IRequest
{ {
public RecActionViewDto Action { get; set; } = null!; public RecActionViewDto Action { get; set; } = null!;
public required InvokeReferences References { get; set; }
}
public record InvokeReferences
{
public required string BatchId { get; init; }
public string? Reference1 { get; init; }
public string? Reference2 { get; init; }
public string? Reference3 { get; init; }
public string? Reference4 { get; init; }
public string? Reference5 { get; init; }
} }
public class InvokeRecActionViewCommandHandler( 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;
@@ -46,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)
{ {
@@ -63,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!);
@@ -86,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;
@@ -149,8 +213,9 @@ public class InvokeRecActionViewCommandHandler(
ActionId = action.Id, ActionId = action.Id,
Header = JsonSerializer.Serialize(resHeaders, options: new() { WriteIndented = false }), Header = JsonSerializer.Serialize(resHeaders, options: new() { WriteIndented = false }),
Body = resBody, Body = resBody,
Info = ResultView.FormatHttpStatusInfo(response.StatusCode), Info = (short)response.StatusCode,
Type = ResultType.Main Type = ResultType.Main,
References = request.References
}, cancel); }, cancel);
} }
catch(Exception ex) catch(Exception ex)
@@ -160,7 +225,8 @@ public class InvokeRecActionViewCommandHandler(
Status = RecStatus.Error, Status = RecStatus.Error,
ActionId = action.Id, ActionId = action.Id,
Error = ex.ToString(), Error = ex.ToString(),
Type = ResultType.Main Type = ResultType.Main,
References = request.References
}, cancel); }, cancel);
if (action.ErrorAction == ErrorAction.Stop) if (action.ErrorAction == ErrorAction.Stop)

View File

@@ -1,6 +1,7 @@
using MediatR; using MediatR;
using ReC.Application.Common.Procedures.UpdateProcedure; using ReC.Application.Common.Procedures.UpdateProcedure;
using ReC.Application.Common.Procedures.UpdateProcedure.Dto; using ReC.Application.Common.Procedures.UpdateProcedure.Dto;
using ReC.Application.Common.Procedures;
namespace ReC.Application.RecActions.Commands; namespace ReC.Application.RecActions.Commands;
@@ -17,7 +18,7 @@ public class UpdateActionProcedureHandler(ISender sender) : IRequestHandler<Upda
{ {
return await sender.Send(new UpdateObjectProcedure return await sender.Send(new UpdateObjectProcedure
{ {
Entity = "ACTION", Entity = EntityType.Action,
Id = request.Id, Id = request.Id,
Action = request.Data Action = request.Data
}, cancel); }, cancel);

View File

@@ -1,5 +1,6 @@
using MediatR; using MediatR;
using ReC.Application.Common.Procedures.DeleteProcedure; using ReC.Application.Common.Procedures.DeleteProcedure;
using ReC.Application.Common.Procedures;
namespace ReC.Application.Results.Commands; namespace ReC.Application.Results.Commands;
@@ -27,7 +28,7 @@ public class DeleteResultProcedureHandler(ISender sender) : IRequestHandler<Dele
{ {
return await sender.Send(new DeleteObjectProcedure return await sender.Send(new DeleteObjectProcedure
{ {
Entity = "RESULT", Entity = EntityType.Result,
Start = request.Start, Start = request.Start,
End = request.End, End = request.End,
Force = request.Force Force = request.Force

View File

@@ -1,6 +1,8 @@
using MediatR; using MediatR;
using ReC.Application.Common.Procedures.InsertProcedure; using ReC.Application.Common.Procedures.InsertProcedure;
using ReC.Application.RecActions.Commands;
using ReC.Domain.Constants; using ReC.Domain.Constants;
using ReC.Application.Common.Procedures;
namespace ReC.Application.Results.Commands; namespace ReC.Application.Results.Commands;
@@ -10,15 +12,11 @@ public record InsertResultCommand : IInsertProcedure
public required RecStatus Status { get; set; } public required RecStatus Status { get; set; }
public string? Header { get; set; } public string? Header { get; set; }
public string? Body { get; set; } public string? Body { get; set; }
public string? Info { get; set; } public short Info { get; set; }
public string? InfoDetail { get; set; } public string? InfoDetail { get; set; }
public string? Error { get; set; } public string? Error { get; set; }
public required ResultType Type { get; set; } public required ResultType Type { get; set; }
public string? Reference1 { get; set; } public required InvokeReferences References { get; set; }
public string? Reference2 { get; set; }
public string? Reference3 { get; set; }
public string? Reference4 { get; set; }
public string? Reference5 { get; set; }
} }
public class InsertResultProcedureHandler(ISender sender) : IRequestHandler<InsertResultCommand, long> public class InsertResultProcedureHandler(ISender sender) : IRequestHandler<InsertResultCommand, long>
@@ -27,7 +25,7 @@ public class InsertResultProcedureHandler(ISender sender) : IRequestHandler<Inse
{ {
return await sender.Send(new InsertObjectProcedure return await sender.Send(new InsertObjectProcedure
{ {
Entity = "RESULT", Entity = EntityType.Result,
Result = request Result = request
}, cancel); }, cancel);
} }

View File

@@ -1,6 +1,7 @@
using MediatR; using MediatR;
using ReC.Application.Common.Procedures.UpdateProcedure; using ReC.Application.Common.Procedures.UpdateProcedure;
using ReC.Application.Common.Procedures.UpdateProcedure.Dto; using ReC.Application.Common.Procedures.UpdateProcedure.Dto;
using ReC.Application.Common.Procedures;
namespace ReC.Application.Results.Commands; namespace ReC.Application.Results.Commands;
@@ -17,7 +18,7 @@ public class UpdateResultProcedureHandler(ISender sender) : IRequestHandler<Upda
{ {
return await sender.Send(new UpdateObjectProcedure return await sender.Send(new UpdateObjectProcedure
{ {
Entity = "RESULT", Entity = EntityType.Result,
Id = request.Id, Id = request.Id,
Result = request.Data Result = request.Data
}, cancel); }, cancel);

View File

@@ -0,0 +1,35 @@
using DigitalData.Core.Abstraction.Application.Repository;
using MediatR;
using Microsoft.EntityFrameworkCore;
using ReC.Domain.Views;
namespace ReC.Application.Results.Queries;
public record AnyResultViewQuery(
long? Id = null,
long? ActionId = null,
long? ProfileId = null,
string? BatchId = null
) : IRequest<bool>;
public class AnyResultViewQueryHandler(IRepository<ResultView> repo) : IRequestHandler<AnyResultViewQuery, bool>
{
public Task<bool> Handle(AnyResultViewQuery request, CancellationToken cancel)
{
var q = repo.Query;
if(request.Id is long id)
q = q.Where(rv => rv.Id == id);
if(request.ActionId is long actionId)
q = q.Where(rv => rv.ActionId == actionId);
if(request.ProfileId is long profileId)
q = q.Where(rv => rv.ProfileId == profileId);
if(request.BatchId is string batchId)
q = q.Where(rv => rv.BatchId == batchId);
return q.AnyAsync(cancel);
}
}

View File

@@ -17,11 +17,13 @@ public record ReadResultViewQuery : IRequest<IEnumerable<ResultViewDto>>
public long? ProfileId { get; init; } = null; public long? ProfileId { get; init; } = null;
public string? BatchId { get; init; } = null;
public bool IncludeAction { get; init; } = true; public bool IncludeAction { get; init; } = true;
public bool IncludeProfile { get; init; } = false; public bool IncludeProfile { get; init; } = false;
public bool Last { get; init; } = false; public bool LastBatch { get; init; } = false;
} }
public class ReadResultViewQueryHandler(IRepository<ResultView> repo, IMapper mapper) : IRequestHandler<ReadResultViewQuery, IEnumerable<ResultViewDto>> public class ReadResultViewQueryHandler(IRepository<ResultView> repo, IMapper mapper) : IRequestHandler<ReadResultViewQuery, IEnumerable<ResultViewDto>>
@@ -39,13 +41,18 @@ public class ReadResultViewQueryHandler(IRepository<ResultView> repo, IMapper ma
if(request.ProfileId is long profileId) if(request.ProfileId is long profileId)
q = q.Where(rv => rv.ProfileId == profileId); q = q.Where(rv => rv.ProfileId == profileId);
if(request.IncludeAction) if(request.BatchId is string batchId)
q = q.Where(rv => rv.BatchId == batchId);
if (request.IncludeAction)
q = q.Include(rv => rv.Action); q = q.Include(rv => rv.Action);
if(request.IncludeProfile) if(request.IncludeProfile)
q = q.Include(rv => rv.Profile); q = q.Include(rv => rv.Profile);
var entities = request.Last ? [await q.OrderBy(rv => rv.AddedWhen).LastOrDefaultAsync(cancel)] : await q.ToListAsync(cancel); var entities = request.LastBatch
? await GetLastBatchEntitiesAsync(q, cancel)
: await q.ToListAsync(cancel);
if (entities.Count == 0) if (entities.Count == 0)
throw new NotFoundException($"No result views found for the given criteria. Criteria: { throw new NotFoundException($"No result views found for the given criteria. Criteria: {
@@ -58,4 +65,20 @@ public class ReadResultViewQueryHandler(IRepository<ResultView> repo, IMapper ma
return mapper.Map<IEnumerable<ResultViewDto>>(entities); return mapper.Map<IEnumerable<ResultViewDto>>(entities);
} }
private static async Task<List<ResultView>> GetLastBatchEntitiesAsync(IQueryable<ResultView> q, CancellationToken cancel)
{
var lastBatchId = await q
.Where(rv => rv.BatchId != null)
.OrderByDescending(rv => rv.AddedWhen)
.Select(rv => rv.BatchId)
.FirstOrDefaultAsync(cancel);
if (lastBatchId is null)
return [];
return await q
.Where(rv => rv.BatchId == lastBatchId)
.ToListAsync(cancel);
}
} }

View File

@@ -0,0 +1,50 @@
namespace ReC.Client.Api
{
/// <summary>
/// Optional reference values that are passed through to all result records when invoking a profile.
/// </summary>
public class InvokeReferences
{
/// <summary>Batch identifier.</summary>
public string
#if NET
?
#endif
BatchId { get; set; }
/// <summary>Reference value 1.</summary>
public string
#if NET
?
#endif
Reference1 { get; set; }
/// <summary>Reference value 2.</summary>
public string
#if NET
?
#endif
Reference2 { get; set; }
/// <summary>Reference value 3.</summary>
public string
#if NET
?
#endif
Reference3 { get; set; }
/// <summary>Reference value 4.</summary>
public string
#if NET
?
#endif
Reference4 { get; set; }
/// <summary>Reference value 5.</summary>
public string
#if NET
?
#endif
Reference5 { get; set; }
}
}

View File

@@ -21,14 +21,28 @@ namespace ReC.Client.Api
/// Invokes a ReC action for the specified profile. /// Invokes a ReC action for the specified profile.
/// </summary> /// </summary>
/// <param name="profileId">The profile identifier.</param> /// <param name="profileId">The profile identifier.</param>
/// <param name="references">Optional reference values to pass through to all result records.</param>
/// <param name="cancellationToken">A token to cancel the operation.</param> /// <param name="cancellationToken">A token to cancel the operation.</param>
/// <returns><see langword="true"/> if the request succeeds; otherwise, <see langword="false"/>.</returns> /// <returns><see langword="true"/> if the request succeeds; otherwise, <see langword="false"/>.</returns>
public async Task<bool> InvokeAsync(int profileId, CancellationToken cancellationToken = default) public async Task<bool> InvokeAsync(int profileId, InvokeReferences references, CancellationToken cancellationToken = default)
{ {
var resp = await Http.PostAsync($"{ResourcePath}/invoke/{profileId}", content: null, cancellationToken); var content = references != null ? ReCClientHelpers.ToJsonContent(references) : null;
var resp = await Http.PostAsync($"{ResourcePath}/invoke/{profileId}", content, cancellationToken);
return resp.IsSuccessStatusCode; return resp.IsSuccessStatusCode;
} }
/// <summary>
/// Invokes a ReC action for the specified profile.
/// </summary>
/// <param name="profileId">The profile identifier.</param>
/// <param name="batchId">Batch identifier.</param>
/// <param name="cancellationToken">A token to cancel the operation.</param>
/// <returns><see langword="true"/> if the request succeeds; otherwise, <see langword="false"/>.</returns>
public Task<bool> InvokeAsync(int profileId, string batchId, CancellationToken cancellationToken = default)
{
return InvokeAsync(profileId, new InvokeReferences() { BatchId = batchId }, cancellationToken);
}
/// <summary> /// <summary>
/// Retrieves Rec actions. /// Retrieves Rec actions.
/// </summary> /// </summary>

View File

@@ -8,7 +8,7 @@
/// </para> /// </para>
/// </summary> /// </summary>
/// <seealso cref="RecStatusExtensions"/> /// <seealso cref="RecStatusExtensions"/>
public enum RecStatus : short public enum RecStatus : byte
{ {
/// <summary> /// <summary>
/// Indicates that the operation completed successfully (value 0). /// Indicates that the operation completed successfully (value 0).

View File

@@ -1,8 +1,6 @@
using ReC.Domain.Constants; using ReC.Domain.Constants;
using System.ComponentModel.DataAnnotations; using System.ComponentModel.DataAnnotations;
using System.ComponentModel.DataAnnotations.Schema; using System.ComponentModel.DataAnnotations.Schema;
using System.Net;
using System.Text.RegularExpressions;
namespace ReC.Domain.Views; namespace ReC.Domain.Views;
@@ -56,6 +54,9 @@ public class ResultView
[Column("RESULT_ERROR")] [Column("RESULT_ERROR")]
public string? Error { get; set; } public string? Error { get; set; }
[Column("BATCH_ID")]
public string? BatchId { get; set; }
[Column("REFERENCE1")] [Column("REFERENCE1")]
public string? Reference1 { get; set; } public string? Reference1 { get; set; }
@@ -82,34 +83,4 @@ public class ResultView
[Column("CHANGED_WHEN")] [Column("CHANGED_WHEN")]
public DateTime? ChangedWhen { get; set; } public DateTime? ChangedWhen { get; set; }
private static readonly Regex HttpStatusInfoRegex = new(@"^(\d{3})\s+(.+)$", RegexOptions.Compiled);
/// <summary>
/// Formats an <see cref="HttpStatusCode"/> into a string in the form "statusCode statusName".
/// For example, <see cref="HttpStatusCode.NotFound"/> becomes "404 Not Found".
/// </summary>
public static string FormatHttpStatusInfo(HttpStatusCode statusCode)
{
var code = (int)statusCode;
var name = statusCode.ToString();
var displayName = Regex.Replace(name, "(?<!^)([A-Z])", " $1");
return $"{code} {displayName}";
}
/// <summary>
/// Parses an Info string in the form "statusCode statusName" back into its components.
/// Returns <c>null</c> if the string does not match the expected format.
/// </summary>
public static (int StatusCode, string StatusName)? ParseHttpStatusInfo(string? info)
{
if (info is null)
return null;
var match = HttpStatusInfoRegex.Match(info);
if (!match.Success)
return null;
return (int.Parse(match.Groups[1].Value), match.Groups[2].Value);
}
} }

View File

@@ -0,0 +1,87 @@
using System.Linq;
using System.Threading.Tasks;
using FluentValidation;
using MediatR;
using Microsoft.Extensions.DependencyInjection;
using NUnit.Framework;
using ReC.Application.RecActions.Commands;
using ReC.Application.Results.Queries;
namespace ReC.Tests.Application.RecActions;
[TestFixture]
public class InvokeBatchDuplicateGuardTests : RecApplicationTestBase
{
private const long ProfileId = 3;
private (ISender Sender, IServiceScope Scope) CreateScopedSender()
{
var scope = ServiceProvider.CreateScope();
var sender = scope.ServiceProvider.GetRequiredService<ISender>();
return (sender, scope);
}
[Test]
public async Task Invoke_with_existing_batchId_throws_ValidationException()
{
var (sender, scope) = CreateScopedSender();
using var _ = scope;
// Arrange: read an existing result to get a real BatchId from the database
var results = await sender.Send(new ReadResultViewQuery
{
ProfileId = ProfileId,
IncludeAction = false,
LastBatch = true
});
var existingBatchId = results.FirstOrDefault()?.BatchId;
Assert.That(existingBatchId, Is.Not.Null.And.Not.Empty,
$"No results with a BatchId found for ProfileId {ProfileId}. Ensure test data exists in the database.");
// Act & Assert: invoking with the same BatchId should throw ValidationException
var ex = Assert.ThrowsAsync<ValidationException>(async () =>
await sender.Send(new InvokeBatchRecActionViewsCommand
{
ProfileId = ProfileId,
References = new InvokeReferences
{
BatchId = existingBatchId!
}
}));
Assert.That(ex!.Errors.Any(e => e.PropertyName.Contains("BatchId")));
}
[Test]
public void Invoke_with_new_batchId_does_not_throw_duplicate_guard()
{
var (sender, scope) = CreateScopedSender();
using var _ = scope;
var uniqueBatchId = $"test-{System.Guid.NewGuid():N}";
// This should NOT throw ValidationException for duplicate BatchId.
// It may throw other exceptions (e.g., no actions found, endpoint errors),
// but the duplicate guard should pass.
try
{
sender.Send(new InvokeBatchRecActionViewsCommand
{
ProfileId = ProfileId,
References = new InvokeReferences
{
BatchId = uniqueBatchId
}
}).GetAwaiter().GetResult();
}
catch (ValidationException valEx) when (valEx.Errors.Any(e => e.PropertyName.Contains("BatchId")))
{
Assert.Fail("Duplicate guard should not trigger for a unique BatchId.");
}
catch
{
// Other exceptions (endpoint errors, etc.) are acceptable
}
}
}

View File

@@ -6,6 +6,7 @@ using NUnit.Framework;
using ReC.Application.Common.Procedures.DeleteProcedure; using ReC.Application.Common.Procedures.DeleteProcedure;
using ReC.Application.Common.Procedures.InsertProcedure; using ReC.Application.Common.Procedures.InsertProcedure;
using ReC.Application.Common.Procedures.UpdateProcedure; using ReC.Application.Common.Procedures.UpdateProcedure;
using ReC.Application.RecActions.Commands;
using ReC.Application.Results.Commands; using ReC.Application.Results.Commands;
using ReC.Domain.Constants; using ReC.Domain.Constants;
using ReC.Tests.Application; using ReC.Tests.Application;
@@ -25,7 +26,7 @@ public class ResultProcedureTests : RecApplicationTestBase
[Test] [Test]
public async Task InsertResultProcedure_runs_via_mediator() public async Task InsertResultProcedure_runs_via_mediator()
{ {
var procedure = new InsertResultCommand { ActionId = 1, Status = HttpStatusCode.OK.ToRecStatus(), Header = "h", Body = "b", Info = "200", Type = Domain.Constants.ResultType.Main }; var procedure = new InsertResultCommand { ActionId = 1, Status = HttpStatusCode.OK.ToRecStatus(), Header = "h", Body = "b", Info = 200, Type = ResultType.Main, References = new () { BatchId = DateTime.Now.ToString() } };
var (sender, scope) = CreateScopedSender(); var (sender, scope) = CreateScopedSender();
using var _ = scope; using var _ = scope;