Compare commits
24 Commits
6d8e51ad70
...
09c0a5f3cf
| Author | SHA1 | Date | |
|---|---|---|---|
| 09c0a5f3cf | |||
| 46eccf7a9b | |||
| 275746afde | |||
| b06d8029c4 | |||
| e69bc9cdb9 | |||
| 983f3f76ad | |||
| afd5cd5fbd | |||
| 12f4bf8828 | |||
| d34af1ac86 | |||
| 9e90efb781 | |||
| ff2a519e95 | |||
| c511f0edcd | |||
| b9dfc15ae2 | |||
| 0895e6bc29 | |||
| 92f2511c63 | |||
| 340349a2d5 | |||
| bbc3524dd9 | |||
| 73d8068d8e | |||
| b724f2e5f4 | |||
| a3aa6ea7ae | |||
| b6420fcc49 | |||
| 8976620205 | |||
| d37eda0d6d | |||
| 5239c2f071 |
1
ReC.sln
1
ReC.sln
@@ -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
483
docs/ReC.Client.xwiki
Normal 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
0
docs/ReC.Client.xwiki.md
Normal 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;
|
||||||
@@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
101
tests/ReC.Tests/Client/CommonApiTests.cs
Normal file
101
tests/ReC.Tests/Client/CommonApiTests.cs
Normal 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="));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
91
tests/ReC.Tests/Client/EndpointAuthApiTests.cs
Normal file
91
tests/ReC.Tests/Client/EndpointAuthApiTests.cs
Normal 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="));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
94
tests/ReC.Tests/Client/EndpointParamsApiTests.cs
Normal file
94
tests/ReC.Tests/Client/EndpointParamsApiTests.cs
Normal 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="));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
91
tests/ReC.Tests/Client/EndpointsApiTests.cs
Normal file
91
tests/ReC.Tests/Client/EndpointsApiTests.cs
Normal 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="));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
118
tests/ReC.Tests/Client/ProfileApiTests.cs
Normal file
118
tests/ReC.Tests/Client/ProfileApiTests.cs
Normal 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="));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
196
tests/ReC.Tests/Client/RecActionApiTests.cs
Normal file
196
tests/ReC.Tests/Client/RecActionApiTests.cs
Normal 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"));
|
||||||
|
}
|
||||||
|
}
|
||||||
75
tests/ReC.Tests/Client/RecClientTestBase.cs
Normal file
75
tests/ReC.Tests/Client/RecClientTestBase.cs
Normal 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.");
|
||||||
|
}
|
||||||
|
}
|
||||||
132
tests/ReC.Tests/Client/ResultApiTests.cs
Normal file
132
tests/ReC.Tests/Client/ResultApiTests.cs
Normal 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="));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
|||||||
Reference in New Issue
Block a user