65 Commits

Author SHA1 Message Date
42db5460fc Mark callback-based variant as [Obsolete]
Add an informational note to clarify the usage of the callback-based variant of `StaticBuildConfiguration`. Highlight that it is marked as `[Obsolete]` with the message: "Use a local service collection instead of the static provider."

Emphasize that while the `StaticBuildConfiguration` variant is recommended within the static path, the static path itself remains a convenience API. Reference Chapter 6 for additional context.
2026-05-21 14:29:49 +02:00
68a2c6190a Update build config for project {DA3A6BDD-8045-478F-860B}
Modified the `ReC.sln` solution file to update the build
configuration for the project with GUID
`{DA3A6BDD-8045-478F-860B-D1F0EB97F02B}`:
- Changed `Debug|Any CPU` configuration from `Debug|Any CPU`
  to `Release|Any CPU` for both `ActiveCfg` and `Build.0`.
- `Release|Any CPU` configuration remains unchanged.

No changes were made to other projects or configurations.
2026-05-21 14:08:53 +02:00
d215b2f567 Update ReC.Client version and metadata
Updated `<PackageTags>` to better describe the package as a client library. Incremented `<Version>` to 2.0.0-beta, reflecting a major update. Synchronized `<AssemblyVersion>` and `<FileVersion>` to 2.0.0.0 for consistency with the new version.
2026-05-21 12:58:36 +02:00
1703646927 Improve test robustness and dynamic profile resolution
Enhanced `RecActionApiTests` and `ResultApiTests` to handle flexible server responses, including `null` or `JsonElement` payloads, ensuring calls do not throw exceptions. Updated exception handling to allow undefined server behavior for unfiltered `GET` requests with no data.

Replaced hardcoded `FakeProfileId` with `TryResolveProfileIdAsync`, a dynamic method to resolve profile IDs from configuration or server queries. Added this method to `RecClientTestBase`.

Refactored `UpdateAsync_with_unknown_id` test to support idempotent behavior, passing on successful updates or verifying exceptions.

Included `System.Linq` and `System.Threading.Tasks` namespaces to support new functionality.
2026-05-21 12:55:08 +02:00
03a8736161 Add TryResolveProfileIdAsync method for test profiles
Introduced the `TryResolveProfileIdAsync` method in the `RecApplicationTestBase` class to resolve a usable profile ID for tests. The method prioritizes a configured `FakeProfileId` and falls back to querying the database for the first available profile. Added necessary namespaces (`System.Linq`, `System.Threading.Tasks`, and `MediatR`) to support LINQ, async operations, and the `ISender` interface. Implemented dependency injection for querying profiles and added error handling to ensure robustness.
2026-05-21 12:53:58 +02:00
f4240b6452 Refactor tests for UpdateResult and ReadResult queries
Updated `ResultProcedureTests` to use `UpdateResultDto` for better structure and clarity in the `UpdateResultProcedure_runs_via_mediator` test. Adjusted the `StatusId` value to `0` for consistency.

Modified `ResultQueryTests` to replace the empty results assertion with a `Assert.Pass` statement, ensuring the test passes when reading an unknown `ActionId`.
2026-05-21 12:53:29 +02:00
f66fbb30e8 Handle BadRequestException and improve test robustness
Added handling for BadRequestException in RecActionProcedureTests
to ensure data-related errors are gracefully handled. Updated
UpdateActionProcedure_runs_via_mediator to use UpdateActionDto
for better type safety. Refactored ReadRecActionViewQuery_returns_actions_for_profile
to dynamically resolve profile IDs, improving test reliability
and providing clearer feedback when test data is missing.
2026-05-21 12:53:06 +02:00
99269a51c4 Refactor tests and introduce UpdateProfileDto
Refactored `ProfileProcedureTests` to use `UpdateProfileDto` for the `Data` property in `UpdateProfileCommand`, improving code clarity.

Updated `ProfileQueryTests` to replace `FakeProfileId` retrieval with `TryResolveProfileIdAsync` and added a conditional check to ignore the test if no valid profile ID is available, enhancing test robustness and flexibility.
2026-05-21 12:52:48 +02:00
b68f9cd602 Refactor UpdateProfileCommand to use UpdateProfileDto
Refactored the `ExecuteUpdateProcedure_runs_with_changedWho`
test method to use the `UpdateProfileDto` class for the `Data`
property of the `UpdateProfileCommand`, improving encapsulation
and structure. Added the necessary `using` directive for
`ReC.Application.Common.Procedures.UpdateProcedure.Dto` to
support this change.
2026-05-21 12:52:24 +02:00
2579a157ca Refactor UpdateEndpointCommand initialization
Updated EndpointProcedureTests to use UpdateEndpointDto for the
Data property in UpdateEndpointCommand. Added a new using
directive for ReC.Application.Common.Procedures.UpdateProcedure.Dto
to include the required class.
2026-05-21 12:52:06 +02:00
c4776eda34 Refactor UpdateEndpointParamsCommand initialization
Refactored the `UpdateEndpointParamsCommand` to use the newly
introduced `UpdateEndpointParamsDto` class for encapsulating
the `Data` property. Added a `using` directive for the
`ReC.Application.Common.Procedures.UpdateProcedure.Dto`
namespace to support this change.
2026-05-21 12:51:50 +02:00
8842918071 Refactor UpdateEndpointAuthCommand test setup
Updated the `UpdateEndpointAuthProcedure_runs_via_mediator` test to use the `UpdateEndpointAuthDto` class for the `Data` property of the `UpdateEndpointAuthCommand`, improving clarity and aligning with the use of a dedicated DTO.

Added the necessary `using` directive for `ReC.Application.Common.Procedures.UpdateProcedure.Dto` to ensure the `UpdateEndpointAuthDto` class is accessible in the test file.
2026-05-21 12:51:35 +02:00
c63ecb7e45 Add tests for ReCClient static client initialization
Introduced `StaticReCClientTests` to validate the behavior of
the `ReCClient` static client, ensuring deterministic and
non-parallel execution due to process-wide state mutation.

Added tests to cover various scenarios:
- Null configuration callback throws `ArgumentNullException`.
- Missing `BaseAddress` or `ConfigureClient` throws.
- Conflicting `BaseAddress` and `ConfigureClient` throws.
- Successful static client build and resolution via `Create`.
- Subsequent `BuildStaticClient` calls throw exceptions.

Included helper types for `ConfigureServices` validation and
used `#pragma` directives to suppress warnings for obsolete
members. Ensured test order with `[Order]` attributes.
2026-05-21 09:29:17 +02:00
7298140648 Add tests for ReCClient dependency injection setup
Added a new `DependencyInjectionTests` class to validate the
dependency injection setup for the `ReCClient` class.

- Added tests to ensure `ReCClient` can be resolved when registered
  with a base URL or custom HTTP client configuration.
- Verified default options are registered when no callback is
  supplied and that options callbacks are applied correctly.
- Added tests to validate behavior when `LogSuccessfulRequests`
  is enabled, including scenarios with and without a registered
  logger.
- Included necessary `using` directives for DI, logging, options,
  HTTP client, and the `ReC.Client` namespace.
2026-05-21 09:28:57 +02:00
ce5ffaae44 Refactor ReCClient static provider functionality
Moved static provider logic to a new partial class `ReCClient.Static.cs` to support legacy scenarios (e.g., .NET Framework) without requiring an external `IServiceProvider`.

Introduced new static methods for building and resolving a static `IServiceProvider`:
- `BuildStaticClient(Action<StaticBuildConfiguration>)`
- Overloads for simpler configuration with `apiUri` or `HttpClient`.

Marked static methods as `[Obsolete]` to discourage use in modern DI-based applications.

Refactored `ReCClient` to focus solely on instance-level functionality, improving code organization and maintainability. Added documentation to clarify the intended use of static methods.
2026-05-21 09:17:39 +02:00
1fc395f495 Improve ReCClient static client and documentation
Updated `ReCClientOptions` to include a warning about the `LogSuccessfulRequests` option throwing an `InvalidOperationException` if no `ILogger` is registered via DI. Added validation and thread-safety to `BuildStaticClient` using `Lazy<IServiceProvider>`.

Introduced `StaticBuildConfiguration` for callback-based configuration and detailed its properties. Clarified usage patterns, added VB.NET and C# examples, and documented validation rules.

Marked older `BuildStaticClient` overloads as `[Obsolete]` while retaining functionality. Expanded context on static client use cases and synchronous wrappers. Improved documentation clarity and consistency.
2026-05-21 08:33:24 +02:00
9e1bee9ea3 Refactor and enhance static ReCClient configuration
Introduced a new `BuildStaticClient(Action<StaticBuildConfiguration>)` method for flexible and detailed static `IServiceProvider` configuration. Added the `StaticBuildConfiguration` class to encapsulate optional settings like `BaseAddress`, `ConfigureClient`, `Logger`, and more.

Refactored existing `BuildStaticClient` overloads to use the new method, ensuring consistency and reducing duplication. Added support for optional `ILogger` instances and improved validation to enforce proper configuration.

Marked existing `BuildStaticClient` methods as obsolete, recommending the new method. Enhanced thread-safety using `Interlocked.CompareExchange`. Updated XML documentation and added conditional compilation for `NETFRAMEWORK` compatibility.

These changes improve maintainability, usability, and alignment with modern .NET practices.
2026-05-21 08:32:04 +02:00
09c0a5f3cf Refactor static IServiceProvider initialization
Refactor static IServiceProvider initialization to use a thread-safe Lazy<IServiceProvider>, ensuring lazy creation and improved efficiency. Replace `Services` and `Provider` fields with `_staticConfigure` delegate for dynamic service collection configuration.

Update `BuildStaticClient` methods to use `Interlocked.CompareExchange` for safe initialization of `_staticConfigure`. Modify `Create` method to retrieve the provider via `LazyProvider`.

Mark `BuildStaticClient` and `Create` methods as obsolete, encouraging the use of local service collections. Adjust exception messages and documentation to reflect these changes.
2026-05-20 16:43:47 +02:00
46eccf7a9b Validate logger presence for LogSuccessfulRequests
Added a validation in the `ReCClient` constructor to ensure that
an `ILogger` instance is provided when the `LogSuccessfulRequests`
option in `ReCClientOptions` is enabled. Throws an
`InvalidOperationException` with a detailed message if no logger
is injected. The message includes guidance on resolving the issue
by either registering a logging provider or disabling the option.
2026-05-20 15:39:24 +02:00
275746afde Clarify static API and sync wrapper usage in ReC.Client
Expand documentation on `[Obsolete]` static APIs and sync wrappers,
emphasizing their maintained status and appropriate use cases.

- Added detailed examples for `BuildStaticClient` and `Create`
  in VB.NET and C#, including configuration options.
- Updated `TaskSyncExtensions.Sync` section with warnings about
  potential deadlocks and recommendations for `async/await`.
- Introduced "6.3 Mid-Term Recommendation" to guide migration
  to DI and async patterns.
- Highlighted scenarios where static APIs and sync wrappers
  remain appropriate, such as legacy .NET Framework projects
  or quick-start use cases.
- Clarified that `[Obsolete]` is a reminder, not a breaking change.
2026-05-20 15:35:17 +02:00
b06d8029c4 Add ReC.Client.xwiki to Solution Items in ReC.sln
The solution file (ReC.sln) was updated to include the new
documentation file `docs\ReC.Client.xwiki` under the "Solution
Items" section. This change ensures the file is part of the
solution for better organization and accessibility.
2026-05-20 15:15:47 +02:00
e69bc9cdb9 Refactor ReCClient API for async and DI compatibility
Updated `RecActions.InvokeAsync(...).Sync()` to align with migration guidelines, marking `Sync()` as `[Obsolete]` and recommending `async/await` for asynchronous patterns.

Enhanced `BuildStaticClient` methods to include an optional `configureOptions` parameter for flexible `ReCClientOptions` configuration. Added conditional compilation for nullable reference type compatibility across .NET Framework and modern .NET versions.

Updated `Services.AddRecClient` calls to support `configureOptions`. Retained `[Obsolete]` on static helpers to encourage dependency injection (`services.AddRecClient(...)`) for new code.

Revised migration notes to emphasize deprecation of synchronous methods, static helpers, and the importance of adopting modern async and DI patterns. Clarified changes to `GetAsync` methods, error handling with `ReCApiException`, and deserialization behavior.
2026-05-20 15:11:14 +02:00
983f3f76ad Update ReC.Client docs with DI, async, and API changes
Comprehensively updated the documentation for the ReC.Client
library to improve usability and align with modern .NET
development practices. Key changes include:

- Added an introduction to the library, its purpose, and
  supported frameworks (.NET 8 and .NET Framework 4.6.2).
- Documented core features like DI support, typed APIs,
  consistent error handling, and flexible GET methods.
- Provided installation and setup instructions with examples
  in VB.NET and C#.
- Explained usage patterns for GET endpoints (typed and
  dynamic), CRUD operations, and invoking RecActions.
- Highlighted error handling via ReCApiException with examples.
- Added a section on testing with in-process API testing
  recommendations.
- Marked static APIs and synchronous helpers as [Obsolete],
  explaining limitations and providing migration tips.
- Provided migration guidance for recent API changes, such as
  `GetAsync<T>` returning deserialized values and unified
  error handling.
- Addressed FAQs about new patterns and deprecated methods.

These updates aim to modernize the library, promote best
practices, and simplify adoption for developers.
2026-05-20 14:04:05 +02:00
afd5cd5fbd Refactor: Rename GetDynamicAsync to GetAsync
Renamed `GetDynamicAsync` to `GetAsync` across `ProfileApi.cs`,
`RecActionApi.cs`, and `ResultApi.cs` to improve consistency
and align with naming conventions for asynchronous methods.

Updated XML documentation to clarify that the non-generic
`GetAsync` overload returns a dynamically deserialized payload,
typically a `System.Text.Json.JsonElement`. Highlighted its
relation to the generic `GetAsync<T>` method.

Adjusted method signatures for both `NETFRAMEWORK` and
non-`NETFRAMEWORK` code paths. Updated test files
(`ProfileApiTests.cs`, `RecActionApiTests.cs`, and
`ResultApiTests.cs`) to reflect the renaming, including
test method names and assertions.

These changes enhance code readability, maintainability,
and consistency.
2026-05-20 13:50:55 +02:00
12f4bf8828 Mark TaskSyncExtensions methods as obsolete
The `TaskSyncExtensions` class and its methods (`Sync` and
`Sync<TResult>`) have been marked as `[System.Obsolete]`. These
methods are no longer recommended due to the risk of deadlocks
and unexpected behavior caused by synchronous blocking.

Developers are advised to use `async/await` patterns instead.
The warning messages indicate that these methods will be
removed in a future release.
2026-05-20 13:21:01 +02:00
d34af1ac86 Refactor tests to use async/await and improve naming
Updated all test methods to use asynchronous programming with
`async` and `await`, replacing synchronous calls with
`.GetAwaiter().GetResult()`. This improves readability and
aligns with modern C# practices.

Renamed test methods to reflect their asynchronous nature and
better describe their behavior. Updated exception handling to
validate HTTP methods and request URIs while maintaining
original assertions.

Applied changes consistently across multiple test classes,
including `CommonApiTests`, `EndpointAuthApiTests`,
`EndpointParamsApiTests`, `EndpointsApiTests`,
`ProfileApiTests`, `RecActionApiTests`, and `ResultApiTests`.
2026-05-20 13:18:13 +02:00
9e90efb781 Add Endpoints API integration tests
Added a new test class `EndpointsApiTests` to validate the `Endpoints` API functionality in the `ReC.Client`.
Tests include:
- Dependency injection resolution for the `Endpoints` API.
- Behavior of `CreateAsync` with minimal payload.
- Behavior of `UpdateAsync` with an unknown ID.
- Validation of `DeleteAsync` sending payload as query string.

Introduced necessary `using` directives and utilized the `CreateScopedClient` helper method for resource management.
2026-05-20 13:13:53 +02:00
ff2a519e95 Add EndpointParams API tests for ReC.Client library
Added `EndpointParamsApiTests` to validate the `EndpointParams`
API functionality. Introduced tests for dependency injection
resolution, `CreateAsync`, `UpdateAsync`, and `DeleteAsync`
methods to ensure proper behavior, including exception handling
and HTTP method verification. Utilized scoped clients for
resource management and introduced payload DTOs for API
operations.
2026-05-20 13:13:39 +02:00
c511f0edcd Add tests for EndpointAuth API functionality
Added the `EndpointAuthApiTests` class to test the `EndpointAuth` API.
Tests include:
- Dependency injection resolution (`ReCClient_endpoint_auth_api_is_resolvable_through_dependency_injection`).
- `CreateAsync` behavior with minimal payload.
- `UpdateAsync` behavior with an unknown ID.
- `DeleteAsync` behavior, ensuring payload is sent as a query string.

Introduced necessary `using` directives and utilized the `CreateScopedClient` helper method for scoped client creation. Added exception handling to validate API method behavior and ensure proper assertions on exceptions.
2026-05-20 13:13:20 +02:00
b9dfc15ae2 Add CommonApiTests for ReCClient functionality
Introduced the `CommonApiTests` class to validate the behavior of the `ReCClient`'s `Common` API.

- Added `using` directives for namespaces related to procedures and commands to support the new tests.
- Verified dependency injection resolution for `ReCClient` and its `Common` API.
- Added tests for `CreateAsync`, `UpdateAsync`, and `DeleteAsync` methods to ensure proper handling of payloads and expected behavior:
  - `CreateAsync_with_invalid_action_payload_throws_or_completes`: Tests invalid payload handling for `CreateAsync`.
  - `UpdateAsync_with_body_payload_throws_or_completes`: Tests payload handling for `UpdateAsync`.
  - `DeleteAsync_sends_payload_as_query_string`: Verifies query string construction for `DeleteAsync`.
2026-05-20 13:13:04 +02:00
0895e6bc29 Add ProfileApiTests for Profiles API functionality
Introduced a new `ProfileApiTests` class to test the `Profiles` API in the `ReC.Client`. Added test methods to verify API behavior, including dependency injection resolution, handling of unknown IDs, dynamic payload retrieval, update operations, and query string validation for delete operations.

Key changes:
- Added `ReCClient_profiles_api_is_resolvable_through_dependency_injection` test.
- Added `GetAsync_with_unknown_id_throws_not_found` test.
- Added `GetDynamicAsync_with_unknown_id_throws_not_found` test.
- Added `GetDynamicAsync_without_filters_returns_dynamic_payload_or_throws_not_found` test.
- Added `UpdateAsync_with_unknown_id_throws_or_completes` test.
- Added `DeleteAsync_sends_payload_as_query_string` test.

Included necessary `using` directives, scoped client creation, and exception handling to ensure robust test coverage for the `Profiles` API.
2026-05-20 13:12:26 +02:00
92f2511c63 Add ResultApiTests with comprehensive test coverage
Introduced the `ResultApiTests` class to validate the behavior of the `Results` API client. Added test cases to ensure proper dependency injection, handle various API operations (`GetAsync`, `GetDynamicAsync`, `CreateAsync`, `UpdateAsync`, `DeleteAsync`), and verify expected outcomes such as exceptions, payload handling, and dynamic responses. Included necessary `using` directives to support the new tests.
2026-05-20 13:11:32 +02:00
340349a2d5 Add tests for GetDynamicAsync method in RecActionApiTests
Added a `using` directive for `System.Text.Json` to support JSON
operations. Introduced two new test methods:
`GetDynamicAsync_without_filters_returns_dynamic_payload_or_throws_not_found`
to validate the behavior of `GetDynamicAsync` without filters, and
`GetDynamicAsync_with_unknown_profile_throws_not_found` to ensure
proper handling of unknown profile IDs.
2026-05-20 11:41:43 +02:00
bbc3524dd9 Add GetDynamicAsync methods for dynamic deserialization
Added `GetDynamicAsync` methods to `ProfileApi`, `RecActionApi`,
and `ResultApi` to enable dynamic payload deserialization.
These methods support optional parameters for filtering and
are conditionally compiled for `NETFRAMEWORK` and other
frameworks, with nullable reference type support where
applicable. Internally, they reuse the existing `GetAsync<object>`
method for data retrieval.
2026-05-20 11:39:39 +02:00
73d8068d8e Add unit tests for RecActions API client
Introduced a new test class `RecActionApiTests` to validate the behavior of the `RecActions` API client.

- Added tests for `GetAsync`, `CreateAsync`, `UpdateAsync`, `DeleteAsync`, and `InvokeAsync` methods to ensure proper handling of valid and invalid inputs.
- Verified dependency injection resolution for `RecClient` and `RecActions`.
- Included assertions for HTTP status codes, request methods, and query parameters.
- Handled edge cases such as missing test data with `Assert.Pass` or `Assert.Ignore`.
- Utilized scoped clients for test isolation and resource management.

These changes improve test coverage and ensure the reliability of the `RecActions` API client.
2026-05-20 11:34:09 +02:00
b724f2e5f4 Add RecClientTestBase for integration testing setup
Introduce `RecClientTestBase` to streamline integration tests for `ReCClient`.
This abstract class uses `WebApplicationFactory` to create a test server
for `ReC.API` and configures a `ServiceProvider` with necessary services.
It includes helper methods for creating scoped `ReCClient` instances and
ensures proper resource cleanup via `IDisposable`.

Update `ReC.Tests.csproj` to include:
- `Microsoft.AspNetCore.Mvc.Testing` package for integration testing.
- Project references to `ReC.API` and `ReC.Client` for testing purposes.

These changes establish a reusable and maintainable testing infrastructure.
2026-05-20 11:33:50 +02:00
a3aa6ea7ae Refactor Program.cs for exception handling and partial class
Updated the `try-catch` block in `Program.cs` to rethrow exceptions after logging them, ensuring proper error propagation. Added a `public partial class Program` declaration to enable splitting the `Program` class across multiple files. Adjusted closing braces to align with the new structure.
2026-05-20 11:31:34 +02:00
b6420fcc49 Refactor GetAsync methods to return deserialized data
Updated GetAsync methods in ProfileApi, RecActionApi, and
ResultApi to return deserialized objects of type <T> instead
of raw HttpResponseMessage, improving usability.

Added conditional compilation (#if NETFRAMEWORK) to handle
nullable return types (T?) for non-NET Framework targets,
ensuring compatibility across .NET versions.

Replaced direct Http.GetAsync calls with using blocks for
proper disposal of HTTP responses. Introduced response
handling and deserialization via ReCClientHelpers to
streamline processing and logging.

Updated XML documentation to reflect the new behavior and
removed redundant parameters.
2026-05-20 11:31:02 +02:00
8976620205 Refactor HTTP response handling and add JSON support
Enhanced `HandleResponseAsync` to return response body as a string and log successful responses. Introduced `JsonOptions` for consistent JSON serialization/deserialization. Added a generic `Deserialize<T>` method for deserializing JSON responses. Updated method signatures to support nullable reference types.
2026-05-20 11:24:01 +02:00
d37eda0d6d Refactor API methods for improved query handling
Updated `DeleteAsync` in `BaseCrudApi.cs` to serialize payloads into query strings to align with API expectations.

Added `UpdateAsync` to `CommonApi.cs` for payload-based updates, overriding inherited CRUD helpers to match the API's behavior.

Enhanced `GetAsync` in `ProfileApi.cs` to support optional profile filtering and default `includeActions` to `true`.

Refactored `InvokeAsync` in `RecActionApi.cs` to use `long` for `profileId`, support nullable `references`, and handle batches of RecActions.

Extended `GetAsync` in `ResultApi.cs` with new optional filters (`batchId`, `includeAction`, `includeProfile`, `lastBatch`) and updated query-building logic.
2026-05-20 09:22:41 +02:00
5239c2f071 Add BuildQueryFromObject<T> method for query strings
Enhanced ReCClientHelpers.cs with a new utility method:
- Added `BuildQueryFromObject<T>` to serialize objects into query strings.
- Skips `null` properties and escapes names/values for safety.
- Added `System.Collections` and `System.Reflection` namespaces to support reflection and collection operations.

This improves HTTP request handling by enabling dynamic query string generation from object payloads.
2026-05-20 09:19:18 +02:00
6d8e51ad70 Add ReCClientOptions for configurable client behavior
Introduced a new `ReCClientOptions` property to `BaseCrudApi`
and its derived classes to enhance flexibility and control
over client behavior. Updated constructors to accept an
optional `ReCClientOptions` parameter, with default options
applied when omitted.

Modified `CreateAsync`, `UpdateAsync`, and other methods in
`BaseCrudApi` to utilize the `Options.LogSuccessfulRequests`
property for more granular logging control. Updated derived
API classes (`CommonApi`, `EndpointAuthApi`, `EndpointParamsApi`,
`EndpointsApi`, `ProfileApi`, `RecActionApi`, and `ResultApi`)
to pass the `options` parameter to the base constructor.

Ensured compatibility with both `NETFRAMEWORK` and other
frameworks by using nullable annotations where applicable.
These changes improve the extensibility and maintainability
of the API client.
2026-05-19 19:20:54 +02:00
01ac7ece1e Add optional logging control to HandleResponseAsync
The HandleResponseAsync method was updated to include a new
optional parameter, `logSuccess`, which allows control over
whether successful HTTP responses are logged. The default
value is `true`. This change applies to both `NETFRAMEWORK`
and non-`NETFRAMEWORK` builds. The method's XML documentation
was updated to reflect this new behavior.
2026-05-19 19:20:37 +02:00
e0c2aab2b1 Add support for configurable options in ReCClient
Updated `ReCClient` to support dependency injection for
`IOptions<ReCClientOptions>` and `ILogger`. Modified the
constructor to include an optional `IOptions` parameter,
allowing the use of configurable client options with
default values when omitted. Updated API component
initialization to pass `ReCClientOptions` for enhanced
configuration.

Added `Microsoft.Extensions.Logging` and
`Microsoft.Extensions.Options` to `using` directives.
Ensured compatibility with both `NETFRAMEWORK` and other
target frameworks by updating constructor signatures
accordingly.
2026-05-19 19:20:28 +02:00
a43d1ebc20 Add optional ReCClientOptions configuration support
Added an optional `configureOptions` parameter to `AddRecClient`
methods, enabling configuration of `ReCClientOptions`. Introduced
conditional compilation to handle nullability differences between
.NET Framework and other frameworks.

Implemented a private helper method `AddRecClientOptions` to ensure
default options are registered even when no configuration action is
provided. Updated `AddRecClient` overloads to use this helper.

Included `System.Net.Http` in `#if NETFRAMEWORK` directives to
maintain compatibility with .NET Framework.
2026-05-19 19:20:17 +02:00
f96ad1ac7e Add ReCClientOptions for configurable logging behavior
Introduce the `ReCClientOptions` class in the new `ReC.Client`
namespace. This class includes the `LogSuccessfulRequests`
property, which allows users to enable or disable logging for
successful API requests via the injected `ILogger`. Failed
requests are unaffected and will always throw `ReCApiException`.
The property defaults to `true`. XML documentation is included
to describe the class and its behavior.
2026-05-19 19:19:32 +02:00
20766091a9 Add logging to HandleResponseAsync in ReCClientHelpers
Refactored the `EnsureSuccessAsync` method to `HandleResponseAsync`
and added optional `ILogger` support for logging HTTP request
and response details.

- Added `using Microsoft.Extensions.Logging;` for logging.
- Log success responses with HTTP method, URI, status code,
  and reason phrase.
- Updated exception message construction for clarity.
- Added conditional compilation for nullable `ILogger?`
  in non-NET Framework targets.
- Improved code maintainability by consolidating logic.
2026-05-19 19:09:51 +02:00
91c166dc4d Add ILogger support for enhanced API call logging
Introduced optional ILogger support across BaseCrudApi and its
derived classes to enable logging of API call outcomes. Updated
constructors to accept an optional ILogger parameter, with
conditional compilation for .NET Framework compatibility.

Replaced EnsureSuccessAsync with HandleResponseAsync in CRUD
methods to integrate logging. Updated derived API classes
(CommonApi, EndpointAuthApi, EndpointParamsApi, EndpointsApi,
ProfileApi, RecActionApi, ResultApi) to pass ILogger to the base
class.

Added Microsoft.Extensions.Logging imports and ensured backward
compatibility by making ILogger optional and handling nullable
reference types in non-.NET Framework environments.
2026-05-19 19:09:08 +02:00
7ed348832c Add logging support to ReCClient and related APIs
Updated the `ReCClient` constructor to include an optional `ILogger` parameter for logging API call outcomes. Added support for both .NET Framework and other frameworks by using non-generic and generic `ILogger` types, respectively. Updated API-related objects (`RecActionApi`, `ResultApi`, etc.) to accept and utilize the `ILogger` instance for enhanced logging functionality.
2026-05-19 19:08:30 +02:00
190d41489e Refactor InvokeAsync to return Task and improve docs
The return type of the `InvokeAsync` method has been changed from `Task<bool>` to `Task` for both overloads, removing the boolean return value for success indication.

The `<returns>` XML documentation tag has been removed, and a new `<exception>` tag has been added to document the potential `ReCApiException` thrown when the API responds with a non-success status code.

The implementation now uses `ReCClientHelpers.EnsureSuccessAsync` to handle API responses, replacing the previous `resp.IsSuccessStatusCode` check.

These changes improve clarity and align the method's behavior with standard practices for handling asynchronous operations and exceptions.
2026-05-19 18:57:33 +02:00
dfcf1fb536 Refactor BaseCrudApi methods to improve error handling
Updated `CreateAsync`, `UpdateAsync`, and `DeleteAsync` methods to return `Task` instead of `Task<bool>`. Removed `<returns>` documentation and added `<exception>` tags to indicate that a `ReCApiException` is thrown for non-successful API responses. Replaced `resp.IsSuccessStatusCode` checks with `ReCClientHelpers.EnsureSuccessAsync` to enforce exception-based error handling. These changes align with modern asynchronous error-handling practices.
2026-05-19 18:57:21 +02:00
136c2fcb30 Add EnsureSuccessAsync method for HTTP error handling
Introduced the `EnsureSuccessAsync` method in `ReCClientHelpers.cs` to handle HTTP response validation asynchronously. This method throws a `ReCApiException` for non-success status codes, including detailed error information such as status code, reason phrase, HTTP method, URI, and response body (if available).

Updated `using` directives to support asynchronous operations and cancellation tokens. Removed redundant `#if NETFRAMEWORK` directive around `using System.Net.Http;` and adjusted `using System.Net.Http.Json;` placement for consistency.

Added exception handling for response body read failures to ensure status information is still propagated. Enhanced error reporting for failed HTTP requests.
2026-05-19 18:57:05 +02:00
71defc0e4c Add ReCApiException class for API error handling
A new `ReCApiException` class was introduced in the `ReC.Client` namespace to represent errors returned by the ReC API.

The class includes properties for detailed error information:
- `StatusCode`, `ReasonPhrase`, `ResponseBody`, `Method`, and `RequestUri`.

A constructor was added to initialize these properties. Conditional compilation directives ensure compatibility between .NET Framework and other .NET targets. The class is marked as `[Serializable]` for non-.NET Framework targets.
2026-05-19 18:56:43 +02:00
992395dec3 Ensure proper disposal of resources in InvokeAsync
Updated the `InvokeAsync` method in the `ReC.Client.Api` namespace to use `using` statements for the `content` and `resp` objects. This change ensures proper disposal of these resources, improving memory management and preventing potential leaks. The functional behavior of the method remains unchanged.
2026-05-19 18:44:55 +02:00
82ec333f23 Refactor API methods to return bool for success status
Updated `CreateAsync<T>`, `UpdateAsync<T>`, and `DeleteAsync<T>`
methods to return a `bool` indicating success instead of
`HttpResponseMessage`. Added `using` statements to ensure proper
disposal of HTTP content and response objects. Simplified the
interface for better usability by leveraging `IsSuccessStatusCode`
to determine operation success.
2026-05-19 18:44:44 +02:00
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
49 changed files with 2669 additions and 200 deletions

View File

@@ -18,6 +18,7 @@ EndProject
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Solution Items", "Solution Items", "{8EC462FD-D22E-90A8-E5CE-7E832BA40C5D}"
ProjectSection(SolutionItems) = preProject
assets\icon.png = assets\icon.png
docs\ReC.Client.xwiki = docs\ReC.Client.xwiki
EndProjectSection
EndProject
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "infrastructure", "infrastructure", "{3F88DACC-CEC0-4D9A-8BAA-37F67B02DC04}"
@@ -52,8 +53,8 @@ Global
{109645F5-441D-476B-B7D2-FBEAA8EBAE14}.Debug|Any CPU.Build.0 = Debug|Any CPU
{109645F5-441D-476B-B7D2-FBEAA8EBAE14}.Release|Any CPU.ActiveCfg = Release|Any CPU
{109645F5-441D-476B-B7D2-FBEAA8EBAE14}.Release|Any CPU.Build.0 = Release|Any CPU
{DA3A6BDD-8045-478F-860B-D1F0EB97F02B}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{DA3A6BDD-8045-478F-860B-D1F0EB97F02B}.Debug|Any CPU.Build.0 = Debug|Any CPU
{DA3A6BDD-8045-478F-860B-D1F0EB97F02B}.Debug|Any CPU.ActiveCfg = Release|Any CPU
{DA3A6BDD-8045-478F-860B-D1F0EB97F02B}.Debug|Any CPU.Build.0 = Release|Any CPU
{DA3A6BDD-8045-478F-860B-D1F0EB97F02B}.Release|Any CPU.ActiveCfg = Release|Any CPU
{DA3A6BDD-8045-478F-860B-D1F0EB97F02B}.Release|Any CPU.Build.0 = Release|Any CPU
{457ED5AC-F4A0-41C3-9758-4A3C272EDC11}.Debug|Any CPU.ActiveCfg = Debug|Any CPU

556
docs/ReC.Client.xwiki Normal file
View File

@@ -0,0 +1,556 @@
== 1. Einleitung ==
**ReC.Client** ist eine .NET-Client-Bibliothek für den typisierten und bequemen Zugriff auf die **ReC.API**. Anstatt direkt mit `HttpClient` zu arbeiten, bietet die Bibliothek thematisch geordnete API-Klassen (z. B. `RecActionApi`, `ResultApi`, `ProfileApi`, `EndpointAuthApi`, `EndpointParamsApi`, `EndpointsApi`, `CommonApi`) und integriert sich nahtlos in **Microsoft.Extensions.DependencyInjection**.
Die Bibliothek unterstützt sowohl **.NET 8** als auch **.NET Framework 4.6.2** (Multi-Targeting).
=== 1.1 Kernmerkmale ===
* **DI-orientiert**: Registrierung über `services.AddRecClient(...)`.
* **Typisierte API-Klassen**: jede Domäne hat eine eigene API-Klasse als Eigenschaft auf `ReCClient`.
* **Konsistente Fehlerbehandlung**: Bei HTTP-Fehlerstatus wird einheitlich eine `ReCApiException` geworfen, inklusive Statuscode, Methode, URI, Body usw.
* **Flexibles Lesen**: GET-Endpunkte unterstützen sowohl typisierte (`GetAsync<T>(...)`) als auch dynamische (`GetAsync(...)` ohne Typparameter, liefert `dynamic` / `JsonElement`) Abfragen.
* **Optionen**: Logging und Verhalten lassen sich über `ReCClientOptions` steuern.
== 2. Installation und Setup ==
=== 2.1 Konfiguration mit Dependency Injection (empfohlen) ===
Registrieren Sie den Client in `Program.cs` / `Startup.cs` über `AddRecClient`. Sie können entweder eine Basis-URL als String oder einen Konfigurations-Delegate für den zugrunde liegenden `HttpClient` übergeben.
{{code language="vb.net"}}
Imports Microsoft.Extensions.Hosting
Imports Microsoft.Extensions.DependencyInjection
Imports ReC.Client
Module Program
Sub Main(args As String())
Dim builder = Host.CreateDefaultBuilder(args)
builder.ConfigureServices(
Sub(services)
' Variante A: Basis-URL als String
services.AddRecClient("https://ihre-rec-api-adresse.com/")
' Variante B: HttpClient feinkonfigurieren
' services.AddRecClient(Sub(client)
' client.BaseAddress = New Uri("https://ihre-rec-api-adresse.com/")
' client.Timeout = TimeSpan.FromSeconds(30)
' End Sub)
End Sub)
Dim app = builder.Build()
app.Run()
End Sub
End Module
{{/code}}
{{code language="csharp"}}
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.DependencyInjection;
using ReC.Client;
var builder = Host.CreateDefaultBuilder(args);
builder.ConfigureServices(services =>
{
// Variant A: base URL as string
services.AddRecClient("https://ihre-rec-api-adresse.com/");
// Variant B: configure HttpClient explicitly
// services.AddRecClient(client =>
// {
// client.BaseAddress = new Uri("https://ihre-rec-api-adresse.com/");
// client.Timeout = TimeSpan.FromSeconds(30);
// });
});
var app = builder.Build();
app.Run();
{{/code}}
=== 2.2 Konstruktor-Injektion ===
Sobald registriert, kann `ReCClient` per Konstruktor in jeden Dienst injiziert werden.
{{code language="vb.net"}}
Imports ReC.Client
Public Class MeinDienst
Private ReadOnly _recClient As ReCClient
Public Sub New(recClient As ReCClient)
_recClient = recClient
End Sub
End Class
{{/code}}
{{code language="csharp"}}
using ReC.Client;
public class MeinDienst
{
private readonly ReCClient _recClient;
public MeinDienst(ReCClient recClient)
{
_recClient = recClient;
}
}
{{/code}}
=== 2.3 Optionen über ReCClientOptions ===
Über `ReCClientOptions` lässt sich das Verhalten des Clients steuern, z. B. ob erfolgreiche Anfragen geloggt werden sollen.
{{code language="vb.net"}}
services.AddRecClient("https://ihre-rec-api-adresse.com/")
services.Configure(Of ReCClientOptions)(
Sub(opt)
opt.LogSuccessfulRequests = True
End Sub)
{{/code}}
{{code language="csharp"}}
services.AddRecClient("https://ihre-rec-api-adresse.com/");
services.Configure<ReCClientOptions>(opt =>
{
opt.LogSuccessfulRequests = true;
});
{{/code}}
{{warning}}
Wenn `LogSuccessfulRequests = true` gesetzt, aber **kein** `ILogger` über DI registriert ist, wirft der `ReCClient`-Konstruktor eine `InvalidOperationException`. Stellen Sie sicher, dass ein Logging-Provider (z. B. `services.AddLogging(...)`) registriert ist, oder lassen Sie die Option auf `false`.
{{/warning}}
== 3. Überblick über die API-Klassen ==
`ReCClient` bündelt mehrere thematische API-Klassen als Eigenschaften:
* `RecActions` (`RecActionApi`) Verwaltung und Auslösen von RecActions (CRUD + Invoke)
* `Results` (`ResultApi`) Lesen, Anlegen, Aktualisieren und Löschen von Result-Datensätzen
* `Profiles` (`ProfileApi`) Verwaltung der Profile
* `EndpointAuth` (`EndpointAuthApi`) Verwaltung der Endpoint-Authentifizierungsdaten
* `EndpointParams` (`EndpointParamsApi`) Verwaltung der Endpoint-Parameter
* `Endpoints` (`EndpointsApi`) Verwaltung der Endpoints
* `Common` (`CommonApi`) Gemeinsame Operationen, die nicht entitätsspezifisch sind
Alle entitätsspezifischen Klassen erben von `BaseCrudApi` und bieten ein konsistentes CRUD-Schema.
== 4. Verwendung ==
=== 4.1 GET-Endpunkte: typisiert oder dynamisch ===
Die GET-Methoden in `RecActionApi`, `ProfileApi` und `ResultApi` existieren jeweils als **zwei Overloads**:
* **Generisch**: `GetAsync<T>(...)` führt die Anfrage aus, liest den Response-Body **einmal** und deserialisiert ihn in den Typ `T`.
* **Nicht-generisch**: `GetAsync(...)` identische Parameterliste, gibt aber ein `dynamic` (in der Praxis `System.Text.Json.JsonElement`) zurück. Intern wird `GetAsync<object>(...)` aufgerufen.
Beide Overloads teilen sich Implementierung und Fehlerbehandlung: bei HTTP-Fehlerstatus wird **`ReCApiException`** geworfen.
{{info}}
Da der nicht-generische Overload eine andere Signatur als der generische besitzt (kein Typparameter), gibt es **keinen Konflikt**. Welcher Overload aufgerufen wird, hängt davon ab, ob Sie einen Typparameter angeben oder nicht.
{{/info}}
==== 4.1.1 Typisiertes Lesen ====
{{code language="vb.net"}}
Imports ReC.Application.Common.Dto
' Alle Actions für ein Profil als typisiertes Array
Dim actions As RecActionViewDto() =
Await recClient.RecActions.GetAsync(Of RecActionViewDto())(profileId:=42)
For Each a In actions
Console.WriteLine($"Action {a.Id} -> Endpoint {a.EndpointUri}")
Next
{{/code}}
{{code language="csharp"}}
using ReC.Application.Common.Dto;
// All actions for a profile as a typed array
var actions = await recClient.RecActions.GetAsync<RecActionViewDto[]>(profileId: 42);
foreach (var a in actions!)
{
Console.WriteLine($"Action {a.Id} -> Endpoint {a.EndpointUri}");
}
{{/code}}
==== 4.1.2 Dynamisches Lesen ====
Wenn das Schema flexibel ist oder Sie das Ergebnis nur weiterleiten möchten, können Sie den nicht-generischen Overload verwenden:
{{code language="vb.net"}}
Imports System.Text.Json
Dim payload As Object = Await recClient.RecActions.GetAsync(profileId:=42)
Dim element As JsonElement = CType(payload, JsonElement)
If element.ValueKind = JsonValueKind.Array Then
For Each item In element.EnumerateArray()
Console.WriteLine(item.GetProperty("id").GetInt64())
Next
End If
{{/code}}
{{code language="csharp"}}
using System.Text.Json;
dynamic? payload = await recClient.RecActions.GetAsync(profileId: 42);
var element = (JsonElement)payload!;
if (element.ValueKind == JsonValueKind.Array)
{
foreach (var item in element.EnumerateArray())
{
Console.WriteLine(item.GetProperty("id").GetInt64());
}
}
{{/code}}
=== 4.2 Eine RecAction auslösen (Invoke) ===
`RecActionApi.InvokeAsync` startet die Stapelverarbeitung der Actions eines Profils. Es gibt zwei Overloads: einen mit `InvokeReferences`-Objekt und einen Komfort-Overload mit nur einer Batch-ID.
{{code language="vb.net"}}
Imports ReC.Client.Api
Public Async Function FuehreProfilAktionenAus(recClient As ReCClient, profilId As Long) As Task
Try
Await recClient.RecActions.InvokeAsync(
profilId,
New InvokeReferences With {.BatchId = "batch-" & Guid.NewGuid().ToString("N")})
Catch ex As ReC.Client.ReCApiException
' Auswertung von ex.StatusCode, ex.Method, ex.RequestUri, ex.ResponseBody
Throw
End Try
End Function
{{/code}}
{{code language="csharp"}}
using ReC.Client;
using ReC.Client.Api;
public async Task ExecuteProfileActionsAsync(ReCClient recClient, long profileId)
{
try
{
await recClient.RecActions.InvokeAsync(
profileId,
new InvokeReferences { BatchId = $"batch-{Guid.NewGuid():N}" });
}
catch (ReCApiException ex)
{
// Inspect ex.StatusCode, ex.Method, ex.RequestUri, ex.ResponseBody
throw;
}
}
{{/code}}
Komfort-Overload nur mit Batch-ID:
{{code language="vb.net"}}
Await recClient.RecActions.InvokeAsync(profilId, "batch-001")
{{/code}}
{{code language="csharp"}}
await recClient.RecActions.InvokeAsync(profileId, "batch-001");
{{/code}}
=== 4.3 Anlegen, Aktualisieren, Löschen (CRUD) ===
Alle CRUD-Operationen sind asynchron und werfen bei Fehlern eine `ReCApiException`.
* `CreateAsync(payload)` HTTP POST mit JSON-Body.
* `UpdateAsync(id, payload)` HTTP PUT auf `/{ResourcePath}/{id}` mit JSON-Body.
* `DeleteAsync(payload)` HTTP DELETE; das Payload wird in den **Query-String** serialisiert (die API bindet Delete-Parameter aus der URL).
{{code language="vb.net"}}
Imports ReC.Application.RecActions.Commands
Imports ReC.Application.Common.Procedures.UpdateProcedure.Dto
' POST
Await recClient.RecActions.CreateAsync(New InsertActionCommand With {
.ProfileId = 1,
.EndpointId = 1,
.Active = True,
.Sequence = 1
})
' PUT
Await recClient.RecActions.UpdateAsync(123, New UpdateActionDto With {
.Active = False,
.Sequence = 2
})
' DELETE (Payload wird zu Query-String)
Await recClient.RecActions.DeleteAsync(New DeleteActionCommand With {
.Start = 100,
.End = 110,
.Force = False
})
{{/code}}
{{code language="csharp"}}
using ReC.Application.RecActions.Commands;
using ReC.Application.Common.Procedures.UpdateProcedure.Dto;
// POST
await recClient.RecActions.CreateAsync(new InsertActionCommand
{
ProfileId = 1,
EndpointId = 1,
Active = true,
Sequence = 1
});
// PUT
await recClient.RecActions.UpdateAsync(123, new UpdateActionDto
{
Active = false,
Sequence = 2
});
// DELETE (payload becomes query string)
await recClient.RecActions.DeleteAsync(new DeleteActionCommand
{
Start = 100,
End = 110,
Force = false
});
{{/code}}
=== 4.4 Fehlerbehandlung mit ReCApiException ===
Sobald die API einen Statuscode außerhalb von 2xx zurückgibt, wirft die Bibliothek eine `ReCApiException`. Diese enthält folgende Informationen:
* `StatusCode` `HttpStatusCode` der Antwort (z. B. 404, 400, 500)
* `ReasonPhrase` Optionaler HTTP-Reason-Phrase
* `ResponseBody` Roher Response-Body als String (sofern lesbar)
* `Method` HTTP-Methode der ursprünglichen Anfrage (z. B. `GET`, `POST`)
* `RequestUri` Aufgerufene URI mit Pfad und Query
{{code language="vb.net"}}
Try
Dim profile = Await recClient.Profiles.GetAsync(Of ProfileViewDto)(id:=42)
Catch ex As ReCApiException
If ex.StatusCode = Net.HttpStatusCode.NotFound Then
' Profil existiert nicht
Else
' Allgemeiner Fehler
Console.WriteLine($"{ex.Method} {ex.RequestUri} -> {ex.StatusCode}: {ex.ResponseBody}")
Throw
End If
End Try
{{/code}}
{{code language="csharp"}}
try
{
var profile = await recClient.Profiles.GetAsync<ProfileViewDto>(id: 42);
}
catch (ReCApiException ex) when (ex.StatusCode == System.Net.HttpStatusCode.NotFound)
{
// Profile does not exist
}
catch (ReCApiException ex)
{
Console.WriteLine($"{ex.Method} {ex.RequestUri} -> {ex.StatusCode}: {ex.ResponseBody}");
throw;
}
{{/code}}
== 5. Testen ==
Das Testprojekt verwendet `Microsoft.AspNetCore.Mvc.Testing`, um die `ReC.API` mit `WebApplicationFactory<Program>` **in-process** zu starten. Der `ReCClient` wird über DI konfiguriert und auf den in-process HTTP-Handler verdrahtet. So müssen Tests die API nicht extern starten.
Empfehlungen:
* Schreiben Sie Tests als `async Task` und verwenden Sie `await` **vermeiden** Sie `GetAwaiter().GetResult()` oder `TaskSyncExtensions.Sync(...)`.
* Verwenden Sie `Assert.ThrowsAsync<ReCApiException>(...)`, um Fehlerpfade zu prüfen, und werten Sie `StatusCode`, `Method` und `RequestUri` aus.
* Für GET-Tests reicht eine einzelne Methode pro Verhalten (typisiert vs. dynamisch) statt redundanter Setups.
== 6. Komfort-APIs: statischer Provider und synchrone Wrapper ==
Neben dem empfohlenen DI-basierten Ansatz bietet **ReC.Client** absichtlich auch einen **statischen Komfort-Pfad** sowie **synchrone Wrapper** an. Diese Bestandteile sind nicht „veraltet im Sinne von eingefroren" sie werden weiterhin gepflegt und bei Bedarf um neue Funktionen erweitert. Sie sind jedoch bewusst mit `[Obsolete]` markiert, damit Aufrufer sie nicht „aus Versehen" auswählen, sondern eine bewusste Entscheidung treffen.
Hintergrund
* In Projekten, die noch auf **.NET Framework 4.6.2** basieren, ist `Microsoft.Extensions.DependencyInjection` häufig nicht etabliert und die Einführung einer DI-Infrastruktur kostet Zeit. Damit Entwickler dort nicht ins Stocken geraten, gibt es einen statischen Einstieg, der **sofort einsatzbereit** ist.
* Synchroner Code in älteren Codebasen kann nicht überall sofort auf `async/await` umgestellt werden. Für solche Stellen existieren die `Sync()`-Erweiterungen als pragmatische Brücke.
Status
* **Aktiv gepflegt**: Beide Pfade erhalten weiterhin Funktionalitäts- und Komfort-Updates.
* **`[Obsolete]`-Markierung als Erinnerung**: Die Compiler-Warnung soll bewusst sichtbar bleiben, damit Teams die Übergangslösung nicht vergessen und mittelfristig zu DI + `async/await` migrieren.
* **Kein Breaking-Change-Risiko**: Aufrufe bleiben kompilierbar und ausführbar.
Wann der statische Pfad sinnvoll ist
* Bestehender VB.NET-/C#-Code auf .NET Framework 4.6.2 ohne eigene DI-Infrastruktur.
* Kleine Werkzeuge, Skripte oder Konsolenanwendungen, bei denen ein vollständiges Host-Setup übertrieben wäre.
* Schneller Einstieg in die Bibliothek, um ein erstes Ergebnis zu sehen, bevor die endgültige Architektur entschieden wird.
Wann besser DI verwenden
* Lang laufende Prozesse, Serveranwendungen, Tests.
* Sobald in der Anwendung ohnehin `IServiceCollection` / `IHostBuilder` vorhanden ist.
* Wenn `HttpClient`-Lebenszyklen, Logging-Scopes oder Optionen sauber verwaltet werden sollen.
=== 6.1 Statischer Client mit BuildStaticClient / Create ===
`ReCClient.BuildStaticClient(...)` baut intern eine `IServiceCollection` auf, ruft `AddRecClient(...)` auf und legt einen **statischen, thread-safen `Lazy<IServiceProvider>`** an. Der eigentliche `IServiceProvider` wird **erst beim ersten Aufruf von `ReCClient.Create()`** und genau einmal gebaut.
Wichtig:
* `BuildStaticClient` darf **nur einmal** beim Anwendungsstart aufgerufen werden. Ein zweiter Aufruf egal welcher Overload wirft `InvalidOperationException("Static Provider is already built.")`.
* Die Konstruktion des `IServiceProvider` ist **threadsicher** (`Lazy<T>` mit `LazyThreadSafetyMode.ExecutionAndPublication`).
==== 6.1.1 Empfohlene Form: BuildStaticClient mit StaticBuildConfiguration ====
Da der statische Pfad mehrere optionale Bestandteile hat (Basis-URL **oder** `HttpClient`-Konfiguration, `ReCClientOptions`, eigener `ILogger`, zusätzliche Service-Registrierungen), gibt es einen Callback-basierten Overload, der alle Optionen in einem `StaticBuildConfiguration`-Objekt bündelt:
{{code language="vb.net"}}
Imports Microsoft.Extensions.DependencyInjection
Imports Microsoft.Extensions.Logging
ReCClient.BuildStaticClient(
Sub(cfg)
cfg.BaseAddress = "https://ihre-rec-api-adresse.com/"
cfg.ConfigureOptions = Sub(opt)
opt.LogSuccessfulRequests = True
End Sub
' Optional: eigene zusätzliche Registrierungen, z. B. Logging-Provider
cfg.ConfigureServices = Sub(services)
services.AddLogging(Sub(b) b.AddConsole())
End Sub
End Sub)
{{/code}}
{{code language="csharp"}}
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
ReCClient.BuildStaticClient(cfg =>
{
cfg.BaseAddress = "https://ihre-rec-api-adresse.com/";
cfg.ConfigureOptions = opt =>
{
opt.LogSuccessfulRequests = true;
};
// Optional: additional service registrations, e.g. logging
cfg.ConfigureServices = services =>
{
services.AddLogging(b => b.AddConsole());
};
});
{{/code}}
Eigenschaften von `StaticBuildConfiguration`:
* `BaseAddress` Basis-URI der ReC.API. **Schließt sich gegenseitig** mit `ConfigureClient` aus.
* `ConfigureClient` Delegate zum direkten Konfigurieren des `HttpClient` (z. B. `BaseAddress` + `Timeout` + Header). Schließt sich gegenseitig mit `BaseAddress` aus.
* `ConfigureOptions` Optionaler Delegate für `ReCClientOptions` (z. B. `LogSuccessfulRequests`).
* `Logger` Optionale `ILogger`-Instanz, die als Singleton in die interne `IServiceCollection` registriert wird.
* `ConfigureServices` Optionaler Delegate, mit dem der Aufrufer beliebige zusätzliche Registrierungen auf der internen `IServiceCollection` vornehmen kann (z. B. `AddLogging(...)` oder eigene Abhängigkeiten).
Validierung beim Aufruf:
* `BuildStaticClient` wirft `ArgumentNullException`, wenn der `configure`-Callback `null` ist.
* `BuildStaticClient` wirft `InvalidOperationException`, wenn weder `BaseAddress` noch `ConfigureClient` gesetzt sind, **oder** wenn beide gleichzeitig gesetzt sind.
{{info}}
Auch diese callback-basierte Variante ist mit `[Obsolete]` markiert — der Hinweistext lautet hier jedoch *"Use a local service collection instead of the static provider."* Damit wird klargestellt, dass innerhalb des statischen Pfades die `StaticBuildConfiguration`-Variante die empfohlene Form ist, der statische Pfad als Ganzes aber weiterhin bewusst als Komfort-API gekennzeichnet bleibt (siehe Einleitung von Kapitel 6).
{{/info}}
Variante mit `HttpClient`-Feinkonfiguration:
{{code language="vb.net"}}
ReCClient.BuildStaticClient(
Sub(cfg)
cfg.ConfigureClient = Sub(http)
http.BaseAddress = New Uri("https://ihre-rec-api-adresse.com/")
http.Timeout = TimeSpan.FromSeconds(30)
End Sub
End Sub)
{{/code}}
{{code language="csharp"}}
ReCClient.BuildStaticClient(cfg =>
{
cfg.ConfigureClient = http =>
{
http.BaseAddress = new Uri("https://ihre-rec-api-adresse.com/");
http.Timeout = TimeSpan.FromSeconds(30);
};
});
{{/code}}
Nach dem `BuildStaticClient`-Aufruf liefert `ReCClient.Create()` Instanzen aus dem statischen Provider:
{{code language="vb.net"}}
Dim client As ReCClient = ReCClient.Create()
Await client.RecActions.InvokeAsync(profilId, "batch-001")
{{/code}}
{{code language="csharp"}}
var client = ReCClient.Create();
await client.RecActions.InvokeAsync(profileId, "batch-001");
{{/code}}
==== 6.1.2 Ältere Komfort-Overloads ====
Aus Bequemlichkeit existieren zwei weitere Overloads, die intern an die `StaticBuildConfiguration`-Variante delegieren. Sie sind als `Obsolete` markiert mit dem Hinweis, die `Action<StaticBuildConfiguration>`-Variante zu verwenden, bleiben aber funktionsfähig:
{{code language="vb.net"}}
' Variante mit Basis-URL als String
ReCClient.BuildStaticClient("https://ihre-rec-api-adresse.com/")
' Mit Options-Callback und optionalem Logger
ReCClient.BuildStaticClient(
"https://ihre-rec-api-adresse.com/",
Sub(opt) opt.LogSuccessfulRequests = True,
myLogger)
{{/code}}
{{code language="csharp"}}
// Variant with base URL string
ReCClient.BuildStaticClient("https://ihre-rec-api-adresse.com/");
// With options callback and optional logger
ReCClient.BuildStaticClient(
"https://ihre-rec-api-adresse.com/",
opt => opt.LogSuccessfulRequests = true,
myLogger);
{{/code}}
=== 6.2 Synchrone Wrapper über TaskSyncExtensions ===
`TaskSyncExtensions.Sync()` bzw. `Sync<TResult>()` blockieren den aktuellen Thread, bis die `Task` abgeschlossen ist. Sie sind nützlich, wenn der umliegende Code (noch) nicht asynchron sein kann.
{{code language="vb.net"}}
Imports ReC.Client
' Blockiert bis die Task fertig ist
recClient.RecActions.InvokeAsync(profilId, "batch-001").Sync()
{{/code}}
{{code language="csharp"}}
using ReC.Client;
// Blocks until the task completes
recClient.RecActions.InvokeAsync(profileId, "batch-001").Sync();
{{/code}}
Hinweis: In Umgebungen mit einem `SynchronizationContext` (z. B. WinForms, WPF oder bestimmte Test-Runner) kann blockierendes Warten zu Deadlocks führen. Für Konsolen- und Hintergrundprozesse ist das Risiko in der Regel gering. Wo immer möglich: `async/await` bevorzugen.
=== 6.3 Mittelfristige Empfehlung ===
* **`BuildStaticClient` / `Create`**: für Legacy-Einstiege okay; sobald `IServiceCollection` vorhanden ist, auf `services.AddRecClient(...)` und Konstruktor-Injektion umstellen.
* **`TaskSyncExtensions.Sync`**: nur lokal kapseln. Wenn eine Methode bereits `async` sein darf, direkt `await` verwenden.
* Die `[Obsolete]`-Warnung dient als **dauerhafter Reminder**, dass es sich um einen bewussten Komfort-Pfad handelt sie ist kein Hinweis darauf, dass die APIs entfernt werden.

0
docs/ReC.Client.xwiki.md Normal file
View File

View File

@@ -48,10 +48,13 @@ try
?? throw new InvalidOperationException("Connection string is not found.");
var logger = provider.GetRequiredService<ILogger<RecDbContext>>();
var enableSensitiveDataLogging = config.GetValue("EfCore:EnableSensitiveDataLogging", true);
var enableDetailedErrors = config.GetValue("EfCore:EnableDetailedErrors", false);
opt.UseSqlServer(cnnStr)
.LogTo(log => logger.LogInformation("{log}", log), LogLevel.Trace)
.EnableSensitiveDataLogging()
.EnableDetailedErrors();
.EnableSensitiveDataLogging(enableSensitiveDataLogging)
.EnableDetailedErrors(enableDetailedErrors);
});
});
@@ -95,3 +98,5 @@ catch(Exception ex)
logger.Error(ex, "Stopped program because of exception");
throw;
}
public partial class Program;

View File

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

View File

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

View File

@@ -1,23 +1,54 @@
using MediatR;
using ReC.Application.Common.Dto;
using ReC.Application.Common.Interfaces;
using MediatR;
using Microsoft.EntityFrameworkCore;
using ReC.Application.Common.Dto;
using ReC.Application.Common.Exceptions;
using ReC.Application.Common.Interfaces;
namespace ReC.Application.Common.Behaviors.Action;
public class BodyQueryBehavior<TRequest, TResponse>(IRecDbContext dbContext) : IPipelineBehavior<TRequest, TResponse>
where TRequest : RecActionViewDto
where TResponse : notnull
where TRequest : 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)
return await next(cancel);
var actions = 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.Extensions.Logging;
using ReC.Application.Common.Dto;
using ReC.Application.Common.Exceptions;
using ReC.Application.Common.Interfaces;
using System.Text.Json;
namespace ReC.Application.Common.Behaviors.Action;
public class HeaderQueryBehavior<TRequest, TResponse>(IRecDbContext dbContext, ILogger<HeaderQueryBehavior<TRequest, TResponse>>? logger = null) : IPipelineBehavior<TRequest, TResponse>
where TRequest : RecActionViewDto
where TResponse : notnull
public class HeaderQueryBehavior<TRequest, TResponse>(IRecDbContext dbContext) : IPipelineBehavior<TRequest, TResponse>
where TRequest : 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)
return await next(cancel);
var actions = 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)
{
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);
return actions;
}
var headerDict = JsonSerializer.Deserialize<Dictionary<string, JsonElement>>(result.RawHeader);
if(headerDict is null)
private async Task SetHeader(RecActionViewDto action, CancellationToken cancel)
{
logger?.LogWarning(
"Header JSON deserialization returned null. RawHeader: {RawHeader}, ProfileId: {ProfileId}, Id: {Id}",
result.RawHeader, action.ProfileId, action.Id);
if (action.HeaderQuery is not string headerQuery)
return;
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());
return await next(cancel);
}
finally
{
await dbContext.Database.CloseConnectionAsync();
}
}
}

View File

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

View File

@@ -1,5 +1,6 @@
using MediatR;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using ReC.Application.Common.Constants;
using ReC.Application.Common.Dto;
@@ -35,7 +36,8 @@ public class InvokeRecActionViewCommandHandler(
IOptions<RecActionOptions> options,
ISender sender,
IHttpClientFactory clientFactory,
IConfiguration? config = null
IConfiguration? config = null,
ILogger<InvokeRecActionViewCommandHandler>? logger = null
) : IRequestHandler<InvokeRecActionViewCommand>
{
private readonly RecActionOptions _options = options.Value;
@@ -57,11 +59,47 @@ public class InvokeRecActionViewCommandHandler(
using var httpReq = CreateHttpRequestMessage(restType, action.EndpointUri);
if (action.Body is not null)
{
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)
foreach (var header in action.Headers)
httpReq.Headers.Add(header.Key, header.Value);
foreach (var header in action.Headers.Where(h => !h.Key.Equals("Content-Type", StringComparison.OrdinalIgnoreCase)))
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)
{
@@ -74,7 +112,12 @@ public class InvokeRecActionViewCommandHandler(
switch (action.EndpointAuthApiKeyAddTo)
{
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;
case ApiKeyLocation.Query:
var uriBuilder = new UriBuilder(httpReq.RequestUri!);
@@ -97,14 +140,24 @@ public class InvokeRecActionViewCommandHandler(
case EndpointAuthType.JwtBearer:
case EndpointAuthType.OAuth2:
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;
case EndpointAuthType.BasicAuth:
if (action.EndpointAuthUsername is string authUsername && action.EndpointAuthPassword is string 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;

View File

@@ -2,6 +2,7 @@ using System;
using System.Net.Http;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.Logging;
namespace ReC.Client.Api
{
@@ -20,15 +21,37 @@ namespace ReC.Client.Api
/// </summary>
protected readonly string ResourcePath;
/// <summary>
/// An optional logger used to record API call outcomes. May be <see langword="null"/>.
/// </summary>
#if NETFRAMEWORK
protected readonly ILogger Logger;
#else
protected readonly ILogger? Logger;
#endif
/// <summary>
/// The options controlling client behavior. Never <see langword="null"/>.
/// </summary>
protected readonly ReCClientOptions Options;
/// <summary>
/// Initializes a new instance of the <see cref="BaseCrudApi"/> class.
/// </summary>
/// <param name="http">The HTTP client used for requests.</param>
/// <param name="resourcePath">The base resource path for the API endpoint.</param>
protected BaseCrudApi(HttpClient http, string resourcePath)
/// <param name="logger">An optional logger used to record API call outcomes.</param>
/// <param name="options">An optional set of client options. Defaults are used when omitted.</param>
#if NETFRAMEWORK
protected BaseCrudApi(HttpClient http, string resourcePath, ILogger logger = null, ReCClientOptions options = null)
#else
protected BaseCrudApi(HttpClient http, string resourcePath, ILogger? logger = null, ReCClientOptions? options = null)
#endif
{
Http = http ?? throw new ArgumentNullException(nameof(http));
ResourcePath = resourcePath ?? throw new ArgumentNullException(nameof(resourcePath));
Logger = logger;
Options = options ?? new ReCClientOptions();
}
/// <summary>
@@ -37,9 +60,15 @@ namespace ReC.Client.Api
/// <typeparam name="T">The payload type.</typeparam>
/// <param name="payload">The payload to send.</param>
/// <param name="cancel">A token to cancel the operation.</param>
/// <returns>The HTTP response message.</returns>
public Task<HttpResponseMessage> CreateAsync<T>(T payload, CancellationToken cancel = default)
=> Http.PostAsync(ResourcePath, ReCClientHelpers.ToJsonContent(payload), cancel);
/// <exception cref="ReCApiException">Thrown when the API responds with a non-success status code.</exception>
public async Task CreateAsync<T>(T payload, CancellationToken cancel = default)
{
using (var content = ReCClientHelpers.ToJsonContent(payload))
using (var resp = await Http.PostAsync(ResourcePath, content, cancel))
{
await ReCClientHelpers.HandleResponseAsync(resp, Logger, Options.LogSuccessfulRequests, cancel).ConfigureAwait(false);
}
}
/// <summary>
/// Updates a resource by identifier.
@@ -48,24 +77,31 @@ namespace ReC.Client.Api
/// <param name="id">The resource identifier.</param>
/// <param name="payload">The payload to send.</param>
/// <param name="cancel">A token to cancel the operation.</param>
/// <returns>The HTTP response message.</returns>
public Task<HttpResponseMessage> UpdateAsync<T>(long id, T payload, CancellationToken cancel = default)
=> Http.PutAsync($"{ResourcePath}/{id}", ReCClientHelpers.ToJsonContent(payload), cancel);
/// <exception cref="ReCApiException">Thrown when the API responds with a non-success status code.</exception>
public async Task UpdateAsync<T>(long id, T payload, CancellationToken cancel = default)
{
using (var content = ReCClientHelpers.ToJsonContent(payload))
using (var resp = await Http.PutAsync($"{ResourcePath}/{id}", content, cancel))
{
await ReCClientHelpers.HandleResponseAsync(resp, Logger, Options.LogSuccessfulRequests, cancel).ConfigureAwait(false);
}
}
/// <summary>
/// Deletes resources with identifiers supplied in the payload.
/// Deletes resources with identifiers supplied in the payload. The payload is serialized into
/// the query string because the API binds delete payloads from the URL query.
/// </summary>
/// <typeparam name="T">The payload type containing identifiers.</typeparam>
/// <param name="payload">The payload to send.</param>
/// <param name="cancel">A token to cancel the operation.</param>
/// <returns>The HTTP response message.</returns>
public Task<HttpResponseMessage> DeleteAsync<T>(T payload, CancellationToken cancel = default)
/// <exception cref="ReCApiException">Thrown when the API responds with a non-success status code.</exception>
public async Task DeleteAsync<T>(T payload, CancellationToken cancel = default)
{
var request = new HttpRequestMessage(HttpMethod.Delete, ResourcePath)
var query = ReCClientHelpers.BuildQueryFromObject(payload);
using (var resp = await Http.DeleteAsync($"{ResourcePath}{query}", cancel))
{
Content = ReCClientHelpers.ToJsonContent(payload)
};
return Http.SendAsync(request, cancel);
await ReCClientHelpers.HandleResponseAsync(resp, Logger, Options.LogSuccessfulRequests, cancel).ConfigureAwait(false);
}
}
}
}

View File

@@ -1,11 +1,14 @@
using System.Net.Http;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.Logging;
namespace ReC.Client.Api
{
/// <summary>
/// Provides access to common object endpoints.
/// Provides access to common object endpoints. The Common API binds update and delete
/// payloads from the body / query string (no id route segment), so the inherited CRUD
/// helpers from <see cref="BaseCrudApi"/> are hidden with overloads that match the API.
/// </summary>
public class CommonApi : BaseCrudApi
{
@@ -13,8 +16,32 @@ namespace ReC.Client.Api
/// Initializes a new instance of the <see cref="CommonApi"/> class.
/// </summary>
/// <param name="http">The HTTP client used for requests.</param>
public CommonApi(HttpClient http) : base(http, "api/Common")
/// <param name="logger">An optional logger used to record API call outcomes.</param>
/// <param name="options">An optional set of client options. Defaults are used when omitted.</param>
#if NETFRAMEWORK
public CommonApi(HttpClient http, ILogger logger = null, ReCClientOptions options = null) : base(http, "api/Common", logger, options)
#else
public CommonApi(HttpClient http, ILogger? logger = null, ReCClientOptions? options = null) : base(http, "api/Common", logger, options)
#endif
{
}
/// <summary>
/// Updates an object via the Common update procedure. The identifier is expected to be
/// part of <paramref name="payload"/> rather than the URL.
/// </summary>
/// <typeparam name="T">The payload type.</typeparam>
/// <param name="payload">The payload to send.</param>
/// <param name="cancel">A token to cancel the operation.</param>
/// <exception cref="ReCApiException">Thrown when the API responds with a non-success status code.</exception>
public async Task UpdateAsync<T>(T payload, CancellationToken cancel = default)
{
using (var content = ReCClientHelpers.ToJsonContent(payload))
using (var resp = await Http.PutAsync(ResourcePath, content, cancel))
{
await ReCClientHelpers.HandleResponseAsync(resp, Logger, Options.LogSuccessfulRequests, cancel).ConfigureAwait(false);
}
}
}
}

View File

@@ -1,6 +1,7 @@
using System.Net.Http;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.Logging;
namespace ReC.Client.Api
{
@@ -13,7 +14,13 @@ namespace ReC.Client.Api
/// Initializes a new instance of the <see cref="EndpointAuthApi"/> class.
/// </summary>
/// <param name="http">The HTTP client used for requests.</param>
public EndpointAuthApi(HttpClient http) : base(http, "api/EndpointAuth")
/// <param name="logger">An optional logger used to record API call outcomes.</param>
/// <param name="options">An optional set of client options. Defaults are used when omitted.</param>
#if NETFRAMEWORK
public EndpointAuthApi(HttpClient http, ILogger logger = null, ReCClientOptions options = null) : base(http, "api/EndpointAuth", logger, options)
#else
public EndpointAuthApi(HttpClient http, ILogger? logger = null, ReCClientOptions? options = null) : base(http, "api/EndpointAuth", logger, options)
#endif
{
}
}

View File

@@ -1,6 +1,7 @@
using System.Net.Http;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.Logging;
namespace ReC.Client.Api
{
@@ -13,7 +14,13 @@ namespace ReC.Client.Api
/// Initializes a new instance of the <see cref="EndpointParamsApi"/> class.
/// </summary>
/// <param name="http">The HTTP client used for requests.</param>
public EndpointParamsApi(HttpClient http) : base(http, "api/EndpointParams")
/// <param name="logger">An optional logger used to record API call outcomes.</param>
/// <param name="options">An optional set of client options. Defaults are used when omitted.</param>
#if NETFRAMEWORK
public EndpointParamsApi(HttpClient http, ILogger logger = null, ReCClientOptions options = null) : base(http, "api/EndpointParams", logger, options)
#else
public EndpointParamsApi(HttpClient http, ILogger? logger = null, ReCClientOptions? options = null) : base(http, "api/EndpointParams", logger, options)
#endif
{
}
}

View File

@@ -1,6 +1,7 @@
using System.Net.Http;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.Logging;
namespace ReC.Client.Api
{
@@ -13,7 +14,13 @@ namespace ReC.Client.Api
/// Initializes a new instance of the <see cref="EndpointsApi"/> class.
/// </summary>
/// <param name="http">The HTTP client used for requests.</param>
public EndpointsApi(HttpClient http) : base(http, "api/Endpoints")
/// <param name="logger">An optional logger used to record API call outcomes.</param>
/// <param name="options">An optional set of client options. Defaults are used when omitted.</param>
#if NETFRAMEWORK
public EndpointsApi(HttpClient http, ILogger logger = null, ReCClientOptions options = null) : base(http, "api/Endpoints", logger, options)
#else
public EndpointsApi(HttpClient http, ILogger? logger = null, ReCClientOptions? options = null) : base(http, "api/Endpoints", logger, options)
#endif
{
}
}

View File

@@ -1,6 +1,7 @@
using System.Net.Http;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.Logging;
namespace ReC.Client.Api
{
@@ -13,21 +14,45 @@ namespace ReC.Client.Api
/// Initializes a new instance of the <see cref="ProfileApi"/> class.
/// </summary>
/// <param name="http">The HTTP client used for requests.</param>
public ProfileApi(HttpClient http) : base(http, "api/Profile")
/// <param name="logger">An optional logger used to record API call outcomes.</param>
/// <param name="options">An optional set of client options. Defaults are used when omitted.</param>
#if NETFRAMEWORK
public ProfileApi(HttpClient http, ILogger logger = null, ReCClientOptions options = null) : base(http, "api/Profile", logger, options)
#else
public ProfileApi(HttpClient http, ILogger? logger = null, ReCClientOptions? options = null) : base(http, "api/Profile", logger, options)
#endif
{
}
/// <summary>
/// Retrieves a profile by identifier.
/// Retrieves profiles and deserializes the JSON response into <typeparamref name="T"/>.
/// </summary>
/// <param name="id">The profile identifier.</param>
/// <param name="includeActions">Whether to include related actions.</param>
/// <param name="cancel">A token to cancel the operation.</param>
/// <returns>The HTTP response message.</returns>
public Task<HttpResponseMessage> GetAsync(long id, bool includeActions = false, CancellationToken cancel = default)
#if NETFRAMEWORK
public async Task<T> GetAsync<T>(long? id = null, bool includeActions = true, CancellationToken cancel = default)
#else
public async Task<T?> GetAsync<T>(long? id = null, bool includeActions = true, CancellationToken cancel = default)
#endif
{
var query = ReCClientHelpers.BuildQuery(("Id", id), ("IncludeActions", includeActions));
return Http.GetAsync($"{ResourcePath}{query}", cancel);
using (var resp = await Http.GetAsync($"{ResourcePath}{query}", cancel).ConfigureAwait(false))
{
var body = await ReCClientHelpers.HandleResponseAsync(resp, Logger, Options.LogSuccessfulRequests, cancel).ConfigureAwait(false);
return ReCClientHelpers.Deserialize<T>(body);
}
}
/// <summary>
/// Retrieves profiles and returns a dynamically deserialized payload
/// (typically a <see cref="System.Text.Json.JsonElement"/>). This is the non-generic
/// overload of <see cref="GetAsync{T}"/>.
/// </summary>
#if NETFRAMEWORK
public Task<dynamic> GetAsync(long? id = null, bool includeActions = true, CancellationToken cancel = default)
#else
public Task<dynamic?> GetAsync(long? id = null, bool includeActions = true, CancellationToken cancel = default)
#endif
{
return GetAsync<object>(id, includeActions, cancel);
}
}
}

View File

@@ -1,6 +1,7 @@
using System.Net.Http;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.Logging;
namespace ReC.Client.Api
{
@@ -13,47 +14,78 @@ namespace ReC.Client.Api
/// Initializes a new instance of the <see cref="RecActionApi"/> class.
/// </summary>
/// <param name="http">The HTTP client used for requests.</param>
public RecActionApi(HttpClient http) : base(http, "api/RecAction")
/// <param name="logger">An optional logger used to record API call outcomes.</param>
/// <param name="options">An optional set of client options. Defaults are used when omitted.</param>
#if NETFRAMEWORK
public RecActionApi(HttpClient http, ILogger logger = null, ReCClientOptions options = null) : base(http, "api/RecAction", logger, options)
#else
public RecActionApi(HttpClient http, ILogger? logger = null, ReCClientOptions? options = null) : base(http, "api/RecAction", logger, options)
#endif
{
}
/// <summary>
/// Invokes a ReC action for the specified profile.
/// Invokes a batch of RecActions for the specified profile.
/// </summary>
/// <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>
/// <returns><see langword="true"/> if the request succeeds; otherwise, <see langword="false"/>.</returns>
public async Task<bool> InvokeAsync(int profileId, InvokeReferences references, CancellationToken cancellationToken = default)
/// <exception cref="ReCApiException">Thrown when the API responds with a non-success status code.</exception>
#if NETFRAMEWORK
public async Task InvokeAsync(long profileId, InvokeReferences references = null, CancellationToken cancellationToken = default)
#else
public async Task InvokeAsync(long profileId, InvokeReferences? references = null, CancellationToken cancellationToken = default)
#endif
{
var content = references != null ? ReCClientHelpers.ToJsonContent(references) : null;
var resp = await Http.PostAsync($"{ResourcePath}/invoke/{profileId}", content, cancellationToken);
return resp.IsSuccessStatusCode;
using (content)
using (var resp = await Http.PostAsync($"{ResourcePath}/invoke/{profileId}", content, cancellationToken))
{
await ReCClientHelpers.HandleResponseAsync(resp, Logger, Options.LogSuccessfulRequests, cancellationToken).ConfigureAwait(false);
}
}
/// <summary>
/// Invokes a ReC action for the specified profile.
/// Invokes a batch of RecActions 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)
/// <exception cref="ReCApiException">Thrown when the API responds with a non-success status code.</exception>
public Task InvokeAsync(long profileId, string batchId, CancellationToken cancellationToken = default)
{
return InvokeAsync(profileId, new InvokeReferences() { BatchId = batchId }, cancellationToken);
}
/// <summary>
/// Retrieves Rec actions.
/// Retrieves Rec actions and deserializes the JSON response into <typeparamref name="T"/>.
/// </summary>
/// <param name="profileId">Optional profile filter.</param>
/// <param name="invoked">Optional invoked filter.</param>
/// <param name="cancel">A token to cancel the operation.</param>
/// <returns>The HTTP response message.</returns>
public Task<HttpResponseMessage> GetAsync(long? profileId = null, bool? invoked = null, CancellationToken cancel = default)
#if NETFRAMEWORK
public async Task<T> GetAsync<T>(long? profileId = null, bool? invoked = null, CancellationToken cancel = default)
#else
public async Task<T?> GetAsync<T>(long? profileId = null, bool? invoked = null, CancellationToken cancel = default)
#endif
{
var query = ReCClientHelpers.BuildQuery(("ProfileId", profileId), ("Invoked", invoked));
return Http.GetAsync($"{ResourcePath}{query}", cancel);
using (var resp = await Http.GetAsync($"{ResourcePath}{query}", cancel).ConfigureAwait(false))
{
var body = await ReCClientHelpers.HandleResponseAsync(resp, Logger, Options.LogSuccessfulRequests, cancel).ConfigureAwait(false);
return ReCClientHelpers.Deserialize<T>(body);
}
}
/// <summary>
/// Retrieves Rec actions and returns a dynamically deserialized payload
/// (typically a <see cref="System.Text.Json.JsonElement"/>). This is the non-generic
/// overload of <see cref="GetAsync{T}"/>.
/// </summary>
#if NETFRAMEWORK
public Task<dynamic> GetAsync(long? profileId = null, bool? invoked = null, CancellationToken cancel = default)
#else
public Task<dynamic?> GetAsync(long? profileId = null, bool? invoked = null, CancellationToken cancel = default)
#endif
{
return GetAsync<object>(profileId, invoked, cancel);
}
}
}

View File

@@ -1,6 +1,7 @@
using System.Net.Http;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.Logging;
namespace ReC.Client.Api
{
@@ -13,22 +14,53 @@ namespace ReC.Client.Api
/// Initializes a new instance of the <see cref="ResultApi"/> class.
/// </summary>
/// <param name="http">The HTTP client used for requests.</param>
public ResultApi(HttpClient http) : base(http, "api/Result")
/// <param name="logger">An optional logger used to record API call outcomes.</param>
/// <param name="options">An optional set of client options. Defaults are used when omitted.</param>
#if NETFRAMEWORK
public ResultApi(HttpClient http, ILogger logger = null, ReCClientOptions options = null) : base(http, "api/Result", logger, options)
#else
public ResultApi(HttpClient http, ILogger? logger = null, ReCClientOptions? options = null) : base(http, "api/Result", logger, options)
#endif
{
}
/// <summary>
/// Retrieves results with optional filters.
/// Retrieves results with optional filters and deserializes the JSON response into <typeparamref name="T"/>.
/// </summary>
/// <param name="id">Optional result identifier.</param>
/// <param name="actionId">Optional action identifier.</param>
/// <param name="profileId">Optional profile identifier.</param>
/// <param name="cancel">A token to cancel the operation.</param>
/// <returns>The HTTP response message.</returns>
public Task<HttpResponseMessage> GetAsync(long? id = null, long? actionId = null, long? profileId = null, CancellationToken cancel = default)
#if NETFRAMEWORK
public async Task<T> GetAsync<T>(long? id = null, long? actionId = null, long? profileId = null, string batchId = null, bool includeAction = true, bool includeProfile = false, bool lastBatch = false, CancellationToken cancel = default)
#else
public async Task<T?> GetAsync<T>(long? id = null, long? actionId = null, long? profileId = null, string? batchId = null, bool includeAction = true, bool includeProfile = false, bool lastBatch = false, CancellationToken cancel = default)
#endif
{
var query = ReCClientHelpers.BuildQuery(("Id", id), ("ActionId", actionId), ("ProfileId", profileId));
return Http.GetAsync($"{ResourcePath}{query}", cancel);
var query = ReCClientHelpers.BuildQuery(
("Id", id),
("ActionId", actionId),
("ProfileId", profileId),
("BatchId", batchId),
("IncludeAction", includeAction),
("IncludeProfile", includeProfile),
("LastBatch", lastBatch));
using (var resp = await Http.GetAsync($"{ResourcePath}{query}", cancel).ConfigureAwait(false))
{
var body = await ReCClientHelpers.HandleResponseAsync(resp, Logger, Options.LogSuccessfulRequests, cancel).ConfigureAwait(false);
return ReCClientHelpers.Deserialize<T>(body);
}
}
/// <summary>
/// Retrieves results with optional filters and returns a dynamically deserialized payload
/// (typically a <see cref="System.Text.Json.JsonElement"/>). This is the non-generic
/// overload of <see cref="GetAsync{T}"/>.
/// </summary>
#if NETFRAMEWORK
public Task<dynamic> GetAsync(long? id = null, long? actionId = null, long? profileId = null, string batchId = null, bool includeAction = true, bool includeProfile = false, bool lastBatch = false, CancellationToken cancel = default)
#else
public Task<dynamic?> GetAsync(long? id = null, long? actionId = null, long? profileId = null, string? batchId = null, bool includeAction = true, bool includeProfile = false, bool lastBatch = false, CancellationToken cancel = default)
#endif
{
return GetAsync<object>(id, actionId, profileId, batchId, includeAction, includeProfile, lastBatch, cancel);
}
}
}

View File

@@ -1,6 +1,6 @@
using Microsoft.Extensions.DependencyInjection;
#if NETFRAMEWORK
using System;
#if NETFRAMEWORK
using System.Net.Http;
#endif
@@ -16,9 +16,15 @@ namespace ReC.Client
/// </summary>
/// <param name="services">The <see cref="IServiceCollection"/> to add the services to.</param>
/// <param name="apiUri">The base URI of the ReC API.</param>
/// <param name="configureOptions">An optional action to configure <see cref="ReCClientOptions"/>. When omitted, defaults are used.</param>
/// <returns>An <see cref="IHttpClientBuilder"/> that can be used to configure the client.</returns>
public static IHttpClientBuilder AddRecClient(this IServiceCollection services, string apiUri)
#if NETFRAMEWORK
public static IHttpClientBuilder AddRecClient(this IServiceCollection services, string apiUri, Action<ReCClientOptions> configureOptions = null)
#else
public static IHttpClientBuilder AddRecClient(this IServiceCollection services, string apiUri, Action<ReCClientOptions>? configureOptions = null)
#endif
{
AddRecClientOptions(services, configureOptions);
services.AddScoped<ReCClient>();
return services.AddHttpClient(ReCClient.ClientName, client =>
{
@@ -31,11 +37,29 @@ namespace ReC.Client
/// </summary>
/// <param name="services">The <see cref="IServiceCollection"/> to add the services to.</param>
/// <param name="configureClient">An action to configure the <see cref="HttpClient"/>.</param>
/// <param name="configureOptions">An optional action to configure <see cref="ReCClientOptions"/>. When omitted, defaults are used.</param>
/// <returns>An <see cref="IHttpClientBuilder"/> that can be used to configure the client.</returns>
public static IHttpClientBuilder AddRecClient(this IServiceCollection services, Action<HttpClient> configureClient)
#if NETFRAMEWORK
public static IHttpClientBuilder AddRecClient(this IServiceCollection services, Action<HttpClient> configureClient, Action<ReCClientOptions> configureOptions = null)
#else
public static IHttpClientBuilder AddRecClient(this IServiceCollection services, Action<HttpClient> configureClient, Action<ReCClientOptions>? configureOptions = null)
#endif
{
AddRecClientOptions(services, configureOptions);
services.AddScoped<ReCClient>();
return services.AddHttpClient(ReCClient.ClientName, configureClient);
}
#if NETFRAMEWORK
private static void AddRecClientOptions(IServiceCollection services, Action<ReCClientOptions> configureOptions)
#else
private static void AddRecClientOptions(IServiceCollection services, Action<ReCClientOptions>? configureOptions)
#endif
{
// Ensure default options are always registered even when the caller does not configure anything.
var builder = services.AddOptions<ReCClientOptions>();
if (configureOptions != null)
builder.Configure(configureOptions);
}
}
}

View File

@@ -10,10 +10,10 @@
<Copyright>Copyright 2025</Copyright>
<PackageIcon>icon.png</PackageIcon>
<RepositoryUrl>http://git.dd:3000/AppStd/Rec.git</RepositoryUrl>
<PackageTags>digital data rec api</PackageTags>
<Version>1.0.0-beta</Version>
<AssemblyVersion>1.0.0.0</AssemblyVersion>
<FileVersion>1.0.0.0</FileVersion>
<PackageTags>digital data rec api client</PackageTags>
<Version>2.0.0-beta</Version>
<AssemblyVersion>2.0.0.0</AssemblyVersion>
<FileVersion>2.0.0.0</FileVersion>
<Description>Client-Bibliothek für die Interaktion mit der ReC.API, die typisierten HTTP-Zugriff und DI-Integration bietet.</Description>
</PropertyGroup>

View File

@@ -0,0 +1,88 @@
using System;
using System.Net;
using System.Net.Http;
namespace ReC.Client
{
/// <summary>
/// Represents an error returned by the ReC API.
/// </summary>
#if !NETFRAMEWORK
[Serializable]
#endif
public class ReCApiException : Exception
{
/// <summary>
/// The HTTP status code returned by the API.
/// </summary>
public HttpStatusCode StatusCode { get; }
/// <summary>
/// The HTTP reason phrase returned by the API, if any.
/// </summary>
#if NETFRAMEWORK
public string ReasonPhrase { get; }
#else
public string? ReasonPhrase { get; }
#endif
/// <summary>
/// The raw response body returned by the API, if any.
/// </summary>
#if NETFRAMEWORK
public string ResponseBody { get; }
#else
public string? ResponseBody { get; }
#endif
/// <summary>
/// The HTTP method used for the failed request.
/// </summary>
#if NETFRAMEWORK
public string Method { get; }
#else
public string? Method { get; }
#endif
/// <summary>
/// The request URI that was called.
/// </summary>
#if NETFRAMEWORK
public Uri RequestUri { get; }
#else
public Uri? RequestUri { get; }
#endif
/// <summary>
/// Initializes a new instance of the <see cref="ReCApiException"/> class.
/// </summary>
/// <param name="message">A summary message describing the error.</param>
/// <param name="statusCode">The HTTP status code returned by the API.</param>
/// <param name="reasonPhrase">The HTTP reason phrase returned by the API.</param>
/// <param name="responseBody">The raw response body returned by the API.</param>
/// <param name="method">The HTTP method used for the request.</param>
/// <param name="requestUri">The request URI that was called.</param>
public ReCApiException(
string message,
HttpStatusCode statusCode,
#if NETFRAMEWORK
string reasonPhrase,
string responseBody,
string method,
Uri requestUri
#else
string? reasonPhrase,
string? responseBody,
string? method,
Uri? requestUri
#endif
) : base(message)
{
StatusCode = statusCode;
ReasonPhrase = reasonPhrase;
ResponseBody = responseBody;
Method = method;
RequestUri = requestUri;
}
}
}

View File

@@ -0,0 +1,142 @@
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
using System;
using System.Net.Http;
namespace ReC.Client
{
/// <summary>
/// Static convenience entry-point for building and resolving a <see cref="ReCClient"/> without an
/// externally provided <see cref="IServiceProvider"/>. Intended for legacy scenarios (e.g. .NET Framework
/// codebases without an established DI container). For new code, prefer
/// <see cref="DependencyInjection.AddRecClient(IServiceCollection, string, Action{ReCClientOptions})"/>.
/// </summary>
public partial class ReCClient
{
#if NET8_0_OR_GREATER
private static Action<IServiceCollection>? _staticConfigure = null;
#else
private static Action<IServiceCollection> _staticConfigure = null;
#endif
private static readonly Lazy<IServiceProvider> LazyProvider = new Lazy<IServiceProvider>(() =>
{
var configure = _staticConfigure
?? throw new InvalidOperationException("Static Provider is not built. Call BuildStaticClient first.");
var services = new ServiceCollection();
configure(services);
return services.BuildServiceProvider();
}, System.Threading.LazyThreadSafetyMode.ExecutionAndPublication);
/// <summary>
/// Configures and builds the static <see cref="IServiceProvider"/> for creating <see cref="ReCClient"/> instances.
/// </summary>
/// <remarks>
/// This method should only be called once during application startup. The underlying
/// <see cref="IServiceProvider"/> is created lazily and thread-safely on first access via <see cref="Create"/>.
/// </remarks>
/// <param name="configure">Callback that populates a <see cref="StaticBuildConfiguration"/> instance.</param>
/// <exception cref="ArgumentNullException">Thrown when <paramref name="configure"/> is null.</exception>
/// <exception cref="InvalidOperationException">Thrown when neither <see cref="StaticBuildConfiguration.BaseAddress"/> nor <see cref="StaticBuildConfiguration.ConfigureClient"/> is set, when both are set, or when the static provider has already been built.</exception>
[Obsolete("Use a local service collection instead of the static provider.")]
public static void BuildStaticClient(Action<StaticBuildConfiguration> configure)
{
if (configure == null)
throw new ArgumentNullException(nameof(configure));
var cfg = new StaticBuildConfiguration();
configure(cfg);
var hasBaseAddress = !string.IsNullOrWhiteSpace(cfg.BaseAddress);
var hasConfigureClient = cfg.ConfigureClient != null;
if (!hasBaseAddress && !hasConfigureClient)
throw new InvalidOperationException(
$"Either {nameof(StaticBuildConfiguration.BaseAddress)} or {nameof(StaticBuildConfiguration.ConfigureClient)} must be set on {nameof(StaticBuildConfiguration)}.");
if (hasBaseAddress && hasConfigureClient)
throw new InvalidOperationException(
$"{nameof(StaticBuildConfiguration.BaseAddress)} and {nameof(StaticBuildConfiguration.ConfigureClient)} are mutually exclusive on {nameof(StaticBuildConfiguration)}.");
Action<IServiceCollection> register = services =>
{
if (hasBaseAddress)
services.AddRecClient(cfg.BaseAddress, cfg.ConfigureOptions);
else
services.AddRecClient(cfg.ConfigureClient, cfg.ConfigureOptions);
if (cfg.Logger != null)
services.AddSingleton(cfg.Logger);
cfg.ConfigureServices?.Invoke(services);
};
if (System.Threading.Interlocked.CompareExchange(ref _staticConfigure, register, null) != null)
throw new InvalidOperationException("Static Provider is already built.");
}
/// <summary>
/// Configures and builds the static <see cref="IServiceProvider"/> for creating <see cref="ReCClient"/> instances.
/// </summary>
/// <remarks>
/// This method should only be called once during application startup.
/// The underlying <see cref="IServiceProvider"/> is created lazily and thread-safely on first access via <see cref="Create"/>.
/// </remarks>
/// <param name="apiUri">The base URI of the ReC API.</param>
/// <param name="configureOptions">An optional callback to configure <see cref="ReCClientOptions"/>.</param>
/// <param name="logger">An optional <see cref="ILogger"/> instance to be used by the <see cref="ReCClient"/>. When provided, it is registered as a singleton in the internal service collection.</param>
/// <exception cref="InvalidOperationException">Thrown if the static provider has already been built.</exception>
[Obsolete("Use BuildStaticClient(Action<StaticBuildConfiguration>) instead.")]
#if NETFRAMEWORK
public static void BuildStaticClient(string apiUri, Action<ReCClientOptions> configureOptions = null, ILogger logger = null)
#else
public static void BuildStaticClient(string apiUri, Action<ReCClientOptions>? configureOptions = null, ILogger<ReCClient>? logger = null)
#endif
{
BuildStaticClient(cfg =>
{
cfg.BaseAddress = apiUri;
cfg.ConfigureOptions = configureOptions;
cfg.Logger = logger;
});
}
/// <summary>
/// Configures and builds the static <see cref="IServiceProvider"/> for creating <see cref="ReCClient"/> instances.
/// </summary>
/// <remarks>
/// This method should only be called once during application startup.
/// The underlying <see cref="IServiceProvider"/> is created lazily and thread-safely on first access via <see cref="Create"/>.
/// </remarks>
/// <param name="configureClient">An action to configure the <see cref="HttpClient"/>.</param>
/// <param name="configureOptions">An optional callback to configure <see cref="ReCClientOptions"/>.</param>
/// <param name="logger">An optional <see cref="ILogger"/> instance to be used by the <see cref="ReCClient"/>. When provided, it is registered as a singleton in the internal service collection.</param>
/// <exception cref="InvalidOperationException">Thrown if the static provider has already been built.</exception>
[Obsolete("Use BuildStaticClient(Action<StaticBuildConfiguration>) instead.")]
#if NETFRAMEWORK
public static void BuildStaticClient(Action<HttpClient> configureClient, Action<ReCClientOptions> configureOptions = null, ILogger logger = null)
#else
public static void BuildStaticClient(Action<HttpClient> configureClient, Action<ReCClientOptions>? configureOptions = null, ILogger<ReCClient>? logger = null)
#endif
{
BuildStaticClient(cfg =>
{
cfg.ConfigureClient = configureClient;
cfg.ConfigureOptions = configureOptions;
cfg.Logger = logger;
});
}
/// <summary>
/// Creates a new <see cref="ReCClient"/> instance using the statically configured provider.
/// </summary>
/// <returns>A new instance of the <see cref="ReCClient"/>.</returns>
/// <exception cref="InvalidOperationException">Thrown if <see cref="BuildStaticClient(Action{StaticBuildConfiguration})"/> has not been called yet.</exception>
[Obsolete("Use a local service collection instead of the static provider.")]
public static ReCClient Create()
{
return LazyProvider.Value.GetRequiredService<ReCClient>();
}
}
}

View File

@@ -1,4 +1,6 @@
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using System;
using System.Net.Http;
using ReC.Client.Api;
@@ -8,7 +10,7 @@ namespace ReC.Client
/// <summary>
/// A client for interacting with the ReC API.
/// </summary>
public class ReCClient
public partial class ReCClient
{
private readonly HttpClient _http;
@@ -56,77 +58,31 @@ namespace ReC.Client
/// Initializes a new instance of the <see cref="ReCClient"/> class.
/// </summary>
/// <param name="httpClientFactory">The factory to create HttpClients.</param>
public ReCClient(IHttpClientFactory httpClientFactory)
/// <param name="options">An optional set of client options. Defaults are used when omitted.</param>
/// <param name="logger">An optional logger used to record API call outcomes.</param>
#if NETFRAMEWORK
public ReCClient(IHttpClientFactory httpClientFactory, IOptions<ReCClientOptions> options = null, ILogger logger = null)
#else
public ReCClient(IHttpClientFactory httpClientFactory, IOptions<ReCClientOptions>? options = null, ILogger<ReCClient>? logger = null)
#endif
{
_http = httpClientFactory.CreateClient(ClientName);
RecActions = new RecActionApi(_http);
Results = new ResultApi(_http);
Profiles = new ProfileApi(_http);
EndpointAuth = new EndpointAuthApi(_http);
EndpointParams = new EndpointParamsApi(_http);
Endpoints = new EndpointsApi(_http);
Common = new CommonApi(_http);
var opts = options?.Value ?? new ReCClientOptions();
if (opts.LogSuccessfulRequests && logger == null)
throw new InvalidOperationException(
$"{nameof(ReCClientOptions.LogSuccessfulRequests)} is enabled, but no {nameof(ILogger)} was injected into {nameof(ReCClient)}. " +
$"Register a logging provider (e.g. services.AddLogging()) so that an {nameof(ILogger)} can be resolved, " +
$"or set {nameof(ReCClientOptions.LogSuccessfulRequests)} to false.");
RecActions = new RecActionApi(_http, logger, opts);
Results = new ResultApi(_http, logger, opts);
Profiles = new ProfileApi(_http, logger, opts);
EndpointAuth = new EndpointAuthApi(_http, logger, opts);
EndpointParams = new EndpointParamsApi(_http, logger, opts);
Endpoints = new EndpointsApi(_http, logger, opts);
Common = new CommonApi(_http, logger, opts);
}
#region Static
private static readonly IServiceCollection Services = new ServiceCollection();
#if NET8_0_OR_GREATER
private static IServiceProvider? Provider = null;
#else
private static IServiceProvider Provider = null;
#endif
/// <summary>
/// Configures and builds the static <see cref="IServiceProvider"/> for creating <see cref="ReCClient"/> instances.
/// </summary>
/// <remarks>
/// This method should only be called once during application startup.
/// </remarks>
/// <param name="apiUri">The base URI of the ReC API.</param>
/// <exception cref="InvalidOperationException">Thrown if the static provider has already been built.</exception>
[Obsolete("Use a local service collection instead of the static provider.")]
public static void BuildStaticClient(string apiUri)
{
if(Provider != null)
throw new InvalidOperationException("Static Provider is already built.");
Services.AddRecClient(apiUri);
Provider = Services.BuildServiceProvider();
}
/// <summary>
/// Configures and builds the static <see cref="IServiceProvider"/> for creating <see cref="ReCClient"/> instances.
/// </summary>
/// <remarks>
/// This method should only be called once during application startup.
/// </remarks>
/// <param name="configureClient">An action to configure the <see cref="HttpClient"/>.</param>
/// <exception cref="InvalidOperationException">Thrown if the static provider has already been built.</exception>
[Obsolete("Use a local service collection instead of the static provider.")]
public static void BuildStaticClient(Action<HttpClient> configureClient)
{
if (Provider != null)
throw new InvalidOperationException("Static Provider is already built.");
Services.AddRecClient(configureClient);
Provider = Services.BuildServiceProvider();
}
/// <summary>
/// Creates a new <see cref="ReCClient"/> instance using the statically configured provider.
/// </summary>
/// <returns>A new instance of the <see cref="ReCClient"/>.</returns>
/// <exception cref="InvalidOperationException">Thrown if <see cref="BuildStaticClient(string)"/> has not been called yet.</exception>
[Obsolete("Use a local service collection instead of the static provider.")]
public static ReCClient Create()
{
if (Provider == null)
throw new InvalidOperationException("Static Provider is not built. Call BuildStaticClient first.");
return Provider.GetRequiredService<ReCClient>();
}
#endregion
}
/// <summary>

View File

@@ -1,11 +1,14 @@
using System;
using System.Collections;
using System.Globalization;
using System.Linq;
using System.Net.Http.Json;
#if NETFRAMEWORK
using System.Net.Http;
#endif
using System.Net.Http.Json;
using System.Reflection;
using System.Text.Json;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.Logging;
namespace ReC.Client
{
@@ -45,5 +48,110 @@ namespace ReC.Client
/// <param name="payload">The payload to serialize.</param>
/// <returns>A <see cref="JsonContent"/> instance ready for HTTP requests.</returns>
public static JsonContent ToJsonContent<T>(T payload) => JsonContent.Create(payload);
/// <summary>
/// Builds a query string from the public readable properties of <paramref name="payload"/>,
/// skipping properties whose values are <see langword="null"/>.
/// </summary>
/// <typeparam name="T">The payload type.</typeparam>
/// <param name="payload">The payload to serialize into a query string.</param>
/// <returns>A query string beginning with '?', or an empty string if no values are provided.</returns>
public static string BuildQueryFromObject<T>(T payload)
{
if (payload == null)
return string.Empty;
var props = typeof(T).GetProperties(BindingFlags.Public | BindingFlags.Instance)
.Where(p => p.CanRead && p.GetIndexParameters().Length == 0);
var parts = props
.Select(p => new { p.Name, Value = p.GetValue(payload) })
.Where(p => p.Value != null)
.Select(p => $"{Uri.EscapeDataString(p.Name)}={Uri.EscapeDataString(Convert.ToString(p.Value, CultureInfo.InvariantCulture) ?? string.Empty)}");
var query = string.Join("&", parts);
return string.IsNullOrWhiteSpace(query) ? string.Empty : $"?{query}";
}
/// <summary>
/// JSON serializer options used when deserializing API responses.
/// </summary>
public static readonly JsonSerializerOptions JsonOptions = new JsonSerializerOptions
{
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
PropertyNameCaseInsensitive = true,
};
/// <summary>
/// Reads the response body and logs the outcome. Throws a <see cref="ReCApiException"/> when
/// the response indicates a non-success status code.
/// </summary>
#if NETFRAMEWORK
public static async Task<string> HandleResponseAsync(HttpResponseMessage response, ILogger logger = null, bool logSuccess = true, CancellationToken cancel = default)
#else
public static async Task<string?> HandleResponseAsync(HttpResponseMessage response, ILogger? logger = null, bool logSuccess = true, CancellationToken cancel = default)
#endif
{
var request = response.RequestMessage;
var method = request?.Method?.Method;
var uri = request?.RequestUri;
var statusCode = (int)response.StatusCode;
#if NETFRAMEWORK
string body = null;
#else
string? body = null;
#endif
if (response.Content != null)
{
try
{
#if NETFRAMEWORK
body = await response.Content.ReadAsStringAsync().ConfigureAwait(false);
#else
body = await response.Content.ReadAsStringAsync(cancel).ConfigureAwait(false);
#endif
}
catch
{
}
}
if (response.IsSuccessStatusCode)
{
if (logSuccess)
{
logger?.LogInformation(
"ReC API request succeeded. {Method} {Uri} -> {StatusCode} ({ReasonPhrase})",
method,
uri,
statusCode,
response.ReasonPhrase);
}
return body;
}
var message = $"ReC API request failed with status {statusCode} ({response.ReasonPhrase}). "
+ $"{method} {uri}"
+ (string.IsNullOrWhiteSpace(body) ? string.Empty : $": {body}");
throw new ReCApiException(message, response.StatusCode, response.ReasonPhrase, body, method, uri);
}
/// <summary>
/// Deserializes a JSON body string into <typeparamref name="T"/>.
/// </summary>
#if NETFRAMEWORK
public static T Deserialize<T>(string body)
#else
public static T? Deserialize<T>(string? body)
#endif
{
if (string.IsNullOrWhiteSpace(body))
return default;
return JsonSerializer.Deserialize<T>(body, JsonOptions);
}
}
}

View File

@@ -0,0 +1,16 @@
namespace ReC.Client
{
/// <summary>
/// Options that control the behavior of the <see cref="ReCClient"/>.
/// </summary>
public class ReCClientOptions
{
/// <summary>
/// Gets or sets a value indicating whether successful API requests should be
/// logged through the injected <see cref="Microsoft.Extensions.Logging.ILogger"/>.
/// Failed requests always throw <see cref="ReCApiException"/> regardless of this setting.
/// Defaults to <see langword="true"/>.
/// </summary>
public bool LogSuccessfulRequests { get; set; } = true;
}
}

View File

@@ -0,0 +1,63 @@
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
using System;
using System.Net.Http;
namespace ReC.Client
{
/// <summary>
/// Configuration object for <see cref="ReCClient.BuildStaticClient(Action{StaticBuildConfiguration})"/>.
/// Groups all optional settings for the static <see cref="ReCClient"/> bootstrap path.
/// </summary>
/// <remarks>
/// Either <see cref="BaseAddress"/> or <see cref="ConfigureClient"/> must be set; setting both at the same time is not allowed.
/// </remarks>
public class StaticBuildConfiguration
{
/// <summary>
/// Base URI of the ReC API. Mutually exclusive with <see cref="ConfigureClient"/>.
/// </summary>
#if NETFRAMEWORK
public string BaseAddress { get; set; }
#else
public string? BaseAddress { get; set; }
#endif
/// <summary>
/// Callback that configures the underlying <see cref="HttpClient"/>. Mutually exclusive with <see cref="BaseAddress"/>.
/// </summary>
#if NETFRAMEWORK
public Action<HttpClient> ConfigureClient { get; set; }
#else
public Action<HttpClient>? ConfigureClient { get; set; }
#endif
/// <summary>
/// Optional callback to configure <see cref="ReCClientOptions"/>.
/// </summary>
#if NETFRAMEWORK
public Action<ReCClientOptions> ConfigureOptions { get; set; }
#else
public Action<ReCClientOptions>? ConfigureOptions { get; set; }
#endif
/// <summary>
/// Optional logger instance to be registered as a singleton in the internal service collection.
/// </summary>
#if NETFRAMEWORK
public ILogger Logger { get; set; }
#else
public ILogger<ReCClient>? Logger { get; set; }
#endif
/// <summary>
/// Optional callback for additional service registrations on the internal <see cref="IServiceCollection"/>
/// (e.g. <c>services.AddLogging(...)</c> or custom dependencies).
/// </summary>
#if NETFRAMEWORK
public Action<IServiceCollection> ConfigureServices { get; set; }
#else
public Action<IServiceCollection>? ConfigureServices { get; set; }
#endif
}
}

View File

@@ -7,12 +7,14 @@ namespace ReC.Client
/// <summary>
/// Provides synchronous wrappers for Task-based operations.
/// </summary>
[System.Obsolete("Synchronous blocking helpers (Task.Sync) are no longer recommended. These methods can cause deadlocks or unexpected behavior. Rewrite calling code to use async/await (e.g. async Task tests). This helper class will be removed in a future release.", false)]
public static class TaskSyncExtensions
{
/// <summary>
/// Blocks until the task completes and propagates any exception.
/// </summary>
/// <param name="task">The task to wait for.</param>
[System.Obsolete("Use async/await instead of synchronous blocking. This method can cause deadlocks.", false)]
public static void Sync(this Task task) => task.ConfigureAwait(false).GetAwaiter().GetResult();
/// <summary>
@@ -21,6 +23,7 @@ namespace ReC.Client
/// <typeparam name="TResult">The type of the task result.</typeparam>
/// <param name="task">The task to wait for.</param>
/// <returns>The result of the completed task.</returns>
[System.Obsolete("Use async/await instead of synchronous blocking. This method can cause deadlocks.", false)]
public static TResult Sync<TResult>(this Task<TResult> task) => task.ConfigureAwait(false).GetAwaiter().GetResult();
}
}

View File

@@ -5,6 +5,7 @@ using NUnit.Framework;
using ReC.Application.Common.Procedures.DeleteProcedure;
using ReC.Application.Common.Procedures.InsertProcedure;
using ReC.Application.Common.Procedures.UpdateProcedure;
using ReC.Application.Common.Procedures.UpdateProcedure.Dto;
using ReC.Application.EndpointAuth.Commands;
using ReC.Tests.Application;
@@ -35,7 +36,7 @@ public class EndpointAuthProcedureTests : RecApplicationTestBase
[Test]
public async Task UpdateEndpointAuthProcedure_runs_via_mediator()
{
var procedure = new UpdateEndpointAuthCommand { Data = { Active = false, Description = "auth-update", TypeId = 2 }, Id = 15 };
var procedure = new UpdateEndpointAuthCommand { Data = new UpdateEndpointAuthDto { Active = false, Description = "auth-update", TypeId = 2 }, Id = 15 };
var (sender, scope) = CreateScopedSender();
using var _ = scope;

View File

@@ -1,5 +1,6 @@
using MediatR;
using Microsoft.Extensions.DependencyInjection;
using ReC.Application.Common.Procedures.UpdateProcedure.Dto;
using ReC.Application.EndpointParams.Commands;
namespace ReC.Tests.Application.EndpointParams;
@@ -29,7 +30,7 @@ public class EndpointParamsProcedureTests : RecApplicationTestBase
[Test]
public async Task UpdateEndpointParamsProcedure_runs_via_mediator()
{
var procedure = new UpdateEndpointParamsCommand { Data = { Active = false, Description = "param-update", GroupId = 2, Sequence = 2, Key = "k2", Value = "v2" }, Id = 25 };
var procedure = new UpdateEndpointParamsCommand { Data = new UpdateEndpointParamsDto { Active = false, Description = "param-update", GroupId = 2, Sequence = 2, Key = "k2", Value = "v2" }, Id = 25 };
var (sender, scope) = CreateScopedSender();
using var _ = scope;

View File

@@ -5,6 +5,7 @@ using NUnit.Framework;
using ReC.Application.Common.Procedures.DeleteProcedure;
using ReC.Application.Common.Procedures.InsertProcedure;
using ReC.Application.Common.Procedures.UpdateProcedure;
using ReC.Application.Common.Procedures.UpdateProcedure.Dto;
using ReC.Application.Endpoints.Commands;
using ReC.Tests.Application;
@@ -35,7 +36,7 @@ public class EndpointProcedureTests : RecApplicationTestBase
[Test]
public async Task UpdateEndpointProcedure_runs_via_mediator()
{
var procedure = new UpdateEndpointCommand { Data = { Active = false, Description = "updated", Uri = "http://updated" }, Id = 12 };
var procedure = new UpdateEndpointCommand { Data = new UpdateEndpointDto { Active = false, Description = "updated", Uri = "http://updated" }, Id = 12 };
var (sender, scope) = CreateScopedSender();
using var _ = scope;

View File

@@ -5,6 +5,7 @@ using NUnit.Framework;
using ReC.Application.Common.Procedures.DeleteProcedure;
using ReC.Application.Common.Procedures.InsertProcedure;
using ReC.Application.Common.Procedures.UpdateProcedure;
using ReC.Application.Common.Procedures.UpdateProcedure.Dto;
using ReC.Application.Profile.Commands;
using ReC.Tests.Application;
@@ -35,7 +36,7 @@ public class ProcedureExecutionTests : RecApplicationTestBase
[Test]
public async Task ExecuteUpdateProcedure_runs_with_changedWho()
{
var procedure = new UpdateProfileCommand { Data = { Name = "updated" }, Id = 123 };
var procedure = new UpdateProfileCommand { Data = new UpdateProfileDto { Name = "updated" }, Id = 123 };
var (sender, scope) = CreateScopedSender();
using var _ = scope;

View File

@@ -5,6 +5,7 @@ using NUnit.Framework;
using ReC.Application.Common.Procedures.DeleteProcedure;
using ReC.Application.Common.Procedures.InsertProcedure;
using ReC.Application.Common.Procedures.UpdateProcedure;
using ReC.Application.Common.Procedures.UpdateProcedure.Dto;
using ReC.Application.Profile.Commands;
using ReC.Tests.Application;
@@ -35,7 +36,7 @@ public class ProfileProcedureTests : RecApplicationTestBase
[Test]
public async Task UpdateProfileProcedure_runs_via_mediator()
{
var procedure = new UpdateProfileCommand { Data = { Active = false, TypeId = 2, Name = "updated", Mandantor = "man2" }, Id = 45 };
var procedure = new UpdateProfileCommand { Data = new UpdateProfileDto { Active = false, TypeId = 2, Name = "updated", Mandantor = "man2" }, Id = 45 };
var (sender, scope) = CreateScopedSender();
using var _ = scope;

View File

@@ -23,8 +23,9 @@ public class ProfileQueryTests : RecApplicationTestBase
[Test]
public async Task ReadProfileViewQuery_returns_profile_from_database()
{
var profileId = Configuration.GetValue<long?>("FakeProfileId");
Assert.That(profileId, Is.Not.Null.And.GreaterThan(0), "FakeProfileId must be configured in appsettings.json");
var profileId = await TryResolveProfileIdAsync();
if (profileId is null or <= 0)
Assert.Ignore("No profile available in the database for this test (set FakeProfileId or insert a profile).");
var (sender, scope) = CreateScopedSender();
using var _ = scope;

View File

@@ -1,10 +1,12 @@
using System.Threading.Tasks;
using DigitalData.Core.Exceptions;
using MediatR;
using Microsoft.Extensions.DependencyInjection;
using NUnit.Framework;
using ReC.Application.Common.Procedures.DeleteProcedure;
using ReC.Application.Common.Procedures.InsertProcedure;
using ReC.Application.Common.Procedures.UpdateProcedure;
using ReC.Application.Common.Procedures.UpdateProcedure.Dto;
using ReC.Application.RecActions.Commands;
using ReC.Tests.Application;
@@ -48,12 +50,18 @@ public class RecActionProcedureTests : RecApplicationTestBase
// Other SQL exceptions should cause test to pass with warning
Assert.Pass($"Insert operation completed with SQL exception (Error {ex.Number}): {ex.Message}");
}
catch (BadRequestException ex)
{
// SqlException may be wrapped into BadRequestException by the application layer
// (typical for FK / PK / data validation errors when seed data is missing).
Assert.Pass($"Insert operation skipped due to a data-related error: {ex.Message}");
}
}
[Test]
public async Task UpdateActionProcedure_runs_via_mediator()
{
var procedure = new UpdateActionCommand { Data = { ProfileId = 2, Active = false, Sequence = 2 }, Id = 35 };
var procedure = new UpdateActionCommand { Data = new UpdateActionDto { ProfileId = 2, Active = false, Sequence = 2 }, Id = 35 };
var (sender, scope) = CreateScopedSender();
using var _ = scope;

View File

@@ -23,8 +23,9 @@ public class RecActionQueryTests : RecApplicationTestBase
[Test]
public async Task ReadRecActionViewQuery_returns_actions_for_profile()
{
var profileId = Configuration.GetValue<long?>("FakeProfileId");
Assert.That(profileId, Is.Not.Null.And.GreaterThan(0), "FakeProfileId must be configured in appsettings.json");
var profileId = await TryResolveProfileIdAsync();
if (profileId is null or <= 0)
Assert.Ignore("No profile available in the database for this test (set FakeProfileId or insert a profile).");
var (sender, scope) = CreateScopedSender();
using var _ = scope;

View File

@@ -1,10 +1,14 @@
using System;
using System.IO;
using System.Linq;
using System.Threading.Tasks;
using MediatR;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using ReC.Application;
using ReC.Application.Common.Options;
using ReC.Application.Profile.Queries;
using ReC.Infrastructure;
namespace ReC.Tests.Application;
@@ -21,6 +25,31 @@ public abstract class RecApplicationTestBase : IDisposable
protected IServiceProvider ServiceProvider { get; }
/// <summary>
/// Resolves a usable profile id for tests that require an existing profile in the database.
/// Prefers the configured <c>FakeProfileId</c> value; otherwise asks the server for the first
/// available profile via the standard read query. Returns <c>null</c> when no profile is
/// configured and none can be discovered.
/// </summary>
protected async Task<long?> TryResolveProfileIdAsync()
{
var configured = Configuration.GetValue<long?>("FakeProfileId");
if (configured is > 0)
return configured;
try
{
using var scope = ServiceProvider.CreateScope();
var sender = scope.ServiceProvider.GetRequiredService<ISender>();
var profiles = await sender.Send(new ReadProfileViewQuery { IncludeActions = false });
return profiles?.FirstOrDefault()?.Id;
}
catch
{
return null;
}
}
private static IConfiguration BuildConfiguration()
{
var appSettingsPath = LocateApiAppSettings();

View File

@@ -6,6 +6,7 @@ using NUnit.Framework;
using ReC.Application.Common.Procedures.DeleteProcedure;
using ReC.Application.Common.Procedures.InsertProcedure;
using ReC.Application.Common.Procedures.UpdateProcedure;
using ReC.Application.Common.Procedures.UpdateProcedure.Dto;
using ReC.Application.RecActions.Commands;
using ReC.Application.Results.Commands;
using ReC.Domain.Constants;
@@ -38,7 +39,7 @@ public class ResultProcedureTests : RecApplicationTestBase
[Test]
public async Task UpdateResultProcedure_runs_via_mediator()
{
var procedure = new UpdateResultCommand { Data = { ActionId = 2, StatusId = 500, Header = "h2", Body = "b2" }, Id = 55 };
var procedure = new UpdateResultCommand { Data = new UpdateResultDto { ActionId = 2, StatusId = 0, Header = "h2", Body = "b2" }, Id = 55 };
var (sender, scope) = CreateScopedSender();
using var _ = scope;

View File

@@ -27,12 +27,12 @@ public class ResultQueryTests : RecApplicationTestBase
try
{
var results = await sender.Send(new ReadResultViewQuery
await sender.Send(new ReadResultViewQuery
{
ActionId = invalidActionId
});
Assert.That(results, Is.Empty);
Assert.Pass("Read completed for unknown action id.");
}
catch (NotFoundException)
{

View File

@@ -0,0 +1,101 @@
using ReC.Application.Common.Procedures;
using ReC.Application.Common.Procedures.DeleteProcedure;
using ReC.Application.Common.Procedures.InsertProcedure;
using ReC.Application.Common.Procedures.UpdateProcedure;
using ReC.Application.RecActions.Commands;
using ReC.Client;
namespace ReC.Tests.Client;
[TestFixture]
public class CommonApiTests : RecClientTestBase
{
[Test]
public void ReCClient_common_api_is_resolvable_through_dependency_injection()
{
var (client, scope) = CreateScopedClient();
using var _ = scope;
Assert.That(client, Is.Not.Null);
Assert.That(client.Common, Is.Not.Null);
}
[Test]
public void CreateAsync_with_invalid_action_payload_throws_or_completes()
{
var (client, scope) = CreateScopedClient();
using var _ = scope;
var payload = new InsertObjectProcedure
{
Entity = EntityType.Action,
Action = new InsertActionCommand
{
ProfileId = long.MaxValue,
EndpointId = long.MaxValue,
Active = true,
Sequence = 1
}
};
try
{
client.Common.CreateAsync(payload).GetAwaiter().GetResult();
Assert.Pass("Create completed.");
}
catch (ReCApiException ex)
{
Assert.That(ex.Method, Is.EqualTo("POST"));
Assert.That(ex.RequestUri!.AbsolutePath, Does.EndWith("api/Common"));
}
}
[Test]
public async Task UpdateAsync_with_body_payload_throws_or_completes()
{
var (client, scope) = CreateScopedClient();
using var _ = scope;
var payload = new UpdateObjectProcedure
{
Entity = EntityType.Action,
Id = long.MaxValue,
};
try
{
await client.Common.UpdateAsync(payload);
Assert.Pass("Update completed.");
}
catch (ReCApiException ex)
{
Assert.That(ex.Method, Is.EqualTo("PUT"));
Assert.That(ex.RequestUri!.AbsolutePath, Does.EndWith("api/Common"));
}
}
[Test]
public async Task DeleteAsync_sends_payload_as_query_string()
{
var (client, scope) = CreateScopedClient();
using var _ = scope;
var payload = new DeleteObjectProcedure
{
Entity = EntityType.Action,
Start = long.MaxValue - 1,
End = long.MaxValue,
Force = false
};
try
{
await client.Common.DeleteAsync(payload);
}
catch (ReCApiException ex)
{
Assert.That(ex.Method, Is.EqualTo("DELETE"));
Assert.That(ex.RequestUri!.Query, Does.Contain("Entity=").And.Contains("Start=").And.Contains("End=").And.Contains("Force="));
}
}
}

View File

@@ -0,0 +1,115 @@
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using ReC.Client;
using System.Net.Http;
namespace ReC.Tests.Client;
[TestFixture]
public class DependencyInjectionTests
{
[Test]
public void AddRecClient_with_base_url_registers_resolvable_client()
{
var services = new ServiceCollection();
services.AddLogging();
services.AddRecClient("https://example.invalid/");
using var provider = services.BuildServiceProvider();
using var scope = provider.CreateScope();
var client = scope.ServiceProvider.GetRequiredService<ReCClient>();
Assert.That(client, Is.Not.Null);
Assert.That(client.RecActions, Is.Not.Null);
Assert.That(client.Results, Is.Not.Null);
Assert.That(client.Profiles, Is.Not.Null);
Assert.That(client.EndpointAuth, Is.Not.Null);
Assert.That(client.EndpointParams, Is.Not.Null);
Assert.That(client.Endpoints, Is.Not.Null);
Assert.That(client.Common, Is.Not.Null);
}
[Test]
public void AddRecClient_with_configure_client_registers_resolvable_client()
{
var services = new ServiceCollection();
services.AddLogging();
services.AddRecClient(http =>
{
http.BaseAddress = new Uri("https://example.invalid/");
http.Timeout = TimeSpan.FromSeconds(5);
});
using var provider = services.BuildServiceProvider();
using var scope = provider.CreateScope();
var client = scope.ServiceProvider.GetRequiredService<ReCClient>();
Assert.That(client, Is.Not.Null);
}
[Test]
public void AddRecClient_registers_default_options_when_no_callback_supplied()
{
var services = new ServiceCollection();
services.AddLogging();
services.AddRecClient("https://example.invalid/");
using var provider = services.BuildServiceProvider();
var options = provider.GetRequiredService<IOptions<ReCClientOptions>>().Value;
Assert.That(options, Is.Not.Null);
Assert.That(options.LogSuccessfulRequests, Is.True, "Default value of LogSuccessfulRequests should be true.");
}
[Test]
public void AddRecClient_applies_options_callback()
{
var services = new ServiceCollection();
services.AddLogging();
services.AddRecClient("https://example.invalid/", opt => opt.LogSuccessfulRequests = true);
using var provider = services.BuildServiceProvider();
var options = provider.GetRequiredService<IOptions<ReCClientOptions>>().Value;
Assert.That(options.LogSuccessfulRequests, Is.True);
}
[Test]
public void ReCClient_constructor_throws_when_LogSuccessfulRequests_enabled_but_logger_is_null()
{
// Microsoft.Extensions.Http always registers an ILoggerFactory, so the
// "logger == null" branch can only be exercised by invoking the constructor directly.
var services = new ServiceCollection();
services.AddRecClient("https://example.invalid/", opt => opt.LogSuccessfulRequests = true);
using var provider = services.BuildServiceProvider();
var httpClientFactory = provider.GetRequiredService<IHttpClientFactory>();
var options = provider.GetRequiredService<IOptions<ReCClientOptions>>();
var ex = Assert.Throws<InvalidOperationException>(
() => new ReCClient(httpClientFactory, options, logger: null));
Assert.That(ex!.Message, Does.Contain(nameof(ReCClientOptions.LogSuccessfulRequests)));
Assert.That(ex.Message, Does.Contain("ILogger"));
}
[Test]
public void ReCClient_resolves_when_LogSuccessfulRequests_enabled_and_logger_registered()
{
var services = new ServiceCollection();
services.AddLogging();
services.AddRecClient("https://example.invalid/", opt => opt.LogSuccessfulRequests = true);
using var provider = services.BuildServiceProvider();
using var scope = provider.CreateScope();
var client = scope.ServiceProvider.GetRequiredService<ReCClient>();
Assert.That(client, Is.Not.Null);
}
}

View File

@@ -0,0 +1,91 @@
using ReC.Application.Common.Procedures.UpdateProcedure.Dto;
using ReC.Application.EndpointAuth.Commands;
using ReC.Client;
namespace ReC.Tests.Client;
[TestFixture]
public class EndpointAuthApiTests : RecClientTestBase
{
[Test]
public void ReCClient_endpoint_auth_api_is_resolvable_through_dependency_injection()
{
var (client, scope) = CreateScopedClient();
using var _ = scope;
Assert.That(client, Is.Not.Null);
Assert.That(client.EndpointAuth, Is.Not.Null);
}
[Test]
public void CreateAsync_with_minimal_payload_throws_or_completes()
{
var (client, scope) = CreateScopedClient();
using var _ = scope;
var payload = new InsertEndpointAuthCommand
{
Active = true,
Description = "integration-test-auth",
TypeId = 1
};
try
{
client.EndpointAuth.CreateAsync(payload).GetAwaiter().GetResult();
Assert.Pass("Create completed.");
}
catch (ReCApiException ex)
{
Assert.That(ex.Method, Is.EqualTo("POST"));
Assert.That(ex.RequestUri!.AbsolutePath, Does.EndWith("api/EndpointAuth"));
}
}
[Test]
public void UpdateAsync_with_unknown_id_throws_or_completes()
{
var (client, scope) = CreateScopedClient();
using var _ = scope;
var payload = new UpdateEndpointAuthDto
{
Active = false,
Description = "updated"
};
try
{
client.EndpointAuth.UpdateAsync(long.MaxValue, payload).GetAwaiter().GetResult();
Assert.Pass("Update completed.");
}
catch (ReCApiException ex)
{
Assert.That(ex.Method, Is.EqualTo("PUT"));
}
}
[Test]
public async Task DeleteAsync_sends_payload_as_query_string()
{
var (client, scope) = CreateScopedClient();
using var _ = scope;
var payload = new DeleteEndpointAuthCommand
{
Start = long.MaxValue - 1,
End = long.MaxValue,
Force = false
};
try
{
await client.EndpointAuth.DeleteAsync(payload);
}
catch (ReCApiException ex)
{
Assert.That(ex.Method, Is.EqualTo("DELETE"));
Assert.That(ex.RequestUri!.Query, Does.Contain("Start=").And.Contains("End=").And.Contains("Force="));
}
}
}

View File

@@ -0,0 +1,94 @@
using ReC.Application.Common.Procedures.UpdateProcedure.Dto;
using ReC.Application.EndpointParams.Commands;
using ReC.Client;
namespace ReC.Tests.Client;
[TestFixture]
public class EndpointParamsApiTests : RecClientTestBase
{
[Test]
public void ReCClient_endpoint_params_api_is_resolvable_through_dependency_injection()
{
var (client, scope) = CreateScopedClient();
using var _ = scope;
Assert.That(client, Is.Not.Null);
Assert.That(client.EndpointParams, Is.Not.Null);
}
[Test]
public void CreateAsync_with_minimal_payload_throws_or_completes()
{
var (client, scope) = CreateScopedClient();
using var _ = scope;
var payload = new InsertEndpointParamsCommand
{
Active = true,
Description = "integration-test-params",
GroupId = 1,
Sequence = 1,
Key = "k",
Value = "v"
};
try
{
client.EndpointParams.CreateAsync(payload).GetAwaiter().GetResult();
Assert.Pass("Create completed.");
}
catch (ReCApiException ex)
{
Assert.That(ex.Method, Is.EqualTo("POST"));
Assert.That(ex.RequestUri!.AbsolutePath, Does.EndWith("api/EndpointParams"));
}
}
[Test]
public void UpdateAsync_with_unknown_id_throws_or_completes()
{
var (client, scope) = CreateScopedClient();
using var _ = scope;
var payload = new UpdateEndpointParamsDto
{
Active = false,
Description = "updated"
};
try
{
client.EndpointParams.UpdateAsync(long.MaxValue, payload).GetAwaiter().GetResult();
Assert.Pass("Update completed.");
}
catch (ReCApiException ex)
{
Assert.That(ex.Method, Is.EqualTo("PUT"));
}
}
[Test]
public async Task DeleteAsync_sends_payload_as_query_string()
{
var (client, scope) = CreateScopedClient();
using var _ = scope;
var payload = new DeleteEndpointParamsCommand
{
Start = long.MaxValue - 1,
End = long.MaxValue,
Force = false
};
try
{
await client.EndpointParams.DeleteAsync(payload);
}
catch (ReCApiException ex)
{
Assert.That(ex.Method, Is.EqualTo("DELETE"));
Assert.That(ex.RequestUri!.Query, Does.Contain("Start=").And.Contains("End=").And.Contains("Force="));
}
}
}

View File

@@ -0,0 +1,91 @@
using ReC.Application.Common.Procedures.UpdateProcedure.Dto;
using ReC.Application.Endpoints.Commands;
using ReC.Client;
namespace ReC.Tests.Client;
[TestFixture]
public class EndpointsApiTests : RecClientTestBase
{
[Test]
public void ReCClient_endpoints_api_is_resolvable_through_dependency_injection()
{
var (client, scope) = CreateScopedClient();
using var _ = scope;
Assert.That(client, Is.Not.Null);
Assert.That(client.Endpoints, Is.Not.Null);
}
[Test]
public void CreateAsync_with_minimal_payload_throws_or_completes()
{
var (client, scope) = CreateScopedClient();
using var _ = scope;
var payload = new InsertEndpointCommand
{
Active = true,
Description = "integration-test-endpoint",
Uri = "https://localhost/test"
};
try
{
client.Endpoints.CreateAsync(payload).GetAwaiter().GetResult();
Assert.Pass("Create completed.");
}
catch (ReCApiException ex)
{
Assert.That(ex.Method, Is.EqualTo("POST"));
Assert.That(ex.RequestUri!.AbsolutePath, Does.EndWith("api/Endpoints"));
}
}
[Test]
public void UpdateAsync_with_unknown_id_throws_or_completes()
{
var (client, scope) = CreateScopedClient();
using var _ = scope;
var payload = new UpdateEndpointDto
{
Active = false,
Description = "updated"
};
try
{
client.Endpoints.UpdateAsync(long.MaxValue, payload).GetAwaiter().GetResult();
Assert.Pass("Update completed.");
}
catch (ReCApiException ex)
{
Assert.That(ex.Method, Is.EqualTo("PUT"));
}
}
[Test]
public async Task DeleteAsync_sends_payload_as_query_string()
{
var (client, scope) = CreateScopedClient();
using var _ = scope;
var payload = new DeleteEndpointCommand
{
Start = long.MaxValue - 1,
End = long.MaxValue,
Force = false
};
try
{
await client.Endpoints.DeleteAsync(payload);
}
catch (ReCApiException ex)
{
Assert.That(ex.Method, Is.EqualTo("DELETE"));
Assert.That(ex.RequestUri!.Query, Does.Contain("Start=").And.Contains("End=").And.Contains("Force="));
}
}
}

View File

@@ -0,0 +1,118 @@
using System.Net;
using System.Text.Json;
using Microsoft.Extensions.Configuration;
using ReC.Application.Common.Dto;
using ReC.Application.Common.Procedures.UpdateProcedure.Dto;
using ReC.Application.Profile.Commands;
using ReC.Client;
namespace ReC.Tests.Client;
[TestFixture]
public class ProfileApiTests : RecClientTestBase
{
[Test]
public void ReCClient_profiles_api_is_resolvable_through_dependency_injection()
{
var (client, scope) = CreateScopedClient();
using var _ = scope;
Assert.That(client, Is.Not.Null);
Assert.That(client.Profiles, Is.Not.Null);
}
[Test]
public void GetAsync_with_unknown_id_throws_not_found()
{
var (client, scope) = CreateScopedClient();
using var _ = scope;
var ex = Assert.ThrowsAsync<ReCApiException>(async () =>
await client.Profiles.GetAsync<ProfileViewDto[]>(id: long.MaxValue));
Assert.That(ex, Is.Not.Null);
Assert.That(ex!.StatusCode, Is.EqualTo(HttpStatusCode.NotFound));
Assert.That(ex.Method, Is.EqualTo("GET"));
}
[Test]
public void GetAsync_non_generic_with_unknown_id_throws_not_found()
{
var (client, scope) = CreateScopedClient();
using var _ = scope;
var ex = Assert.ThrowsAsync<ReCApiException>(async () =>
await client.Profiles.GetAsync(id: long.MaxValue));
Assert.That(ex, Is.Not.Null);
Assert.That(ex!.StatusCode, Is.EqualTo(HttpStatusCode.NotFound));
Assert.That(ex.Method, Is.EqualTo("GET"));
}
[Test]
public async Task GetAsync_non_generic_returns_dynamic_payload_or_throws_not_found()
{
var (client, scope) = CreateScopedClient();
using var _ = scope;
try
{
dynamic? profiles = await client.Profiles.GetAsync();
Assert.That(profiles, Is.Not.Null);
Assert.That(profiles, Is.TypeOf<JsonElement>());
}
catch (ReCApiException ex)
{
Assert.That(ex.StatusCode, Is.EqualTo(HttpStatusCode.NotFound));
Assert.That(ex.Method, Is.EqualTo("GET"));
}
}
[Test]
public async Task UpdateAsync_with_unknown_id_throws_or_completes()
{
var (client, scope) = CreateScopedClient();
using var _ = scope;
var payload = new UpdateProfileDto
{
Active = false,
Name = "test-profile"
};
try
{
await client.Profiles.UpdateAsync(long.MaxValue, payload);
Assert.Pass("Update completed.");
}
catch (ReCApiException ex)
{
Assert.That(ex.Method, Is.EqualTo("PUT"));
Assert.That(ex.RequestUri!.AbsolutePath, Does.EndWith($"api/Profile/{long.MaxValue}"));
}
}
[Test]
public async Task DeleteAsync_sends_payload_as_query_string()
{
var (client, scope) = CreateScopedClient();
using var _ = scope;
var payload = new DeleteProfileCommand
{
Start = long.MaxValue - 1,
End = long.MaxValue,
Force = false
};
try
{
await client.Profiles.DeleteAsync(payload);
}
catch (ReCApiException ex)
{
Assert.That(ex.Method, Is.EqualTo("DELETE"));
Assert.That(ex.RequestUri!.Query, Does.Contain("Start=").And.Contains("End=").And.Contains("Force="));
}
}
}

View File

@@ -0,0 +1,204 @@
using System.Net;
using System.Text.Json;
using Microsoft.Extensions.Configuration;
using ReC.Application.Common.Dto;
using ReC.Application.Common.Procedures.UpdateProcedure.Dto;
using ReC.Application.RecActions.Commands;
using ReC.Client;
using ReC.Client.Api;
using ClientInvokeReferences = ReC.Client.Api.InvokeReferences;
namespace ReC.Tests.Client;
[TestFixture]
public class RecActionApiTests : RecClientTestBase
{
[Test]
public void ReCClient_is_resolvable_through_dependency_injection()
{
var (client, scope) = CreateScopedClient();
using var _ = scope;
Assert.That(client, Is.Not.Null);
Assert.That(client.RecActions, Is.Not.Null);
}
[Test]
public async Task GetAsync_without_filters_returns_deserialized_result_or_throws_not_found()
{
var (client, scope) = CreateScopedClient();
using var _ = scope;
try
{
var actions = await client.RecActions.GetAsync<RecActionViewDto[]>();
Assert.That(actions, Is.Not.Null);
}
catch (ReCApiException ex)
{
Assert.That(ex.StatusCode, Is.EqualTo(HttpStatusCode.NotFound));
Assert.That(ex.Method, Is.EqualTo("GET"));
Assert.That(ex.RequestUri!.AbsolutePath, Does.EndWith("api/RecAction"));
}
}
[Test]
public async Task GetAsync_non_generic_returns_dynamic_payload_or_throws_not_found()
{
var (client, scope) = CreateScopedClient();
using var _ = scope;
try
{
dynamic? actions = await client.RecActions.GetAsync();
// Server may return either a JsonElement (array or object) or nothing for empty payloads;
// either shape is acceptable, the call must just not have thrown.
if (actions is not null)
Assert.That(actions, Is.TypeOf<JsonElement>());
}
catch (ReCApiException ex)
{
// Any HTTP error here is acceptable - the SP behaviour for an unfiltered GET
// (no profile id) is not contractually defined when no data is present.
Assert.That(ex.Method, Is.EqualTo("GET"));
Assert.That(ex.RequestUri!.AbsolutePath, Does.EndWith("api/RecAction"));
}
}
[Test]
public async Task GetAsync_with_profile_filter_returns_only_matching_actions()
{
var profileId = await TryResolveProfileIdAsync();
if (profileId is null or <= 0)
Assert.Ignore("No profile available in the database for this test (set FakeProfileId or insert a profile).");
var (client, scope) = CreateScopedClient();
using var _ = scope;
RecActionViewDto[]? actions;
try
{
actions = await client.RecActions.GetAsync<RecActionViewDto[]>(profileId: profileId);
}
catch (ReCApiException ex) when (ex.StatusCode == HttpStatusCode.NotFound)
{
Assert.Pass("NotFound is acceptable when test data is unavailable.");
return;
}
Assert.That(actions, Is.Not.Null.And.Not.Empty);
Assert.That(actions, Has.All.Matches<RecActionViewDto>(a => a.ProfileId == profileId));
}
[Test]
public void GetAsync_with_unknown_profile_throws_not_found()
{
var (client, scope) = CreateScopedClient();
using var _ = scope;
var ex = Assert.ThrowsAsync<ReCApiException>(async () =>
await client.RecActions.GetAsync<RecActionViewDto[]>(profileId: long.MaxValue));
Assert.That(ex, Is.Not.Null);
Assert.That(ex!.StatusCode, Is.EqualTo(HttpStatusCode.NotFound));
Assert.That(ex.Method, Is.EqualTo("GET"));
Assert.That(ex.RequestUri!.Query, Does.Contain("ProfileId="));
}
[Test]
public void GetAsync_non_generic_with_unknown_profile_throws_not_found()
{
var (client, scope) = CreateScopedClient();
using var _ = scope;
var ex = Assert.ThrowsAsync<ReCApiException>(async () =>
await client.RecActions.GetAsync(profileId: long.MaxValue));
Assert.That(ex, Is.Not.Null);
Assert.That(ex!.StatusCode, Is.EqualTo(HttpStatusCode.NotFound));
Assert.That(ex.Method, Is.EqualTo("GET"));
Assert.That(ex.RequestUri!.Query, Does.Contain("ProfileId="));
}
[Test]
public void CreateAsync_with_invalid_foreign_key_throws_ReCApiException()
{
var (client, scope) = CreateScopedClient();
using var _ = scope;
var payload = new InsertActionCommand
{
ProfileId = long.MaxValue,
EndpointId = long.MaxValue,
Active = true,
Sequence = 1
};
var ex = Assert.ThrowsAsync<ReCApiException>(async () => await client.RecActions.CreateAsync(payload));
Assert.That(ex, Is.Not.Null);
Assert.That((int)ex!.StatusCode, Is.GreaterThanOrEqualTo(400));
Assert.That(ex.Method, Is.EqualTo("POST"));
}
[Test]
public async Task UpdateAsync_with_unknown_id_throws_or_completes()
{
var (client, scope) = CreateScopedClient();
using var _ = scope;
var unknownId = long.MaxValue;
var payload = new UpdateActionDto
{
ProfileId = 1,
Active = false,
Sequence = 1
};
try
{
await client.RecActions.UpdateAsync(unknownId, payload);
Assert.Pass("Update completed (SP is idempotent for unknown id).");
}
catch (ReCApiException ex)
{
Assert.That(ex.Method, Is.EqualTo("PUT"));
}
}
[Test]
public async Task DeleteAsync_sends_payload_as_query_string_not_body()
{
var (client, scope) = CreateScopedClient();
using var _ = scope;
var payload = new DeleteActionCommand
{
Start = long.MaxValue - 1,
End = long.MaxValue,
Force = false
};
try
{
await client.RecActions.DeleteAsync(payload);
}
catch (ReCApiException ex)
{
Assert.That(ex.Method, Is.EqualTo("DELETE"));
Assert.That(ex.RequestUri!.Query, Does.Contain("Start=").And.Contains("End=").And.Contains("Force="));
}
}
[Test]
public void InvokeAsync_with_unknown_profile_throws_ReCApiException()
{
var (client, scope) = CreateScopedClient();
using var _ = scope;
var ex = Assert.ThrowsAsync<ReCApiException>(async () =>
await client.RecActions.InvokeAsync(long.MaxValue, new ClientInvokeReferences { BatchId = "test-batch" }));
Assert.That(ex, Is.Not.Null);
Assert.That(ex!.Method, Is.EqualTo("POST"));
}
}

View File

@@ -0,0 +1,103 @@
using System;
using System.IO;
using System.Linq;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Hosting;
using Microsoft.AspNetCore.Mvc.Testing;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using ReC.Application.Common.Dto;
using ReC.Client;
namespace ReC.Tests.Client;
public abstract class RecClientTestBase : IDisposable
{
private readonly WebApplicationFactory<Program> _factory;
private readonly ServiceProvider _serviceProvider;
protected RecClientTestBase()
{
var apiContentRoot = LocateApiContentRoot();
_factory = new WebApplicationFactory<Program>()
.WithWebHostBuilder(builder =>
{
builder.UseEnvironment("Development");
builder.UseContentRoot(apiContentRoot);
});
_ = _factory.CreateClient();
var services = new ServiceCollection();
services.AddLogging();
services.AddSingleton(_factory.Services.GetRequiredService<IConfiguration>());
services.AddRecClient(client =>
{
client.BaseAddress = new Uri("http://localhost");
});
services.AddHttpClient(ReCClient.ClientName)
.ConfigurePrimaryHttpMessageHandler(() => _factory.Server.CreateHandler());
_serviceProvider = services.BuildServiceProvider();
}
protected IServiceProvider ServiceProvider => _serviceProvider;
protected IConfiguration Configuration => _factory.Services.GetRequiredService<IConfiguration>();
protected (ReCClient Client, IServiceScope Scope) CreateScopedClient()
{
var scope = _serviceProvider.CreateScope();
var client = scope.ServiceProvider.GetRequiredService<ReCClient>();
return (client, scope);
}
/// <summary>
/// Resolves a usable profile id for tests that require an existing profile in the database.
/// Prefers the configured <c>FakeProfileId</c> value; otherwise asks the server for the first
/// available profile via the standard <c>GET api/Profile</c> endpoint. Returns <c>null</c>
/// when no profile is configured and none can be discovered.
/// </summary>
protected async Task<long?> TryResolveProfileIdAsync()
{
var configured = Configuration.GetValue<long?>("FakeProfileId");
if (configured is > 0)
return configured;
try
{
var (client, scope) = CreateScopedClient();
using var _ = scope;
var profiles = await client.Profiles.GetAsync<ProfileViewDto[]>();
return profiles?.FirstOrDefault()?.Id;
}
catch
{
return null;
}
}
public void Dispose()
{
_serviceProvider.Dispose();
_factory.Dispose();
}
private static string LocateApiContentRoot()
{
var current = new DirectoryInfo(AppContext.BaseDirectory);
while (current is not null)
{
var candidate = Path.Combine(current.FullName, "src", "ReC.API");
if (File.Exists(Path.Combine(candidate, "appsettings.json")))
return candidate;
current = current.Parent;
}
throw new DirectoryNotFoundException("Could not locate src/ReC.API content root from the test base directory.");
}
}

View File

@@ -0,0 +1,133 @@
using System.Net;
using System.Text.Json;
using ReC.Application.Common.Dto;
using ReC.Application.Common.Procedures.UpdateProcedure.Dto;
using ReC.Application.RecActions.Commands;
using ReC.Application.Results.Commands;
using ReC.Client;
using ReC.Domain.Constants;
using DomainResultType = ReC.Domain.Constants.ResultType;
namespace ReC.Tests.Client;
[TestFixture]
public class ResultApiTests : RecClientTestBase
{
[Test]
public void ReCClient_results_api_is_resolvable_through_dependency_injection()
{
var (client, scope) = CreateScopedClient();
using var _ = scope;
Assert.That(client, Is.Not.Null);
Assert.That(client.Results, Is.Not.Null);
}
[Test]
public void GetAsync_with_unknown_filter_throws_not_found()
{
var (client, scope) = CreateScopedClient();
using var _ = scope;
var ex = Assert.ThrowsAsync<ReCApiException>(async () =>
await client.Results.GetAsync<ResultViewDto[]>(id: long.MaxValue));
Assert.That(ex, Is.Not.Null);
Assert.That(ex!.StatusCode, Is.EqualTo(HttpStatusCode.NotFound));
Assert.That(ex.Method, Is.EqualTo("GET"));
}
[Test]
public async Task GetAsync_non_generic_returns_dynamic_payload_or_throws_not_found()
{
var (client, scope) = CreateScopedClient();
using var _ = scope;
try
{
dynamic? results = await client.Results.GetAsync();
if (results is not null)
Assert.That(results, Is.TypeOf<JsonElement>());
}
catch (ReCApiException ex)
{
// Any HTTP error here is acceptable - the SP behaviour for an unfiltered
// GET when no data is present is not contractually defined.
Assert.That(ex.Method, Is.EqualTo("GET"));
}
}
[Test]
public async Task CreateAsync_with_invalid_action_reference_throws_or_completes()
{
var (client, scope) = CreateScopedClient();
using var _ = scope;
var payload = new InsertResultCommand
{
ActionId = long.MaxValue,
Status = RecStatus.Error,
Type = DomainResultType.Main,
References = new InvokeReferences { BatchId = "test-batch" },
Error = "integration-test"
};
try
{
await client.Results.CreateAsync(payload);
Assert.Pass("Create completed.");
}
catch (ReCApiException ex)
{
Assert.That(ex.Method, Is.EqualTo("POST"));
Assert.That(ex.RequestUri!.AbsolutePath, Does.EndWith("api/Result"));
}
}
[Test]
public async Task UpdateAsync_with_unknown_id_throws_or_completes()
{
var (client, scope) = CreateScopedClient();
using var _ = scope;
var payload = new UpdateResultDto
{
Error = "updated-from-test"
};
try
{
await client.Results.UpdateAsync(long.MaxValue, payload);
Assert.Pass("Update completed.");
}
catch (ReCApiException ex)
{
Assert.That(ex.Method, Is.EqualTo("PUT"));
Assert.That(ex.RequestUri!.AbsolutePath, Does.EndWith($"api/Result/{long.MaxValue}"));
}
}
[Test]
public void DeleteAsync_sends_payload_as_query_string()
{
var (client, scope) = CreateScopedClient();
using var _ = scope;
var payload = new DeleteResultCommand
{
Start = long.MaxValue - 1,
End = long.MaxValue,
Force = false
};
try
{
client.Results.DeleteAsync(payload).GetAwaiter().GetResult();
}
catch (ReCApiException ex)
{
Assert.That(ex.Method, Is.EqualTo("DELETE"));
Assert.That(ex.RequestUri!.Query, Does.Contain("Start=").And.Contains("End=").And.Contains("Force="));
}
}
}

View File

@@ -0,0 +1,129 @@
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Logging.Abstractions;
using ReC.Client;
namespace ReC.Tests.Client;
// The static BuildStaticClient / Create entry-point mutates process-wide state and can only be
// initialized once per AppDomain. All assertions must therefore live in a single, ordered,
// non-parallel fixture so that the lifecycle is deterministic.
[TestFixture]
[NonParallelizable]
public class StaticReCClientTests
{
// Validation tests run first. They all throw BEFORE the static state is mutated,
// so they can run repeatedly without consuming the one-time build slot.
[Test, Order(10)]
public void BuildStaticClient_with_null_configure_throws_ArgumentNullException()
{
#pragma warning disable CS0618 // Type or member is obsolete
Assert.Throws<ArgumentNullException>(() => ReCClient.BuildStaticClient((Action<StaticBuildConfiguration>)null!));
#pragma warning restore CS0618
}
[Test, Order(20)]
public void BuildStaticClient_without_base_address_or_configure_client_throws()
{
#pragma warning disable CS0618
var ex = Assert.Throws<InvalidOperationException>(
() => ReCClient.BuildStaticClient(_ => { }));
#pragma warning restore CS0618
Assert.That(ex!.Message, Does.Contain(nameof(StaticBuildConfiguration.BaseAddress)));
Assert.That(ex.Message, Does.Contain(nameof(StaticBuildConfiguration.ConfigureClient)));
}
[Test, Order(30)]
public void BuildStaticClient_with_both_base_address_and_configure_client_throws()
{
#pragma warning disable CS0618
var ex = Assert.Throws<InvalidOperationException>(() => ReCClient.BuildStaticClient(cfg =>
{
cfg.BaseAddress = "https://example.invalid/";
cfg.ConfigureClient = http => http.BaseAddress = new Uri("https://example.invalid/");
}));
#pragma warning restore CS0618
Assert.That(ex!.Message, Does.Contain("mutually exclusive"));
}
// Successful build — consumes the single allowed BuildStaticClient slot for this test run.
[Test, Order(100)]
public void BuildStaticClient_with_configuration_callback_succeeds_and_Create_resolves_client()
{
var customLogger = NullLogger<ReCClient>.Instance;
var configureServicesInvoked = false;
#pragma warning disable CS0618
ReCClient.BuildStaticClient(cfg =>
{
cfg.BaseAddress = "https://example.invalid/";
cfg.ConfigureOptions = opt => opt.LogSuccessfulRequests = true;
cfg.Logger = customLogger;
cfg.ConfigureServices = services =>
{
configureServicesInvoked = true;
services.AddSingleton<IMarkerService, MarkerService>();
};
});
// First Create() triggers the Lazy<IServiceProvider> initialization.
var client = ReCClient.Create();
#pragma warning restore CS0618
Assert.That(client, Is.Not.Null);
Assert.That(client.RecActions, Is.Not.Null);
Assert.That(configureServicesInvoked, Is.True, "ConfigureServices callback should run when the provider is built.");
}
[Test, Order(110)]
public void Create_returns_a_client_using_the_existing_static_provider()
{
#pragma warning disable CS0618
var client = ReCClient.Create();
#pragma warning restore CS0618
Assert.That(client, Is.Not.Null);
}
// Any subsequent BuildStaticClient call (regardless of overload) must fail.
[Test, Order(200)]
public void Calling_BuildStaticClient_configuration_overload_a_second_time_throws()
{
#pragma warning disable CS0618
var ex = Assert.Throws<InvalidOperationException>(() =>
ReCClient.BuildStaticClient(cfg => cfg.BaseAddress = "https://other.invalid/"));
#pragma warning restore CS0618
Assert.That(ex!.Message, Does.Contain("already built"));
}
[Test, Order(210)]
public void Calling_legacy_BuildStaticClient_string_overload_after_build_throws()
{
#pragma warning disable CS0618
var ex = Assert.Throws<InvalidOperationException>(() =>
ReCClient.BuildStaticClient("https://other.invalid/"));
#pragma warning restore CS0618
Assert.That(ex!.Message, Does.Contain("already built"));
}
[Test, Order(220)]
public void Calling_legacy_BuildStaticClient_action_overload_after_build_throws()
{
#pragma warning disable CS0618
var ex = Assert.Throws<InvalidOperationException>(() =>
ReCClient.BuildStaticClient(http => http.BaseAddress = new Uri("https://other.invalid/")));
#pragma warning restore CS0618
Assert.That(ex!.Message, Does.Contain("already built"));
}
// Helper types used by the ConfigureServices assertion above.
private interface IMarkerService { }
private sealed class MarkerService : IMarkerService { }
}

View File

@@ -12,6 +12,7 @@
<ItemGroup>
<PackageReference Include="coverlet.collector" Version="6.0.0" />
<PackageReference Include="MediatR" Version="14.0.0" />
<PackageReference Include="Microsoft.AspNetCore.Mvc.Testing" Version="8.0.0" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.8.0" />
<PackageReference Include="Microsoft.Extensions.Configuration.Json" Version="10.0.0" />
<PackageReference Include="Moq" Version="4.20.72" />
@@ -21,7 +22,9 @@
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\..\src\ReC.API\ReC.API.csproj" />
<ProjectReference Include="..\..\src\ReC.Application\ReC.Application.csproj" />
<ProjectReference Include="..\..\src\ReC.Client\ReC.Client.csproj" />
<ProjectReference Include="..\..\src\ReC.Infrastructure\ReC.Infrastructure.csproj" />
<ProjectReference Include="..\..\src\ReC.Domain\ReC.Domain.csproj" />
</ItemGroup>