24 Commits

Author SHA1 Message Date
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
21 changed files with 1617 additions and 78 deletions

View File

@@ -18,6 +18,7 @@ EndProject
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Solution Items", "Solution Items", "{8EC462FD-D22E-90A8-E5CE-7E832BA40C5D}" Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Solution Items", "Solution Items", "{8EC462FD-D22E-90A8-E5CE-7E832BA40C5D}"
ProjectSection(SolutionItems) = preProject ProjectSection(SolutionItems) = preProject
assets\icon.png = assets\icon.png assets\icon.png = assets\icon.png
docs\ReC.Client.xwiki = docs\ReC.Client.xwiki
EndProjectSection EndProjectSection
EndProject EndProject
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "infrastructure", "infrastructure", "{3F88DACC-CEC0-4D9A-8BAA-37F67B02DC04}" Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "infrastructure", "infrastructure", "{3F88DACC-CEC0-4D9A-8BAA-37F67B02DC04}"

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

@@ -0,0 +1,483 @@
== 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}}
== 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 (Beispiel: der jüngst hinzugefügte optionale `Action<ReCClientOptions>`-Parameter in `BuildStaticClient`). 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 `IServiceProvider` ab. Anschließend liefert `ReCClient.Create()` Instanzen aus diesem Provider.
Wichtig: `BuildStaticClient` darf **nur einmal** beim Anwendungsstart aufgerufen werden.
Variante mit Basis-URL:
{{code language="vb.net"}}
' Einmalig beim Anwendungsstart
ReCClient.BuildStaticClient("https://ihre-rec-api-adresse.com/")
' Später irgendwo im Code
Dim client As ReCClient = ReCClient.Create()
Await client.RecActions.InvokeAsync(profilId, "batch-001")
{{/code}}
{{code language="csharp"}}
// Once at application startup
ReCClient.BuildStaticClient("https://ihre-rec-api-adresse.com/");
// Later somewhere in code
var client = ReCClient.Create();
await client.RecActions.InvokeAsync(profileId, "batch-001");
{{/code}}
Variante mit `HttpClient`-Konfiguration:
{{code language="vb.net"}}
ReCClient.BuildStaticClient(Sub(http)
http.BaseAddress = New Uri("https://ihre-rec-api-adresse.com/")
http.Timeout = TimeSpan.FromSeconds(30)
End Sub)
{{/code}}
{{code language="csharp"}}
ReCClient.BuildStaticClient(http =>
{
http.BaseAddress = new Uri("https://ihre-rec-api-adresse.com/");
http.Timeout = TimeSpan.FromSeconds(30);
});
{{/code}}
Optional kann zusätzlich `ReCClientOptions` über einen Callback gesetzt werden die Signatur entspricht der von `AddRecClient`:
{{code language="vb.net"}}
ReCClient.BuildStaticClient(
"https://ihre-rec-api-adresse.com/",
Sub(opt)
opt.LogSuccessfulRequests = True
End Sub)
{{/code}}
{{code language="csharp"}}
ReCClient.BuildStaticClient(
"https://ihre-rec-api-adresse.com/",
opt =>
{
opt.LogSuccessfulRequests = true;
});
{{/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

@@ -98,3 +98,5 @@ catch(Exception ex)
logger.Error(ex, "Stopped program because of exception"); logger.Error(ex, "Stopped program because of exception");
throw; throw;
} }
public partial class Program;

View File

@@ -88,7 +88,8 @@ namespace ReC.Client.Api
} }
/// <summary> /// <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> /// </summary>
/// <typeparam name="T">The payload type containing identifiers.</typeparam> /// <typeparam name="T">The payload type containing identifiers.</typeparam>
/// <param name="payload">The payload to send.</param> /// <param name="payload">The payload to send.</param>
@@ -96,11 +97,8 @@ namespace ReC.Client.Api
/// <exception cref="ReCApiException">Thrown when the API responds with a non-success status code.</exception> /// <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) public async Task DeleteAsync<T>(T payload, CancellationToken cancel = default)
{ {
using (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)
})
using (var resp = await Http.SendAsync(request, cancel))
{ {
await ReCClientHelpers.HandleResponseAsync(resp, Logger, Options.LogSuccessfulRequests, cancel).ConfigureAwait(false); await ReCClientHelpers.HandleResponseAsync(resp, Logger, Options.LogSuccessfulRequests, cancel).ConfigureAwait(false);
} }

View File

@@ -6,7 +6,9 @@ using Microsoft.Extensions.Logging;
namespace ReC.Client.Api namespace ReC.Client.Api
{ {
/// <summary> /// <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> /// </summary>
public class CommonApi : BaseCrudApi public class CommonApi : BaseCrudApi
{ {
@@ -23,5 +25,23 @@ namespace ReC.Client.Api
#endif #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

@@ -25,16 +25,34 @@ namespace ReC.Client.Api
} }
/// <summary> /// <summary>
/// Retrieves a profile by identifier. /// Retrieves profiles and deserializes the JSON response into <typeparamref name="T"/>.
/// </summary> /// </summary>
/// <param name="id">The profile identifier.</param> #if NETFRAMEWORK
/// <param name="includeActions">Whether to include related actions.</param> public async Task<T> GetAsync<T>(long? id = null, bool includeActions = true, CancellationToken cancel = default)
/// <param name="cancel">A token to cancel the operation.</param> #else
/// <returns>The HTTP response message.</returns> public async Task<T?> GetAsync<T>(long? id = null, bool includeActions = true, CancellationToken cancel = default)
public Task<HttpResponseMessage> GetAsync(long id, bool includeActions = false, CancellationToken cancel = default) #endif
{ {
var query = ReCClientHelpers.BuildQuery(("Id", id), ("IncludeActions", includeActions)); 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

@@ -25,13 +25,17 @@ namespace ReC.Client.Api
} }
/// <summary> /// <summary>
/// Invokes a ReC action for the specified profile. /// Invokes a batch of RecActions for the specified profile.
/// </summary> /// </summary>
/// <param name="profileId">The profile identifier.</param> /// <param name="profileId">The profile identifier.</param>
/// <param name="references">Optional reference values to pass through to all result records.</param> /// <param name="references">Optional reference values to pass through to all result records.</param>
/// <param name="cancellationToken">A token to cancel the operation.</param> /// <param name="cancellationToken">A token to cancel the operation.</param>
/// <exception cref="ReCApiException">Thrown when the API responds with a non-success status code.</exception> /// <exception cref="ReCApiException">Thrown when the API responds with a non-success status code.</exception>
public async Task InvokeAsync(int profileId, InvokeReferences references, CancellationToken cancellationToken = default) #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 content = references != null ? ReCClientHelpers.ToJsonContent(references) : null;
using (content) using (content)
@@ -42,28 +46,46 @@ namespace ReC.Client.Api
} }
/// <summary> /// <summary>
/// Invokes a ReC action for the specified profile. /// Invokes a batch of RecActions for the specified profile.
/// </summary> /// </summary>
/// <param name="profileId">The profile identifier.</param> /// <param name="profileId">The profile identifier.</param>
/// <param name="batchId">Batch identifier.</param> /// <param name="batchId">Batch identifier.</param>
/// <param name="cancellationToken">A token to cancel the operation.</param> /// <param name="cancellationToken">A token to cancel the operation.</param>
/// <exception cref="ReCApiException">Thrown when the API responds with a non-success status code.</exception> /// <exception cref="ReCApiException">Thrown when the API responds with a non-success status code.</exception>
public Task InvokeAsync(int profileId, string batchId, CancellationToken cancellationToken = default) public Task InvokeAsync(long profileId, string batchId, CancellationToken cancellationToken = default)
{ {
return InvokeAsync(profileId, new InvokeReferences() { BatchId = batchId }, cancellationToken); return InvokeAsync(profileId, new InvokeReferences() { BatchId = batchId }, cancellationToken);
} }
/// <summary> /// <summary>
/// Retrieves Rec actions. /// Retrieves Rec actions and deserializes the JSON response into <typeparamref name="T"/>.
/// </summary> /// </summary>
/// <param name="profileId">Optional profile filter.</param> #if NETFRAMEWORK
/// <param name="invoked">Optional invoked filter.</param> public async Task<T> GetAsync<T>(long? profileId = null, bool? invoked = null, CancellationToken cancel = default)
/// <param name="cancel">A token to cancel the operation.</param> #else
/// <returns>The HTTP response message.</returns> public async Task<T?> GetAsync<T>(long? profileId = null, bool? invoked = null, CancellationToken cancel = default)
public Task<HttpResponseMessage> GetAsync(long? profileId = null, bool? invoked = null, CancellationToken cancel = default) #endif
{ {
var query = ReCClientHelpers.BuildQuery(("ProfileId", profileId), ("Invoked", invoked)); 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

@@ -25,17 +25,42 @@ namespace ReC.Client.Api
} }
/// <summary> /// <summary>
/// Retrieves results with optional filters. /// Retrieves results with optional filters and deserializes the JSON response into <typeparamref name="T"/>.
/// </summary> /// </summary>
/// <param name="id">Optional result identifier.</param> #if NETFRAMEWORK
/// <param name="actionId">Optional action identifier.</param> 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)
/// <param name="profileId">Optional profile identifier.</param> #else
/// <param name="cancel">A token to cancel the operation.</param> 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)
/// <returns>The HTTP response message.</returns> #endif
public Task<HttpResponseMessage> GetAsync(long? id = null, long? actionId = null, long? profileId = null, CancellationToken cancel = default)
{ {
var query = ReCClientHelpers.BuildQuery(("Id", id), ("ActionId", actionId), ("ProfileId", profileId)); var query = ReCClientHelpers.BuildQuery(
return Http.GetAsync($"{ResourcePath}{query}", cancel); ("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

@@ -68,6 +68,13 @@ namespace ReC.Client
{ {
_http = httpClientFactory.CreateClient(ClientName); _http = httpClientFactory.CreateClient(ClientName);
var opts = options?.Value ?? new ReCClientOptions(); 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); RecActions = new RecActionApi(_http, logger, opts);
Results = new ResultApi(_http, logger, opts); Results = new ResultApi(_http, logger, opts);
Profiles = new ProfileApi(_http, logger, opts); Profiles = new ProfileApi(_http, logger, opts);
@@ -78,30 +85,42 @@ namespace ReC.Client
} }
#region Static #region Static
private static readonly IServiceCollection Services = new ServiceCollection();
#if NET8_0_OR_GREATER #if NET8_0_OR_GREATER
private static IServiceProvider? Provider = null; private static Action<IServiceCollection>? _staticConfigure = null;
#else #else
private static IServiceProvider Provider = null; private static Action<IServiceCollection> _staticConfigure = null;
#endif #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> /// <summary>
/// Configures and builds the static <see cref="IServiceProvider"/> for creating <see cref="ReCClient"/> instances. /// Configures and builds the static <see cref="IServiceProvider"/> for creating <see cref="ReCClient"/> instances.
/// </summary> /// </summary>
/// <remarks> /// <remarks>
/// This method should only be called once during application startup. /// 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> /// </remarks>
/// <param name="apiUri">The base URI of the ReC API.</param> /// <param name="apiUri">The base URI of the ReC API.</param>
/// <param name="configureOptions">An optional callback to configure <see cref="ReCClientOptions"/>.</param>
/// <exception cref="InvalidOperationException">Thrown if the static provider has already been built.</exception> /// <exception cref="InvalidOperationException">Thrown if the static provider has already been built.</exception>
[Obsolete("Use a local service collection instead of the static provider.")] [Obsolete("Use a local service collection instead of the static provider.")]
public static void BuildStaticClient(string apiUri) #if NETFRAMEWORK
public static void BuildStaticClient(string apiUri, Action<ReCClientOptions> configureOptions = null)
#else
public static void BuildStaticClient(string apiUri, Action<ReCClientOptions>? configureOptions = null)
#endif
{ {
if(Provider != null) Action<IServiceCollection> configure = services => services.AddRecClient(apiUri, configureOptions);
if (System.Threading.Interlocked.CompareExchange(ref _staticConfigure, configure, null) != null)
throw new InvalidOperationException("Static Provider is already built."); throw new InvalidOperationException("Static Provider is already built.");
Services.AddRecClient(apiUri);
Provider = Services.BuildServiceProvider();
} }
/// <summary> /// <summary>
@@ -109,31 +128,32 @@ namespace ReC.Client
/// </summary> /// </summary>
/// <remarks> /// <remarks>
/// This method should only be called once during application startup. /// 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> /// </remarks>
/// <param name="configureClient">An action to configure the <see cref="HttpClient"/>.</param> /// <param name="configureClient">An action to configure the <see cref="HttpClient"/>.</param>
/// <param name="configureOptions">An optional callback to configure <see cref="ReCClientOptions"/>.</param>
/// <exception cref="InvalidOperationException">Thrown if the static provider has already been built.</exception> /// <exception cref="InvalidOperationException">Thrown if the static provider has already been built.</exception>
[Obsolete("Use a local service collection instead of the static provider.")] [Obsolete("Use a local service collection instead of the static provider.")]
public static void BuildStaticClient(Action<HttpClient> configureClient) #if NETFRAMEWORK
public static void BuildStaticClient(Action<HttpClient> configureClient, Action<ReCClientOptions> configureOptions = null)
#else
public static void BuildStaticClient(Action<HttpClient> configureClient, Action<ReCClientOptions>? configureOptions = null)
#endif
{ {
if (Provider != null) Action<IServiceCollection> configure = services => services.AddRecClient(configureClient, configureOptions);
if (System.Threading.Interlocked.CompareExchange(ref _staticConfigure, configure, null) != null)
throw new InvalidOperationException("Static Provider is already built."); throw new InvalidOperationException("Static Provider is already built.");
Services.AddRecClient(configureClient);
Provider = Services.BuildServiceProvider();
} }
/// <summary> /// <summary>
/// Creates a new <see cref="ReCClient"/> instance using the statically configured provider. /// Creates a new <see cref="ReCClient"/> instance using the statically configured provider.
/// </summary> /// </summary>
/// <returns>A new instance of the <see cref="ReCClient"/>.</returns> /// <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> /// <exception cref="InvalidOperationException">Thrown if <see cref="BuildStaticClient(string, Action{ReCClientOptions})"/> has not been called yet.</exception>
[Obsolete("Use a local service collection instead of the static provider.")] [Obsolete("Use a local service collection instead of the static provider.")]
public static ReCClient Create() public static ReCClient Create()
{ {
if (Provider == null) return LazyProvider.Value.GetRequiredService<ReCClient>();
throw new InvalidOperationException("Static Provider is not built. Call BuildStaticClient first.");
return Provider.GetRequiredService<ReCClient>();
} }
#endregion #endregion
} }

View File

@@ -1,8 +1,11 @@
using System; using System;
using System.Collections;
using System.Globalization; using System.Globalization;
using System.Linq; using System.Linq;
using System.Net.Http; using System.Net.Http;
using System.Net.Http.Json; using System.Net.Http.Json;
using System.Reflection;
using System.Text.Json;
using System.Threading; using System.Threading;
using System.Threading.Tasks; using System.Threading.Tasks;
using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging;
@@ -47,18 +50,46 @@ namespace ReC.Client
public static JsonContent ToJsonContent<T>(T payload) => JsonContent.Create(payload); public static JsonContent ToJsonContent<T>(T payload) => JsonContent.Create(payload);
/// <summary> /// <summary>
/// Logs the outcome of an HTTP response. Throws a <see cref="ReCApiException"/> when the /// Builds a query string from the public readable properties of <paramref name="payload"/>,
/// response indicates a non-success status code; otherwise (optionally) writes an informational /// skipping properties whose values are <see langword="null"/>.
/// log entry containing the request and response details. /// </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> /// </summary>
/// <param name="response">The HTTP response to inspect.</param>
/// <param name="logger">An optional logger used to record the outcome. May be <see langword="null"/>.</param>
/// <param name="logSuccess">When <see langword="false"/>, successful responses are not logged.</param>
/// <param name="cancel">A token to cancel the operation.</param>
#if NETFRAMEWORK #if NETFRAMEWORK
public static async Task HandleResponseAsync(HttpResponseMessage response, ILogger logger = null, bool logSuccess = true, CancellationToken cancel = default) public static async Task<string> HandleResponseAsync(HttpResponseMessage response, ILogger logger = null, bool logSuccess = true, CancellationToken cancel = default)
#else #else
public static async Task HandleResponseAsync(HttpResponseMessage response, ILogger? logger = null, bool logSuccess = true, CancellationToken cancel = default) public static async Task<string?> HandleResponseAsync(HttpResponseMessage response, ILogger? logger = null, bool logSuccess = true, CancellationToken cancel = default)
#endif #endif
{ {
var request = response.RequestMessage; var request = response.RequestMessage;
@@ -66,20 +97,6 @@ namespace ReC.Client
var uri = request?.RequestUri; var uri = request?.RequestUri;
var statusCode = (int)response.StatusCode; var statusCode = (int)response.StatusCode;
if (response.IsSuccessStatusCode)
{
if (logSuccess)
{
logger?.LogInformation(
"ReC API request succeeded. {Method} {Uri} -> {StatusCode} ({ReasonPhrase})",
method,
uri,
statusCode,
response.ReasonPhrase);
}
return;
}
#if NETFRAMEWORK #if NETFRAMEWORK
string body = null; string body = null;
#else #else
@@ -97,15 +114,44 @@ namespace ReC.Client
} }
catch catch
{ {
// Swallow body read failures; status info is still propagated.
} }
} }
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}). " var message = $"ReC API request failed with status {statusCode} ({response.ReasonPhrase}). "
+ $"{method} {uri}" + $"{method} {uri}"
+ (string.IsNullOrWhiteSpace(body) ? string.Empty : $": {body}"); + (string.IsNullOrWhiteSpace(body) ? string.Empty : $": {body}");
throw new ReCApiException(message, response.StatusCode, response.ReasonPhrase, body, method, uri); 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

@@ -7,12 +7,14 @@ namespace ReC.Client
/// <summary> /// <summary>
/// Provides synchronous wrappers for Task-based operations. /// Provides synchronous wrappers for Task-based operations.
/// </summary> /// </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 public static class TaskSyncExtensions
{ {
/// <summary> /// <summary>
/// Blocks until the task completes and propagates any exception. /// Blocks until the task completes and propagates any exception.
/// </summary> /// </summary>
/// <param name="task">The task to wait for.</param> /// <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(); public static void Sync(this Task task) => task.ConfigureAwait(false).GetAwaiter().GetResult();
/// <summary> /// <summary>
@@ -21,6 +23,7 @@ namespace ReC.Client
/// <typeparam name="TResult">The type of the task result.</typeparam> /// <typeparam name="TResult">The type of the task result.</typeparam>
/// <param name="task">The task to wait for.</param> /// <param name="task">The task to wait for.</param>
/// <returns>The result of the completed task.</returns> /// <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(); public static TResult Sync<TResult>(this Task<TResult> task) => task.ConfigureAwait(false).GetAwaiter().GetResult();
} }
} }

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,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,196 @@
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();
Assert.That(actions, Is.Not.Null);
Assert.That(actions, Is.TypeOf<JsonElement>());
Assert.That(((JsonElement)actions).ValueKind, Is.EqualTo(JsonValueKind.Array));
}
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_with_profile_filter_returns_only_matching_actions()
{
var profileId = Configuration.GetValue<long?>("FakeProfileId");
if (profileId is null or <= 0)
Assert.Ignore("FakeProfileId must be configured in appsettings.json for this test.");
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 void UpdateAsync_with_unknown_id_throws_ReCApiException_with_method_PUT()
{
var (client, scope) = CreateScopedClient();
using var _ = scope;
var unknownId = long.MaxValue;
var payload = new UpdateActionDto
{
ProfileId = 1,
Active = false,
Sequence = 1
};
var ex = Assert.ThrowsAsync<ReCApiException>(async () => await client.RecActions.UpdateAsync(unknownId, payload));
Assert.That(ex, Is.Not.Null);
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,75 @@
using System;
using System.IO;
using Microsoft.AspNetCore.Hosting;
using Microsoft.AspNetCore.Mvc.Testing;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
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);
}
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,132 @@
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();
Assert.That(results, Is.Not.Null);
Assert.That(results, 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 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

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