Compare commits
119 Commits
d6af24cd91
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
| 1fc395f495 | |||
| 9e1bee9ea3 | |||
| 09c0a5f3cf | |||
| 46eccf7a9b | |||
| 275746afde | |||
| b06d8029c4 | |||
| e69bc9cdb9 | |||
| 983f3f76ad | |||
| afd5cd5fbd | |||
| 12f4bf8828 | |||
| d34af1ac86 | |||
| 9e90efb781 | |||
| ff2a519e95 | |||
| c511f0edcd | |||
| b9dfc15ae2 | |||
| 0895e6bc29 | |||
| 92f2511c63 | |||
| 340349a2d5 | |||
| bbc3524dd9 | |||
| 73d8068d8e | |||
| b724f2e5f4 | |||
| a3aa6ea7ae | |||
| b6420fcc49 | |||
| 8976620205 | |||
| d37eda0d6d | |||
| 5239c2f071 | |||
| 6d8e51ad70 | |||
| 01ac7ece1e | |||
| e0c2aab2b1 | |||
| a43d1ebc20 | |||
| f96ad1ac7e | |||
| 20766091a9 | |||
| 91c166dc4d | |||
| 7ed348832c | |||
| 190d41489e | |||
| dfcf1fb536 | |||
| 136c2fcb30 | |||
| 71defc0e4c | |||
| 992395dec3 | |||
| 82ec333f23 | |||
| a924e32291 | |||
| 28a4146069 | |||
| 17d40817f2 | |||
| 330443d2c9 | |||
| 6ca876c762 | |||
| e89af1cbcd | |||
| 761fd208e5 | |||
| d149cbea3a | |||
| bb2dd4d63b | |||
| 4bde1d090f | |||
| 6681e56afc | |||
| d61f5ce885 | |||
| 6374a5c257 | |||
| 3b4954d387 | |||
| 42d586604e | |||
| 4088a52196 | |||
| 58b3c8ec95 | |||
| 68b3eb53c0 | |||
| 0d9489203f | |||
| 0a564d8aa8 | |||
| f5b2db0296 | |||
| 7a22024624 | |||
| c9cd92a55a | |||
| 93adaba322 | |||
| c16cb2a1c4 | |||
| c20162e051 | |||
| 70c2f7342d | |||
| a10f917084 | |||
| e1c3f74cd4 | |||
| e45aeea2b9 | |||
| 38f91aae84 | |||
| 9bb5c97df6 | |||
| d8c7499436 | |||
| 6d86760354 | |||
| 6b1daf77cb | |||
| d3d5ebac61 | |||
| b1924f2a4a | |||
| c27ed1e744 | |||
| 9eb54565cb | |||
| 05dfb6f424 | |||
| cf6c90ad05 | |||
| 4a9c4341c2 | |||
| ead12b6095 | |||
| 3c7fcb71c0 | |||
| 0b01b4a658 | |||
| 8d511ec81a | |||
| 685c5abca7 | |||
| b7aea848a9 | |||
| e5eb0f19e7 | |||
| 859ff5902e | |||
| 42789567f0 | |||
| 46eef255ca | |||
| aae42949b6 | |||
| bdf273c8e1 | |||
| ba8ab28b03 | |||
| 4cc8d22756 | |||
| 2a4378eb9a | |||
| cb5bbfb722 | |||
| 2736a78d4f | |||
| ddb8b2673e | |||
| a70aee6e28 | |||
| f329543793 | |||
| 645891150c | |||
| 95cb34394c | |||
| 83d6832236 | |||
| e816340755 | |||
| 64e8e2a5cc | |||
| 0edf2626a7 | |||
| 1d16276a8a | |||
| 4eae092031 | |||
| ce7fe03525 | |||
| a93780df5c | |||
| d7a2a01421 | |||
| 329e441ede | |||
| 1ad7ff3b34 | |||
| bcfbd851bd | |||
| 2e157656a7 | |||
| 8042a6f898 | |||
| f25fc627fe |
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}"
|
||||
ProjectSection(SolutionItems) = preProject
|
||||
assets\icon.png = assets\icon.png
|
||||
docs\ReC.Client.xwiki = docs\ReC.Client.xwiki
|
||||
EndProjectSection
|
||||
EndProject
|
||||
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "infrastructure", "infrastructure", "{3F88DACC-CEC0-4D9A-8BAA-37F67B02DC04}"
|
||||
|
||||
552
docs/ReC.Client.xwiki
Normal file
552
docs/ReC.Client.xwiki
Normal file
@@ -0,0 +1,552 @@
|
||||
== 1. Einleitung ==
|
||||
|
||||
**ReC.Client** ist eine .NET-Client-Bibliothek für den typisierten und bequemen Zugriff auf die **ReC.API**. Anstatt direkt mit `HttpClient` zu arbeiten, bietet die Bibliothek thematisch geordnete API-Klassen (z. B. `RecActionApi`, `ResultApi`, `ProfileApi`, `EndpointAuthApi`, `EndpointParamsApi`, `EndpointsApi`, `CommonApi`) und integriert sich nahtlos in **Microsoft.Extensions.DependencyInjection**.
|
||||
|
||||
Die Bibliothek unterstützt sowohl **.NET 8** als auch **.NET Framework 4.6.2** (Multi-Targeting).
|
||||
|
||||
=== 1.1 Kernmerkmale ===
|
||||
|
||||
* **DI-orientiert**: Registrierung über `services.AddRecClient(...)`.
|
||||
* **Typisierte API-Klassen**: jede Domäne hat eine eigene API-Klasse als Eigenschaft auf `ReCClient`.
|
||||
* **Konsistente Fehlerbehandlung**: Bei HTTP-Fehlerstatus wird einheitlich eine `ReCApiException` geworfen, inklusive Statuscode, Methode, URI, Body usw.
|
||||
* **Flexibles Lesen**: GET-Endpunkte unterstützen sowohl typisierte (`GetAsync<T>(...)`) als auch dynamische (`GetAsync(...)` ohne Typparameter, liefert `dynamic` / `JsonElement`) Abfragen.
|
||||
* **Optionen**: Logging und Verhalten lassen sich über `ReCClientOptions` steuern.
|
||||
|
||||
== 2. Installation und Setup ==
|
||||
|
||||
=== 2.1 Konfiguration mit Dependency Injection (empfohlen) ===
|
||||
|
||||
Registrieren Sie den Client in `Program.cs` / `Startup.cs` über `AddRecClient`. Sie können entweder eine Basis-URL als String oder einen Konfigurations-Delegate für den zugrunde liegenden `HttpClient` übergeben.
|
||||
|
||||
{{code language="vb.net"}}
|
||||
Imports Microsoft.Extensions.Hosting
|
||||
Imports Microsoft.Extensions.DependencyInjection
|
||||
Imports ReC.Client
|
||||
|
||||
Module Program
|
||||
Sub Main(args As String())
|
||||
Dim builder = Host.CreateDefaultBuilder(args)
|
||||
|
||||
builder.ConfigureServices(
|
||||
Sub(services)
|
||||
' Variante A: Basis-URL als String
|
||||
services.AddRecClient("https://ihre-rec-api-adresse.com/")
|
||||
|
||||
' Variante B: HttpClient feinkonfigurieren
|
||||
' services.AddRecClient(Sub(client)
|
||||
' client.BaseAddress = New Uri("https://ihre-rec-api-adresse.com/")
|
||||
' client.Timeout = TimeSpan.FromSeconds(30)
|
||||
' End Sub)
|
||||
End Sub)
|
||||
|
||||
Dim app = builder.Build()
|
||||
app.Run()
|
||||
End Sub
|
||||
End Module
|
||||
{{/code}}
|
||||
|
||||
{{code language="csharp"}}
|
||||
using Microsoft.Extensions.Hosting;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using ReC.Client;
|
||||
|
||||
var builder = Host.CreateDefaultBuilder(args);
|
||||
builder.ConfigureServices(services =>
|
||||
{
|
||||
// Variant A: base URL as string
|
||||
services.AddRecClient("https://ihre-rec-api-adresse.com/");
|
||||
|
||||
// Variant B: configure HttpClient explicitly
|
||||
// services.AddRecClient(client =>
|
||||
// {
|
||||
// client.BaseAddress = new Uri("https://ihre-rec-api-adresse.com/");
|
||||
// client.Timeout = TimeSpan.FromSeconds(30);
|
||||
// });
|
||||
});
|
||||
|
||||
var app = builder.Build();
|
||||
app.Run();
|
||||
{{/code}}
|
||||
|
||||
=== 2.2 Konstruktor-Injektion ===
|
||||
|
||||
Sobald registriert, kann `ReCClient` per Konstruktor in jeden Dienst injiziert werden.
|
||||
|
||||
{{code language="vb.net"}}
|
||||
Imports ReC.Client
|
||||
|
||||
Public Class MeinDienst
|
||||
Private ReadOnly _recClient As ReCClient
|
||||
|
||||
Public Sub New(recClient As ReCClient)
|
||||
_recClient = recClient
|
||||
End Sub
|
||||
End Class
|
||||
{{/code}}
|
||||
|
||||
{{code language="csharp"}}
|
||||
using ReC.Client;
|
||||
|
||||
public class MeinDienst
|
||||
{
|
||||
private readonly ReCClient _recClient;
|
||||
|
||||
public MeinDienst(ReCClient recClient)
|
||||
{
|
||||
_recClient = recClient;
|
||||
}
|
||||
}
|
||||
{{/code}}
|
||||
|
||||
=== 2.3 Optionen über ReCClientOptions ===
|
||||
|
||||
Über `ReCClientOptions` lässt sich das Verhalten des Clients steuern, z. B. ob erfolgreiche Anfragen geloggt werden sollen.
|
||||
|
||||
{{code language="vb.net"}}
|
||||
services.AddRecClient("https://ihre-rec-api-adresse.com/")
|
||||
services.Configure(Of ReCClientOptions)(
|
||||
Sub(opt)
|
||||
opt.LogSuccessfulRequests = True
|
||||
End Sub)
|
||||
{{/code}}
|
||||
|
||||
{{code language="csharp"}}
|
||||
services.AddRecClient("https://ihre-rec-api-adresse.com/");
|
||||
services.Configure<ReCClientOptions>(opt =>
|
||||
{
|
||||
opt.LogSuccessfulRequests = true;
|
||||
});
|
||||
{{/code}}
|
||||
|
||||
{{warning}}
|
||||
Wenn `LogSuccessfulRequests = true` gesetzt, aber **kein** `ILogger` über DI registriert ist, wirft der `ReCClient`-Konstruktor eine `InvalidOperationException`. Stellen Sie sicher, dass ein Logging-Provider (z. B. `services.AddLogging(...)`) registriert ist, oder lassen Sie die Option auf `false`.
|
||||
{{/warning}}
|
||||
|
||||
== 3. Überblick über die API-Klassen ==
|
||||
|
||||
`ReCClient` bündelt mehrere thematische API-Klassen als Eigenschaften:
|
||||
|
||||
* `RecActions` (`RecActionApi`) – Verwaltung und Auslösen von RecActions (CRUD + Invoke)
|
||||
* `Results` (`ResultApi`) – Lesen, Anlegen, Aktualisieren und Löschen von Result-Datensätzen
|
||||
* `Profiles` (`ProfileApi`) – Verwaltung der Profile
|
||||
* `EndpointAuth` (`EndpointAuthApi`) – Verwaltung der Endpoint-Authentifizierungsdaten
|
||||
* `EndpointParams` (`EndpointParamsApi`) – Verwaltung der Endpoint-Parameter
|
||||
* `Endpoints` (`EndpointsApi`) – Verwaltung der Endpoints
|
||||
* `Common` (`CommonApi`) – Gemeinsame Operationen, die nicht entitätsspezifisch sind
|
||||
|
||||
Alle entitätsspezifischen Klassen erben von `BaseCrudApi` und bieten ein konsistentes CRUD-Schema.
|
||||
|
||||
== 4. Verwendung ==
|
||||
|
||||
=== 4.1 GET-Endpunkte: typisiert oder dynamisch ===
|
||||
|
||||
Die GET-Methoden in `RecActionApi`, `ProfileApi` und `ResultApi` existieren jeweils als **zwei Overloads**:
|
||||
|
||||
* **Generisch**: `GetAsync<T>(...)` – führt die Anfrage aus, liest den Response-Body **einmal** und deserialisiert ihn in den Typ `T`.
|
||||
* **Nicht-generisch**: `GetAsync(...)` – identische Parameterliste, gibt aber ein `dynamic` (in der Praxis `System.Text.Json.JsonElement`) zurück. Intern wird `GetAsync<object>(...)` aufgerufen.
|
||||
|
||||
Beide Overloads teilen sich Implementierung und Fehlerbehandlung: bei HTTP-Fehlerstatus wird **`ReCApiException`** geworfen.
|
||||
|
||||
{{info}}
|
||||
Da der nicht-generische Overload eine andere Signatur als der generische besitzt (kein Typparameter), gibt es **keinen Konflikt**. Welcher Overload aufgerufen wird, hängt davon ab, ob Sie einen Typparameter angeben oder nicht.
|
||||
{{/info}}
|
||||
|
||||
==== 4.1.1 Typisiertes Lesen ====
|
||||
|
||||
{{code language="vb.net"}}
|
||||
Imports ReC.Application.Common.Dto
|
||||
|
||||
' Alle Actions für ein Profil als typisiertes Array
|
||||
Dim actions As RecActionViewDto() =
|
||||
Await recClient.RecActions.GetAsync(Of RecActionViewDto())(profileId:=42)
|
||||
|
||||
For Each a In actions
|
||||
Console.WriteLine($"Action {a.Id} -> Endpoint {a.EndpointUri}")
|
||||
Next
|
||||
{{/code}}
|
||||
|
||||
{{code language="csharp"}}
|
||||
using ReC.Application.Common.Dto;
|
||||
|
||||
// All actions for a profile as a typed array
|
||||
var actions = await recClient.RecActions.GetAsync<RecActionViewDto[]>(profileId: 42);
|
||||
|
||||
foreach (var a in actions!)
|
||||
{
|
||||
Console.WriteLine($"Action {a.Id} -> Endpoint {a.EndpointUri}");
|
||||
}
|
||||
{{/code}}
|
||||
|
||||
==== 4.1.2 Dynamisches Lesen ====
|
||||
|
||||
Wenn das Schema flexibel ist oder Sie das Ergebnis nur weiterleiten möchten, können Sie den nicht-generischen Overload verwenden:
|
||||
|
||||
{{code language="vb.net"}}
|
||||
Imports System.Text.Json
|
||||
|
||||
Dim payload As Object = Await recClient.RecActions.GetAsync(profileId:=42)
|
||||
Dim element As JsonElement = CType(payload, JsonElement)
|
||||
|
||||
If element.ValueKind = JsonValueKind.Array Then
|
||||
For Each item In element.EnumerateArray()
|
||||
Console.WriteLine(item.GetProperty("id").GetInt64())
|
||||
Next
|
||||
End If
|
||||
{{/code}}
|
||||
|
||||
{{code language="csharp"}}
|
||||
using System.Text.Json;
|
||||
|
||||
dynamic? payload = await recClient.RecActions.GetAsync(profileId: 42);
|
||||
var element = (JsonElement)payload!;
|
||||
|
||||
if (element.ValueKind == JsonValueKind.Array)
|
||||
{
|
||||
foreach (var item in element.EnumerateArray())
|
||||
{
|
||||
Console.WriteLine(item.GetProperty("id").GetInt64());
|
||||
}
|
||||
}
|
||||
{{/code}}
|
||||
|
||||
=== 4.2 Eine RecAction auslösen (Invoke) ===
|
||||
|
||||
`RecActionApi.InvokeAsync` startet die Stapelverarbeitung der Actions eines Profils. Es gibt zwei Overloads: einen mit `InvokeReferences`-Objekt und einen Komfort-Overload mit nur einer Batch-ID.
|
||||
|
||||
{{code language="vb.net"}}
|
||||
Imports ReC.Client.Api
|
||||
|
||||
Public Async Function FuehreProfilAktionenAus(recClient As ReCClient, profilId As Long) As Task
|
||||
Try
|
||||
Await recClient.RecActions.InvokeAsync(
|
||||
profilId,
|
||||
New InvokeReferences With {.BatchId = "batch-" & Guid.NewGuid().ToString("N")})
|
||||
Catch ex As ReC.Client.ReCApiException
|
||||
' Auswertung von ex.StatusCode, ex.Method, ex.RequestUri, ex.ResponseBody
|
||||
Throw
|
||||
End Try
|
||||
End Function
|
||||
{{/code}}
|
||||
|
||||
{{code language="csharp"}}
|
||||
using ReC.Client;
|
||||
using ReC.Client.Api;
|
||||
|
||||
public async Task ExecuteProfileActionsAsync(ReCClient recClient, long profileId)
|
||||
{
|
||||
try
|
||||
{
|
||||
await recClient.RecActions.InvokeAsync(
|
||||
profileId,
|
||||
new InvokeReferences { BatchId = $"batch-{Guid.NewGuid():N}" });
|
||||
}
|
||||
catch (ReCApiException ex)
|
||||
{
|
||||
// Inspect ex.StatusCode, ex.Method, ex.RequestUri, ex.ResponseBody
|
||||
throw;
|
||||
}
|
||||
}
|
||||
{{/code}}
|
||||
|
||||
Komfort-Overload nur mit Batch-ID:
|
||||
|
||||
{{code language="vb.net"}}
|
||||
Await recClient.RecActions.InvokeAsync(profilId, "batch-001")
|
||||
{{/code}}
|
||||
|
||||
{{code language="csharp"}}
|
||||
await recClient.RecActions.InvokeAsync(profileId, "batch-001");
|
||||
{{/code}}
|
||||
|
||||
=== 4.3 Anlegen, Aktualisieren, Löschen (CRUD) ===
|
||||
|
||||
Alle CRUD-Operationen sind asynchron und werfen bei Fehlern eine `ReCApiException`.
|
||||
|
||||
* `CreateAsync(payload)` – HTTP POST mit JSON-Body.
|
||||
* `UpdateAsync(id, payload)` – HTTP PUT auf `/{ResourcePath}/{id}` mit JSON-Body.
|
||||
* `DeleteAsync(payload)` – HTTP DELETE; das Payload wird in den **Query-String** serialisiert (die API bindet Delete-Parameter aus der URL).
|
||||
|
||||
{{code language="vb.net"}}
|
||||
Imports ReC.Application.RecActions.Commands
|
||||
Imports ReC.Application.Common.Procedures.UpdateProcedure.Dto
|
||||
|
||||
' POST
|
||||
Await recClient.RecActions.CreateAsync(New InsertActionCommand With {
|
||||
.ProfileId = 1,
|
||||
.EndpointId = 1,
|
||||
.Active = True,
|
||||
.Sequence = 1
|
||||
})
|
||||
|
||||
' PUT
|
||||
Await recClient.RecActions.UpdateAsync(123, New UpdateActionDto With {
|
||||
.Active = False,
|
||||
.Sequence = 2
|
||||
})
|
||||
|
||||
' DELETE (Payload wird zu Query-String)
|
||||
Await recClient.RecActions.DeleteAsync(New DeleteActionCommand With {
|
||||
.Start = 100,
|
||||
.End = 110,
|
||||
.Force = False
|
||||
})
|
||||
{{/code}}
|
||||
|
||||
{{code language="csharp"}}
|
||||
using ReC.Application.RecActions.Commands;
|
||||
using ReC.Application.Common.Procedures.UpdateProcedure.Dto;
|
||||
|
||||
// POST
|
||||
await recClient.RecActions.CreateAsync(new InsertActionCommand
|
||||
{
|
||||
ProfileId = 1,
|
||||
EndpointId = 1,
|
||||
Active = true,
|
||||
Sequence = 1
|
||||
});
|
||||
|
||||
// PUT
|
||||
await recClient.RecActions.UpdateAsync(123, new UpdateActionDto
|
||||
{
|
||||
Active = false,
|
||||
Sequence = 2
|
||||
});
|
||||
|
||||
// DELETE (payload becomes query string)
|
||||
await recClient.RecActions.DeleteAsync(new DeleteActionCommand
|
||||
{
|
||||
Start = 100,
|
||||
End = 110,
|
||||
Force = false
|
||||
});
|
||||
{{/code}}
|
||||
|
||||
=== 4.4 Fehlerbehandlung mit ReCApiException ===
|
||||
|
||||
Sobald die API einen Statuscode außerhalb von 2xx zurückgibt, wirft die Bibliothek eine `ReCApiException`. Diese enthält folgende Informationen:
|
||||
|
||||
* `StatusCode` – `HttpStatusCode` der Antwort (z. B. 404, 400, 500)
|
||||
* `ReasonPhrase` – Optionaler HTTP-Reason-Phrase
|
||||
* `ResponseBody` – Roher Response-Body als String (sofern lesbar)
|
||||
* `Method` – HTTP-Methode der ursprünglichen Anfrage (z. B. `GET`, `POST`)
|
||||
* `RequestUri` – Aufgerufene URI mit Pfad und Query
|
||||
|
||||
{{code language="vb.net"}}
|
||||
Try
|
||||
Dim profile = Await recClient.Profiles.GetAsync(Of ProfileViewDto)(id:=42)
|
||||
Catch ex As ReCApiException
|
||||
If ex.StatusCode = Net.HttpStatusCode.NotFound Then
|
||||
' Profil existiert nicht
|
||||
Else
|
||||
' Allgemeiner Fehler
|
||||
Console.WriteLine($"{ex.Method} {ex.RequestUri} -> {ex.StatusCode}: {ex.ResponseBody}")
|
||||
Throw
|
||||
End If
|
||||
End Try
|
||||
{{/code}}
|
||||
|
||||
{{code language="csharp"}}
|
||||
try
|
||||
{
|
||||
var profile = await recClient.Profiles.GetAsync<ProfileViewDto>(id: 42);
|
||||
}
|
||||
catch (ReCApiException ex) when (ex.StatusCode == System.Net.HttpStatusCode.NotFound)
|
||||
{
|
||||
// Profile does not exist
|
||||
}
|
||||
catch (ReCApiException ex)
|
||||
{
|
||||
Console.WriteLine($"{ex.Method} {ex.RequestUri} -> {ex.StatusCode}: {ex.ResponseBody}");
|
||||
throw;
|
||||
}
|
||||
{{/code}}
|
||||
|
||||
== 5. Testen ==
|
||||
|
||||
Das Testprojekt verwendet `Microsoft.AspNetCore.Mvc.Testing`, um die `ReC.API` mit `WebApplicationFactory<Program>` **in-process** zu starten. Der `ReCClient` wird über DI konfiguriert und auf den in-process HTTP-Handler verdrahtet. So müssen Tests die API nicht extern starten.
|
||||
|
||||
Empfehlungen:
|
||||
|
||||
* Schreiben Sie Tests als `async Task` und verwenden Sie `await` – **vermeiden** Sie `GetAwaiter().GetResult()` oder `TaskSyncExtensions.Sync(...)`.
|
||||
* Verwenden Sie `Assert.ThrowsAsync<ReCApiException>(...)`, um Fehlerpfade zu prüfen, und werten Sie `StatusCode`, `Method` und `RequestUri` aus.
|
||||
* Für GET-Tests reicht eine einzelne Methode pro Verhalten (typisiert vs. dynamisch) statt redundanter Setups.
|
||||
|
||||
== 6. Komfort-APIs: statischer Provider und synchrone Wrapper ==
|
||||
|
||||
Neben dem empfohlenen DI-basierten Ansatz bietet **ReC.Client** absichtlich auch einen **statischen Komfort-Pfad** sowie **synchrone Wrapper** an. Diese Bestandteile sind nicht „veraltet im Sinne von eingefroren" – sie werden weiterhin gepflegt und bei Bedarf um neue Funktionen erweitert. Sie sind jedoch bewusst mit `[Obsolete]` markiert, damit Aufrufer sie nicht „aus Versehen" auswählen, sondern eine bewusste Entscheidung treffen.
|
||||
|
||||
Hintergrund
|
||||
|
||||
* In Projekten, die noch auf **.NET Framework 4.6.2** basieren, ist `Microsoft.Extensions.DependencyInjection` häufig nicht etabliert und die Einführung einer DI-Infrastruktur kostet Zeit. Damit Entwickler dort nicht ins Stocken geraten, gibt es einen statischen Einstieg, der **sofort einsatzbereit** ist.
|
||||
* Synchroner Code in älteren Codebasen kann nicht überall sofort auf `async/await` umgestellt werden. Für solche Stellen existieren die `Sync()`-Erweiterungen als pragmatische Brücke.
|
||||
|
||||
Status
|
||||
|
||||
* **Aktiv gepflegt**: Beide Pfade erhalten weiterhin Funktionalitäts- und Komfort-Updates.
|
||||
* **`[Obsolete]`-Markierung als Erinnerung**: Die Compiler-Warnung soll bewusst sichtbar bleiben, damit Teams die Übergangslösung nicht vergessen und mittelfristig zu DI + `async/await` migrieren.
|
||||
* **Kein Breaking-Change-Risiko**: Aufrufe bleiben kompilierbar und ausführbar.
|
||||
|
||||
Wann der statische Pfad sinnvoll ist
|
||||
|
||||
* Bestehender VB.NET-/C#-Code auf .NET Framework 4.6.2 ohne eigene DI-Infrastruktur.
|
||||
* Kleine Werkzeuge, Skripte oder Konsolenanwendungen, bei denen ein vollständiges Host-Setup übertrieben wäre.
|
||||
* Schneller Einstieg in die Bibliothek, um ein erstes Ergebnis zu sehen, bevor die endgültige Architektur entschieden wird.
|
||||
|
||||
Wann besser DI verwenden
|
||||
|
||||
* Lang laufende Prozesse, Serveranwendungen, Tests.
|
||||
* Sobald in der Anwendung ohnehin `IServiceCollection` / `IHostBuilder` vorhanden ist.
|
||||
* Wenn `HttpClient`-Lebenszyklen, Logging-Scopes oder Optionen sauber verwaltet werden sollen.
|
||||
|
||||
=== 6.1 Statischer Client mit BuildStaticClient / Create ===
|
||||
|
||||
`ReCClient.BuildStaticClient(...)` baut intern eine `IServiceCollection` auf, ruft `AddRecClient(...)` auf und legt einen **statischen, thread-safen `Lazy<IServiceProvider>`** an. Der eigentliche `IServiceProvider` wird **erst beim ersten Aufruf von `ReCClient.Create()`** und genau einmal gebaut.
|
||||
|
||||
Wichtig:
|
||||
|
||||
* `BuildStaticClient` darf **nur einmal** beim Anwendungsstart aufgerufen werden. Ein zweiter Aufruf – egal welcher Overload – wirft `InvalidOperationException("Static Provider is already built.")`.
|
||||
* Die Konstruktion des `IServiceProvider` ist **threadsicher** (`Lazy<T>` mit `LazyThreadSafetyMode.ExecutionAndPublication`).
|
||||
|
||||
==== 6.1.1 Empfohlene Form: BuildStaticClient mit StaticBuildConfiguration ====
|
||||
|
||||
Da der statische Pfad mehrere optionale Bestandteile hat (Basis-URL **oder** `HttpClient`-Konfiguration, `ReCClientOptions`, eigener `ILogger`, zusätzliche Service-Registrierungen), gibt es einen Callback-basierten Overload, der alle Optionen in einem `StaticBuildConfiguration`-Objekt bündelt:
|
||||
|
||||
{{code language="vb.net"}}
|
||||
Imports Microsoft.Extensions.DependencyInjection
|
||||
Imports Microsoft.Extensions.Logging
|
||||
|
||||
ReCClient.BuildStaticClient(
|
||||
Sub(cfg)
|
||||
cfg.BaseAddress = "https://ihre-rec-api-adresse.com/"
|
||||
|
||||
cfg.ConfigureOptions = Sub(opt)
|
||||
opt.LogSuccessfulRequests = True
|
||||
End Sub
|
||||
|
||||
' Optional: eigene zusätzliche Registrierungen, z. B. Logging-Provider
|
||||
cfg.ConfigureServices = Sub(services)
|
||||
services.AddLogging(Sub(b) b.AddConsole())
|
||||
End Sub
|
||||
End Sub)
|
||||
{{/code}}
|
||||
|
||||
{{code language="csharp"}}
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
ReCClient.BuildStaticClient(cfg =>
|
||||
{
|
||||
cfg.BaseAddress = "https://ihre-rec-api-adresse.com/";
|
||||
|
||||
cfg.ConfigureOptions = opt =>
|
||||
{
|
||||
opt.LogSuccessfulRequests = true;
|
||||
};
|
||||
|
||||
// Optional: additional service registrations, e.g. logging
|
||||
cfg.ConfigureServices = services =>
|
||||
{
|
||||
services.AddLogging(b => b.AddConsole());
|
||||
};
|
||||
});
|
||||
{{/code}}
|
||||
|
||||
Eigenschaften von `StaticBuildConfiguration`:
|
||||
|
||||
* `BaseAddress` – Basis-URI der ReC.API. **Schließt sich gegenseitig** mit `ConfigureClient` aus.
|
||||
* `ConfigureClient` – Delegate zum direkten Konfigurieren des `HttpClient` (z. B. `BaseAddress` + `Timeout` + Header). Schließt sich gegenseitig mit `BaseAddress` aus.
|
||||
* `ConfigureOptions` – Optionaler Delegate für `ReCClientOptions` (z. B. `LogSuccessfulRequests`).
|
||||
* `Logger` – Optionale `ILogger`-Instanz, die als Singleton in die interne `IServiceCollection` registriert wird.
|
||||
* `ConfigureServices` – Optionaler Delegate, mit dem der Aufrufer beliebige zusätzliche Registrierungen auf der internen `IServiceCollection` vornehmen kann (z. B. `AddLogging(...)` oder eigene Abhängigkeiten).
|
||||
|
||||
Validierung beim Aufruf:
|
||||
|
||||
* `BuildStaticClient` wirft `ArgumentNullException`, wenn der `configure`-Callback `null` ist.
|
||||
* `BuildStaticClient` wirft `InvalidOperationException`, wenn weder `BaseAddress` noch `ConfigureClient` gesetzt sind, **oder** wenn beide gleichzeitig gesetzt sind.
|
||||
|
||||
Variante mit `HttpClient`-Feinkonfiguration:
|
||||
|
||||
{{code language="vb.net"}}
|
||||
ReCClient.BuildStaticClient(
|
||||
Sub(cfg)
|
||||
cfg.ConfigureClient = Sub(http)
|
||||
http.BaseAddress = New Uri("https://ihre-rec-api-adresse.com/")
|
||||
http.Timeout = TimeSpan.FromSeconds(30)
|
||||
End Sub
|
||||
End Sub)
|
||||
{{/code}}
|
||||
|
||||
{{code language="csharp"}}
|
||||
ReCClient.BuildStaticClient(cfg =>
|
||||
{
|
||||
cfg.ConfigureClient = http =>
|
||||
{
|
||||
http.BaseAddress = new Uri("https://ihre-rec-api-adresse.com/");
|
||||
http.Timeout = TimeSpan.FromSeconds(30);
|
||||
};
|
||||
});
|
||||
{{/code}}
|
||||
|
||||
Nach dem `BuildStaticClient`-Aufruf liefert `ReCClient.Create()` Instanzen aus dem statischen Provider:
|
||||
|
||||
{{code language="vb.net"}}
|
||||
Dim client As ReCClient = ReCClient.Create()
|
||||
Await client.RecActions.InvokeAsync(profilId, "batch-001")
|
||||
{{/code}}
|
||||
|
||||
{{code language="csharp"}}
|
||||
var client = ReCClient.Create();
|
||||
await client.RecActions.InvokeAsync(profileId, "batch-001");
|
||||
{{/code}}
|
||||
|
||||
==== 6.1.2 Ältere Komfort-Overloads ====
|
||||
|
||||
Aus Bequemlichkeit existieren zwei weitere Overloads, die intern an die `StaticBuildConfiguration`-Variante delegieren. Sie sind als `Obsolete` markiert mit dem Hinweis, die `Action<StaticBuildConfiguration>`-Variante zu verwenden, bleiben aber funktionsfähig:
|
||||
|
||||
{{code language="vb.net"}}
|
||||
' Variante mit Basis-URL als String
|
||||
ReCClient.BuildStaticClient("https://ihre-rec-api-adresse.com/")
|
||||
|
||||
' Mit Options-Callback und optionalem Logger
|
||||
ReCClient.BuildStaticClient(
|
||||
"https://ihre-rec-api-adresse.com/",
|
||||
Sub(opt) opt.LogSuccessfulRequests = True,
|
||||
myLogger)
|
||||
{{/code}}
|
||||
|
||||
{{code language="csharp"}}
|
||||
// Variant with base URL string
|
||||
ReCClient.BuildStaticClient("https://ihre-rec-api-adresse.com/");
|
||||
|
||||
// With options callback and optional logger
|
||||
ReCClient.BuildStaticClient(
|
||||
"https://ihre-rec-api-adresse.com/",
|
||||
opt => opt.LogSuccessfulRequests = true,
|
||||
myLogger);
|
||||
{{/code}}
|
||||
|
||||
=== 6.2 Synchrone Wrapper über TaskSyncExtensions ===
|
||||
|
||||
`TaskSyncExtensions.Sync()` bzw. `Sync<TResult>()` blockieren den aktuellen Thread, bis die `Task` abgeschlossen ist. Sie sind nützlich, wenn der umliegende Code (noch) nicht asynchron sein kann.
|
||||
|
||||
{{code language="vb.net"}}
|
||||
Imports ReC.Client
|
||||
|
||||
' Blockiert bis die Task fertig ist
|
||||
recClient.RecActions.InvokeAsync(profilId, "batch-001").Sync()
|
||||
{{/code}}
|
||||
|
||||
{{code language="csharp"}}
|
||||
using ReC.Client;
|
||||
|
||||
// Blocks until the task completes
|
||||
recClient.RecActions.InvokeAsync(profileId, "batch-001").Sync();
|
||||
{{/code}}
|
||||
|
||||
Hinweis: In Umgebungen mit einem `SynchronizationContext` (z. B. WinForms, WPF oder bestimmte Test-Runner) kann blockierendes Warten zu Deadlocks führen. Für Konsolen- und Hintergrundprozesse ist das Risiko in der Regel gering. Wo immer möglich: `async/await` bevorzugen.
|
||||
|
||||
=== 6.3 Mittelfristige Empfehlung ===
|
||||
|
||||
* **`BuildStaticClient` / `Create`**: für Legacy-Einstiege okay; sobald `IServiceCollection` vorhanden ist, auf `services.AddRecClient(...)` und Konstruktor-Injektion umstellen.
|
||||
* **`TaskSyncExtensions.Sync`**: nur lokal kapseln. Wenn eine Methode bereits `async` sein darf, direkt `await` verwenden.
|
||||
* Die `[Obsolete]`-Warnung dient als **dauerhafter Reminder**, dass es sich um einen bewussten Komfort-Pfad handelt – sie ist kein Hinweis darauf, dass die APIs entfernt werden.
|
||||
0
docs/ReC.Client.xwiki.md
Normal file
0
docs/ReC.Client.xwiki.md
Normal file
@@ -14,13 +14,18 @@ public class RecActionController(IMediator mediator) : ControllerBase
|
||||
/// Invokes a batch of RecActions for a given profile.
|
||||
/// </summary>
|
||||
/// <param name="profileId">The identifier of the profile whose RecActions should be invoked.</param>
|
||||
/// <param name="references">Optional reference values that are passed through to all result records.</param>
|
||||
/// <param name="cancel">A token to cancel the operation.</param>
|
||||
/// <returns>An HTTP 202 Accepted response indicating the process has been started.</returns>
|
||||
[HttpPost("invoke/{profileId}")]
|
||||
[ProducesResponseType(StatusCodes.Status202Accepted)]
|
||||
public async Task<IActionResult> Invoke([FromRoute] long profileId, CancellationToken cancel)
|
||||
public async Task<IActionResult> Invoke([FromRoute] long profileId, [FromBody] InvokeReferences references, CancellationToken cancel = default)
|
||||
{
|
||||
await mediator.Send(new InvokeBatchRecActionViewsCommand { ProfileId = profileId }, cancel);
|
||||
await mediator.Send(new InvokeBatchRecActionViewsCommand
|
||||
{
|
||||
ProfileId = profileId,
|
||||
References = references
|
||||
}, cancel);
|
||||
return Accepted();
|
||||
}
|
||||
|
||||
|
||||
@@ -1,8 +0,0 @@
|
||||
namespace ReC.API.Models;
|
||||
|
||||
public class FakeRequest
|
||||
{
|
||||
public Dictionary<string, object>? Body { get; init; }
|
||||
|
||||
public Dictionary<string, object>? Header { get; init; }
|
||||
}
|
||||
@@ -1,17 +0,0 @@
|
||||
namespace ReC.API.Models;
|
||||
|
||||
public enum ResultType
|
||||
{
|
||||
/// <summary>
|
||||
/// Return the full result object.
|
||||
/// </summary>
|
||||
Full,
|
||||
/// <summary>
|
||||
/// Return only the header part of the result.
|
||||
/// </summary>
|
||||
OnlyHeader,
|
||||
/// <summary>
|
||||
/// Return only the body part of the result.
|
||||
/// </summary>
|
||||
OnlyBody
|
||||
}
|
||||
@@ -48,10 +48,13 @@ try
|
||||
?? throw new InvalidOperationException("Connection string is not found.");
|
||||
|
||||
var logger = provider.GetRequiredService<ILogger<RecDbContext>>();
|
||||
var enableSensitiveDataLogging = config.GetValue("EfCore:EnableSensitiveDataLogging", true);
|
||||
var enableDetailedErrors = config.GetValue("EfCore:EnableDetailedErrors", false);
|
||||
|
||||
opt.UseSqlServer(cnnStr)
|
||||
.LogTo(log => logger.LogInformation("{log}", log), LogLevel.Trace)
|
||||
.EnableSensitiveDataLogging()
|
||||
.EnableDetailedErrors();
|
||||
.EnableSensitiveDataLogging(enableSensitiveDataLogging)
|
||||
.EnableDetailedErrors(enableDetailedErrors);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -95,3 +98,5 @@ catch(Exception ex)
|
||||
logger.Error(ex, "Stopped program because of exception");
|
||||
throw;
|
||||
}
|
||||
|
||||
public partial class Program;
|
||||
@@ -9,7 +9,7 @@
|
||||
<LaunchSiteAfterPublish>true</LaunchSiteAfterPublish>
|
||||
<ExcludeApp_Data>false</ExcludeApp_Data>
|
||||
<ProjectGuid>420218ad-3c27-4003-9a84-36c92352f175</ProjectGuid>
|
||||
<DesktopBuildPackageLocation>P:\Install .Net\0 DD - Smart UP\ReC\API\$(Version)\Rec.API.zip</DesktopBuildPackageLocation>
|
||||
<DesktopBuildPackageLocation>M:\App&Service\0 DD - Smart UP\ReC\API\$(Version)\Rec.API.zip</DesktopBuildPackageLocation>
|
||||
<PackageAsSingleFile>true</PackageAsSingleFile>
|
||||
<DeployIisAppPath>Rec.API</DeployIisAppPath>
|
||||
<_TargetId>IISWebDeployPackage</_TargetId>
|
||||
|
||||
@@ -10,10 +10,10 @@
|
||||
<Product>ReC.API</Product>
|
||||
<PackageIcon>Assets\icon.ico</PackageIcon>
|
||||
<PackageTags>digital data rest-caller rec api</PackageTags>
|
||||
<Version>2.0.2-beta</Version>
|
||||
<AssemblyVersion>2.0.2.0</AssemblyVersion>
|
||||
<FileVersion>2.0.2.0</FileVersion>
|
||||
<InformationalVersion>2.0.1-beta</InformationalVersion>
|
||||
<Version>2.4.0-beta</Version>
|
||||
<AssemblyVersion>2.4.0.0</AssemblyVersion>
|
||||
<FileVersion>2.4.0.0</FileVersion>
|
||||
<InformationalVersion>2.4.0-beta</InformationalVersion>
|
||||
<Copyright>Copyright © 2025 Digital Data GmbH. All rights reserved.</Copyright>
|
||||
<GenerateDocumentationFile>true</GenerateDocumentationFile>
|
||||
<NoWarn>$(NoWarn);1591</NoWarn>
|
||||
|
||||
@@ -5,9 +5,13 @@
|
||||
},
|
||||
"AllowedHosts": "*",
|
||||
"LuckyPennySoftwareLicenseKey": "eyJhbGciOiJSUzI1NiIsImtpZCI6Ikx1Y2t5UGVubnlTb2Z0d2FyZUxpY2Vuc2VLZXkvYmJiMTNhY2I1OTkwNGQ4OWI0Y2IxYzg1ZjA4OGNjZjkiLCJ0eXAiOiJKV1QifQ.eyJpc3MiOiJodHRwczovL2x1Y2t5cGVubnlzb2Z0d2FyZS5jb20iLCJhdWQiOiJMdWNreVBlbm55U29mdHdhcmUiLCJleHAiOiIxNzg0ODUxMjAwIiwiaWF0IjoiMTc1MzM2MjQ5MSIsImFjY291bnRfaWQiOiIwMTk4M2M1OWU0YjM3MjhlYmZkMzEwM2MyYTQ4NmU4NSIsImN1c3RvbWVyX2lkIjoiY3RtXzAxazB5NmV3MmQ4YTk4Mzg3aDJnbTRuOWswIiwic3ViX2lkIjoiLSIsImVkaXRpb24iOiIwIiwidHlwZSI6IjIifQ.ZqsFG7kv_-xGfxS6ACk3i0iuNiVUXX2AvPI8iAcZ6-z2170lGv__aO32tWpQccD9LCv5931lBNLWSblKS0MT3gOt-5he2TEftwiSQGFwoIBgtOHWsNRMinUrg2trceSp3IhyS3UaMwnxZDrCvx4-0O-kpOzVpizeHUAZNr5U7oSCWO34bpKdae6grtM5e3f93Z1vs7BW_iPgItd-aLvPwApbaG9VhmBTKlQ7b4Jh64y7UXJ9mKP7Qb_Oa97oEg0oY5DPHOWTZWeE1EzORgVr2qkK2DELSHuZ_EIUhODojkClPNAKtvEl_qEjpq0HZCIvGwfCCRlKlSkQqIeZdFkiXg",
|
||||
"EfCore": {
|
||||
"EnableSensitiveDataLogging": true,
|
||||
"EnableDetailedErrors": false
|
||||
},
|
||||
"RecAction": {
|
||||
"AddedWho": "ReC.API",
|
||||
"UseHttp1ForNtlm": false
|
||||
"UseHttp1ForNtlm": false,
|
||||
"AutoDetectHeaders": false
|
||||
},
|
||||
// Bad request SqlException numbers numbers can be updated at runtime; no restart required.
|
||||
"SqlException": {
|
||||
|
||||
@@ -1,23 +1,54 @@
|
||||
using MediatR;
|
||||
using ReC.Application.Common.Dto;
|
||||
using ReC.Application.Common.Interfaces;
|
||||
using MediatR;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using ReC.Application.Common.Dto;
|
||||
using ReC.Application.Common.Exceptions;
|
||||
using ReC.Application.Common.Interfaces;
|
||||
|
||||
namespace ReC.Application.Common.Behaviors.Action;
|
||||
|
||||
public class BodyQueryBehavior<TRequest, TResponse>(IRecDbContext dbContext) : IPipelineBehavior<TRequest, TResponse>
|
||||
where TRequest : RecActionViewDto
|
||||
where TResponse : notnull
|
||||
where TRequest : notnull
|
||||
where TResponse : IEnumerable<RecActionViewDto>
|
||||
{
|
||||
public async Task<TResponse> Handle(TRequest action, RequestHandlerDelegate<TResponse> next, CancellationToken cancel)
|
||||
public async Task<TResponse> Handle(TRequest request, RequestHandlerDelegate<TResponse> next, CancellationToken cancel)
|
||||
{
|
||||
if (action.BodyQuery is null)
|
||||
return await next(cancel);
|
||||
var actions = await next(cancel);
|
||||
|
||||
var result = await dbContext.BodyQueryResults.FromSqlRaw(action.BodyQuery).SingleOrDefaultAsync(cancel);
|
||||
foreach (var action in actions)
|
||||
await SetBody(action, cancel);
|
||||
|
||||
action.Body = result?.RawBody;
|
||||
return actions;
|
||||
}
|
||||
|
||||
return await next(cancel);
|
||||
private async Task SetBody(RecActionViewDto action, CancellationToken cancel)
|
||||
{
|
||||
if (action.BodyQuery is not string bodyQuery)
|
||||
return;
|
||||
|
||||
await using var command = dbContext.Database.GetDbConnection().CreateCommand();
|
||||
command.CommandText = bodyQuery;
|
||||
|
||||
await dbContext.Database.OpenConnectionAsync(cancel);
|
||||
try
|
||||
{
|
||||
object? scalar;
|
||||
try
|
||||
{
|
||||
scalar = await command.ExecuteScalarAsync(cancel);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
throw new DataIntegrityException(
|
||||
$"Body query execution failed. The stored SQL may be malformed. ActionId: {action.Id}, ProfileId: {action.ProfileId}, Error: {ex.Message}");
|
||||
}
|
||||
|
||||
action.Body = scalar as string
|
||||
?? throw new DataIntegrityException(
|
||||
$"Body query returned no result or a null value. ActionId: {action.Id}, ProfileId: {action.ProfileId}");
|
||||
}
|
||||
finally
|
||||
{
|
||||
await dbContext.Database.CloseConnectionAsync();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,43 +1,61 @@
|
||||
using MediatR;
|
||||
using MediatR;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using ReC.Application.Common.Dto;
|
||||
using ReC.Application.Common.Exceptions;
|
||||
using ReC.Application.Common.Interfaces;
|
||||
using System.Text.Json;
|
||||
|
||||
namespace ReC.Application.Common.Behaviors.Action;
|
||||
|
||||
public class HeaderQueryBehavior<TRequest, TResponse>(IRecDbContext dbContext, ILogger<HeaderQueryBehavior<TRequest, TResponse>>? logger = null) : IPipelineBehavior<TRequest, TResponse>
|
||||
where TRequest : RecActionViewDto
|
||||
where TResponse : notnull
|
||||
public class HeaderQueryBehavior<TRequest, TResponse>(IRecDbContext dbContext) : IPipelineBehavior<TRequest, TResponse>
|
||||
where TRequest : notnull
|
||||
where TResponse : IEnumerable<RecActionViewDto>
|
||||
{
|
||||
public async Task<TResponse> Handle(TRequest action, RequestHandlerDelegate<TResponse> next, CancellationToken cancel)
|
||||
public async Task<TResponse> Handle(TRequest request, RequestHandlerDelegate<TResponse> next, CancellationToken cancel)
|
||||
{
|
||||
if (action.HeaderQuery is null)
|
||||
return await next(cancel);
|
||||
var actions = await next(cancel);
|
||||
|
||||
var result = await dbContext.HeaderQueryResults.FromSqlRaw(action.HeaderQuery).SingleOrDefaultAsync(cancel);
|
||||
foreach (var action in actions)
|
||||
await SetHeader(action, cancel);
|
||||
|
||||
if (result?.RawHeader is null)
|
||||
{
|
||||
logger?.LogWarning("Header query did not return a result or returned a null REQUEST_HEADER. Profile ID: {ProfileId}, Action ID: {Id}", action.ProfileId, action.Id);
|
||||
|
||||
return await next(cancel);
|
||||
return actions;
|
||||
}
|
||||
|
||||
var headerDict = JsonSerializer.Deserialize<Dictionary<string, JsonElement>>(result.RawHeader);
|
||||
|
||||
if(headerDict is null)
|
||||
private async Task SetHeader(RecActionViewDto action, CancellationToken cancel)
|
||||
{
|
||||
logger?.LogWarning(
|
||||
"Header JSON deserialization returned null. RawHeader: {RawHeader}, ProfileId: {ProfileId}, Id: {Id}",
|
||||
result.RawHeader, action.ProfileId, action.Id);
|
||||
if (action.HeaderQuery is not string headerQuery)
|
||||
return;
|
||||
|
||||
return await next(cancel);
|
||||
await using var command = dbContext.Database.GetDbConnection().CreateCommand();
|
||||
command.CommandText = headerQuery;
|
||||
|
||||
await dbContext.Database.OpenConnectionAsync(cancel);
|
||||
try
|
||||
{
|
||||
object? scalar;
|
||||
try
|
||||
{
|
||||
scalar = await command.ExecuteScalarAsync(cancel);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
throw new DataIntegrityException(
|
||||
$"Header query execution failed. The stored SQL may be malformed. ActionId: {action.Id}, ProfileId: {action.ProfileId}, Error: {ex.Message}");
|
||||
}
|
||||
|
||||
if (scalar is not string rawHeader)
|
||||
throw new DataIntegrityException(
|
||||
$"Header query returned no result or a null value. ActionId: {action.Id}, ProfileId: {action.ProfileId}");
|
||||
|
||||
var headerDict = JsonSerializer.Deserialize<Dictionary<string, JsonElement>>(rawHeader)
|
||||
?? throw new DataIntegrityException(
|
||||
$"Header query returned invalid JSON. ActionId: {action.Id}, ProfileId: {action.ProfileId}");
|
||||
|
||||
action.Headers = headerDict.ToDictionary(header => header.Key, kvp => kvp.Value.ToString());
|
||||
|
||||
return await next(cancel);
|
||||
}
|
||||
finally
|
||||
{
|
||||
await dbContext.Database.CloseConnectionAsync();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -23,10 +23,11 @@ public class PostprocessingBehavior(IRecDbContext context, ISender sender) : IPi
|
||||
|
||||
await sender.Send(new InsertResultCommand()
|
||||
{
|
||||
Status = RecStatus.QuerySuccess,
|
||||
Status = RecStatus.OK,
|
||||
ActionId = request.Action.Id,
|
||||
Info = info,
|
||||
Type = ResultType.Post
|
||||
InfoDetail = info,
|
||||
Type = ResultType.Post,
|
||||
References = request.References
|
||||
}, cancel);
|
||||
}
|
||||
}
|
||||
@@ -36,10 +37,11 @@ public class PostprocessingBehavior(IRecDbContext context, ISender sender) : IPi
|
||||
|
||||
await sender.Send(new InsertResultCommand()
|
||||
{
|
||||
Status = RecStatus.Failed,
|
||||
Status = RecStatus.Error,
|
||||
ActionId = request.Action.Id,
|
||||
Error = error,
|
||||
Type = ResultType.Post
|
||||
Type = ResultType.Post,
|
||||
References = request.References
|
||||
}, cancel);
|
||||
|
||||
if (request.Action.ErrorAction == ErrorAction.Stop)
|
||||
|
||||
@@ -20,10 +20,11 @@ public class PreprocessingBehavior(IRecDbContext context, ISender sender) : IPip
|
||||
|
||||
await sender.Send(new InsertResultCommand()
|
||||
{
|
||||
Status = RecStatus.QuerySuccess,
|
||||
Status = RecStatus.OK,
|
||||
ActionId = request.Action.Id,
|
||||
Info = JsonSerializer.Serialize(result),
|
||||
Type = ResultType.Pre
|
||||
InfoDetail = JsonSerializer.Serialize(result),
|
||||
Type = ResultType.Pre,
|
||||
References = request.References
|
||||
}, cancel);
|
||||
}
|
||||
}
|
||||
@@ -31,10 +32,11 @@ public class PreprocessingBehavior(IRecDbContext context, ISender sender) : IPip
|
||||
{
|
||||
await sender.Send(new InsertResultCommand()
|
||||
{
|
||||
Status = RecStatus.Failed,
|
||||
Status = RecStatus.Error,
|
||||
ActionId = request.Action.Id,
|
||||
Error = ex.ToString(),
|
||||
Type = ResultType.Pre
|
||||
Type = ResultType.Pre,
|
||||
References = request.References
|
||||
}, cancel);
|
||||
|
||||
if (request.Action.ErrorAction == ErrorAction.Stop)
|
||||
|
||||
@@ -26,8 +26,12 @@ public record ResultViewDto
|
||||
|
||||
public string? Info { get; set; }
|
||||
|
||||
public string? InfoDetail { get; set; }
|
||||
|
||||
public string? Error { get; set; }
|
||||
|
||||
public string? BatchId { get; set; }
|
||||
|
||||
public string? AddedWho { get; init; }
|
||||
|
||||
public DateTime? AddedWhen { get; init; }
|
||||
|
||||
@@ -3,4 +3,5 @@
|
||||
public class RecActionOptions
|
||||
{
|
||||
public bool UseHttp1ForNtlm { get; set; } = false;
|
||||
public bool AutoDetectHeaders { get; set; } = false;
|
||||
}
|
||||
@@ -11,9 +11,9 @@ namespace ReC.Application.Common.Procedures.DeleteProcedure;
|
||||
public record DeleteObjectProcedure : IRequest<int>
|
||||
{
|
||||
/// <summary>
|
||||
/// Target entity: ACTION, ENDPOINT, ENDPOINT_AUTH, ENDPOINT_PARAMS, PROFILE, RESULT
|
||||
/// Target entity for the delete operation.
|
||||
/// </summary>
|
||||
public string Entity { get; set; } = null!;
|
||||
public required EntityType Entity { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Start GUID/ID (inclusive)
|
||||
|
||||
51
src/ReC.Application/Common/Procedures/EntityType.cs
Normal file
51
src/ReC.Application/Common/Procedures/EntityType.cs
Normal file
@@ -0,0 +1,51 @@
|
||||
namespace ReC.Application.Common.Procedures;
|
||||
|
||||
/// <summary>
|
||||
/// Represents the target entity type for stored procedure operations (Insert, Update, Delete).
|
||||
/// </summary>
|
||||
public enum EntityType
|
||||
{
|
||||
/// <summary>
|
||||
/// A scheduled or configured action within a profile that invokes an endpoint (maps to TBREC_CFG_ACTION).
|
||||
/// </summary>
|
||||
Action,
|
||||
|
||||
/// <summary>
|
||||
/// A REST endpoint URI configuration (maps to TBREC_CFG_ENDPOINT).
|
||||
/// </summary>
|
||||
Endpoint,
|
||||
|
||||
/// <summary>
|
||||
/// Authentication credentials for an endpoint such as API key, Bearer token, or NTLM (maps to TBREC_CFG_ENDPOINT_AUTH).
|
||||
/// </summary>
|
||||
EndpointAuth,
|
||||
|
||||
/// <summary>
|
||||
/// Key-value parameters attached to an endpoint (maps to TBREC_CFG_ENDPOINT_PARAMS).
|
||||
/// </summary>
|
||||
EndpointParams,
|
||||
|
||||
/// <summary>
|
||||
/// A profile that groups one or more actions and defines execution settings (maps to TBREC_CFG_PROFILE).
|
||||
/// </summary>
|
||||
Profile,
|
||||
|
||||
/// <summary>
|
||||
/// The outcome of an action invocation including HTTP status, headers, body, and error details (maps to TBREC_OUT_RESULT).
|
||||
/// </summary>
|
||||
Result
|
||||
}
|
||||
|
||||
public static class EntityTypeExtensions
|
||||
{
|
||||
public static string ToDbString(this EntityType entityType) => entityType switch
|
||||
{
|
||||
EntityType.Action => "ACTION",
|
||||
EntityType.Endpoint => "ENDPOINT",
|
||||
EntityType.EndpointAuth => "ENDPOINT_AUTH",
|
||||
EntityType.EndpointParams => "ENDPOINT_PARAMS",
|
||||
EntityType.Profile => "PROFILE",
|
||||
EntityType.Result => "RESULT",
|
||||
_ => throw new ArgumentOutOfRangeException(nameof(entityType), $"Not expected entity type value: {entityType}")
|
||||
};
|
||||
}
|
||||
@@ -18,9 +18,9 @@ namespace ReC.Application.Common.Procedures.InsertProcedure;
|
||||
public record InsertObjectProcedure : IRequest<long>
|
||||
{
|
||||
/// <summary>
|
||||
/// Target entity: ACTION, ENDPOINT, ENDPOINT_AUTH, ENDPOINT_PARAMS, PROFILE, RESULT
|
||||
/// Target entity for the insert operation.
|
||||
/// </summary>
|
||||
public string Entity { get; set; } = null!;
|
||||
public required EntityType Entity { get; set; }
|
||||
|
||||
//TODO: update to set in authentication middleware or similar, and remove from procedure properties
|
||||
internal string? AddedWho { get; private set; } = "ReC.API";
|
||||
@@ -76,12 +76,19 @@ public class InsertObjectProcedureHandler(IRepository repo, IOptionsMonitor<SqlE
|
||||
.Add("pPROFILE_LOG_LEVEL_ID", request.Profile?.LogLevelId, SqlDbType.TinyInt)
|
||||
.Add("pPROFILE_LANGUAGE_ID", request.Profile?.LanguageId, SqlDbType.SmallInt)
|
||||
.Add("pRESULT_ACTION_ID", request.Result?.ActionId)
|
||||
.Add("pRESULT_STATUS_ID", request.Result?.Status, SqlDbType.SmallInt)
|
||||
.Add("pRESULT_STATUS_ID", request.Result?.Status, SqlDbType.TinyInt)
|
||||
.Add("pRESULT_TYPE_ID", request.Result?.Type is not null ? (byte)request.Result.Type : null, SqlDbType.TinyInt)
|
||||
.Add("pRESULT_HEADER", request.Result?.Header)
|
||||
.Add("pRESULT_BODY", request.Result?.Body)
|
||||
.Add("pRESULT_INFO", request.Result?.Info)
|
||||
.Add("pRESULT_INFO_ID", request.Result?.Info, SqlDbType.SmallInt)
|
||||
.Add("pRESULT_INFO_DETAIL", request.Result?.InfoDetail)
|
||||
.Add("pRESULT_ERROR", request.Result?.Error)
|
||||
.Add("pRESULT_BATCH_ID", request.Result?.References?.BatchId)
|
||||
.Add("pRESULT_REFERENCE1", request.Result?.References?.Reference1)
|
||||
.Add("pRESULT_REFERENCE2", request.Result?.References?.Reference2)
|
||||
.Add("pRESULT_REFERENCE3", request.Result?.References?.Reference3)
|
||||
.Add("pRESULT_REFERENCE4", request.Result?.References?.Reference4)
|
||||
.Add("pRESULT_REFERENCE5", request.Result?.References?.Reference5)
|
||||
.Add("pENDPOINT_PARAMS_ACTIVE", request.EndpointParams?.Active)
|
||||
.Add("pENDPOINT_PARAMS_DESCRIPTION", request.EndpointParams?.Description)
|
||||
.Add("pENDPOINT_PARAMS_GROUP_ID", request.EndpointParams?.GroupId, SqlDbType.SmallInt)
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
using Microsoft.Data.SqlClient;
|
||||
using Microsoft.EntityFrameworkCore.Metadata.Internal;
|
||||
using System.Data;
|
||||
using System.Text;
|
||||
|
||||
@@ -19,6 +20,9 @@ internal sealed class StoredProcedureBuilder(string procedureName, string? retur
|
||||
_execSql.AppendLine($"{_separator}@{name} = @{name}");
|
||||
_separator = ',';
|
||||
|
||||
if (!dbType.HasValue && value is DateTime)
|
||||
dbType = SqlDbType.DateTime;
|
||||
|
||||
if (dbType.HasValue)
|
||||
_parameters.Add(new SqlParameter($"@{name}", dbType.Value) { Value = value });
|
||||
else
|
||||
@@ -27,6 +31,22 @@ internal sealed class StoredProcedureBuilder(string procedureName, string? retur
|
||||
return this;
|
||||
}
|
||||
|
||||
public StoredProcedureBuilder Add(string name, EntityType entityType)
|
||||
{
|
||||
var entityTypeStr = entityType switch
|
||||
{
|
||||
EntityType.Action => "ACTION",
|
||||
EntityType.Endpoint => "ENDPOINT",
|
||||
EntityType.EndpointAuth => "ENDPOINT_AUTH",
|
||||
EntityType.EndpointParams => "ENDPOINT_PARAMS",
|
||||
EntityType.Profile => "PROFILE",
|
||||
EntityType.Result => "RESULT",
|
||||
_ => throw new ArgumentOutOfRangeException(nameof(entityType), $"Not expected entity type value: {entityType}")
|
||||
};
|
||||
|
||||
return Add(name, entityTypeStr);
|
||||
}
|
||||
|
||||
public StoredProcedureBuilder AddOutput(string name, SqlDbType dbType)
|
||||
{
|
||||
_execSql.AppendLine($"{_separator}@{name} = @{name} OUTPUT");
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
namespace ReC.Application.Common.Procedures.UpdateProcedure.Dto;
|
||||
using ReC.Application.RecActions.Commands;
|
||||
|
||||
namespace ReC.Application.Common.Procedures.UpdateProcedure.Dto;
|
||||
|
||||
public record UpdateResultDto
|
||||
{
|
||||
@@ -6,4 +8,8 @@ public record UpdateResultDto
|
||||
public short? StatusId { get; set; }
|
||||
public string? Header { get; set; }
|
||||
public string? Body { get; set; }
|
||||
public short? Info { get; set; }
|
||||
public string? InfoDetail { get; set; }
|
||||
public string? Error { get; set; }
|
||||
public InvokeReferences? References { get; set; }
|
||||
}
|
||||
@@ -13,9 +13,9 @@ namespace ReC.Application.Common.Procedures.UpdateProcedure;
|
||||
public record UpdateObjectProcedure : IRequest<int>
|
||||
{
|
||||
/// <summary>
|
||||
/// Target entity: ACTION, ENDPOINT, ENDPOINT_AUTH, ENDPOINT_PARAMS, PROFILE, RESULT
|
||||
/// Target entity for the update operation.
|
||||
/// </summary>
|
||||
public string Entity { get; set; } = null!;
|
||||
public required EntityType Entity { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Target GUID to update (required)
|
||||
@@ -86,9 +86,18 @@ public class UpdateObjectProcedureHandler(IRepository repo, IOptionsMonitor<SqlE
|
||||
.Add("pPROFILE_LAST_RUN", request.Profile.LastRun)
|
||||
.Add("pPROFILE_LAST_RESULT", request.Profile.LastResult)
|
||||
.Add("pRESULT_ACTION_ID", request.Result.ActionId)
|
||||
.Add("pRESULT_STATUS_ID", request.Result.StatusId, SqlDbType.SmallInt)
|
||||
.Add("pRESULT_STATUS_ID", request.Result.StatusId, SqlDbType.TinyInt)
|
||||
.Add("pRESULT_HEADER", request.Result.Header)
|
||||
.Add("pRESULT_BODY", request.Result.Body);
|
||||
.Add("pRESULT_BODY", request.Result.Body)
|
||||
.Add("pRESULT_INFO_ID", request.Result.Info, SqlDbType.SmallInt)
|
||||
.Add("pRESULT_INFO_DETAIL", request.Result.InfoDetail)
|
||||
.Add("pRESULT_ERROR", request.Result.Error)
|
||||
.Add("pRESULT_BATCH_ID", request.Result.References?.BatchId)
|
||||
.Add("pRESULT_REFERENCE1", request.Result.References?.Reference1)
|
||||
.Add("pRESULT_REFERENCE2", request.Result.References?.Reference2)
|
||||
.Add("pRESULT_REFERENCE3", request.Result.References?.Reference3)
|
||||
.Add("pRESULT_REFERENCE4", request.Result.References?.Reference4)
|
||||
.Add("pRESULT_REFERENCE5", request.Result.References?.Reference5);
|
||||
|
||||
try
|
||||
{
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
using FluentValidation;
|
||||
using ReC.Application.Common.Procedures.DeleteProcedure;
|
||||
|
||||
namespace ReC.Application.Common.Validations;
|
||||
|
||||
public class DeleteObjectProcedureValidator : AbstractValidator<DeleteObjectProcedure>
|
||||
{
|
||||
public DeleteObjectProcedureValidator()
|
||||
{
|
||||
RuleFor(x => x.Entity)
|
||||
.IsInEnum()
|
||||
.WithMessage("ENTITY must be a valid EntityType value.");
|
||||
|
||||
RuleFor(x => x.Start)
|
||||
.GreaterThan(0)
|
||||
.WithMessage("Start GUID/ID must be greater than 0.");
|
||||
|
||||
RuleFor(x => x.End)
|
||||
.GreaterThanOrEqualTo(x => x.Start)
|
||||
.WithMessage("End GUID/ID must be greater than or equal to Start GUID/ID.");
|
||||
}
|
||||
}
|
||||
@@ -7,11 +7,9 @@ public class InsertObjectProcedureValidator : AbstractValidator<InsertObjectProc
|
||||
{
|
||||
public InsertObjectProcedureValidator()
|
||||
{
|
||||
// ENTITY must be one of the allowed values
|
||||
RuleFor(x => x.Entity)
|
||||
.NotEmpty()
|
||||
.Must(e => e is "ACTION" or "ENDPOINT" or "ENDPOINT_AUTH" or "ENDPOINT_PARAMS" or "PROFILE" or "RESULT")
|
||||
.WithMessage("ENTITY must be one of: ACTION, ENDPOINT, ENDPOINT_AUTH, ENDPOINT_PARAMS, PROFILE, RESULT.");
|
||||
.IsInEnum()
|
||||
.WithMessage("ENTITY must be a valid EntityType value.");
|
||||
|
||||
// ACTION validation
|
||||
When(x => x.Action != null, () =>
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
using FluentValidation;
|
||||
using ReC.Application.Results.Commands;
|
||||
|
||||
namespace ReC.Application.Common.Validations;
|
||||
|
||||
public class InsertResultCommandValidator : AbstractValidator<InsertResultCommand>
|
||||
{
|
||||
public InsertResultCommandValidator()
|
||||
{
|
||||
RuleFor(x => x.ActionId)
|
||||
.NotNull()
|
||||
.WithMessage("ActionId is required.")
|
||||
.GreaterThan(0L)
|
||||
.When(x => x.ActionId.HasValue)
|
||||
.WithMessage("ActionId must be greater than 0.");
|
||||
|
||||
RuleFor(x => x.References.BatchId)
|
||||
.NotEmpty()
|
||||
.WithMessage("BatchId is required.");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
using FluentValidation;
|
||||
using MediatR;
|
||||
using ReC.Application.RecActions.Commands;
|
||||
using ReC.Application.Results.Queries;
|
||||
|
||||
namespace ReC.Application.Common.Validations;
|
||||
|
||||
public class InvokeBatchRecActionViewsCommandValidator : AbstractValidator<InvokeBatchRecActionViewsCommand>
|
||||
{
|
||||
public InvokeBatchRecActionViewsCommandValidator(ISender sender)
|
||||
{
|
||||
RuleFor(x => x.References.BatchId)
|
||||
.NotEmpty()
|
||||
.WithMessage("BatchId is required.")
|
||||
.MustAsync(async (batchId, cancel) =>
|
||||
{
|
||||
var any = await sender.Send(new AnyResultViewQuery(BatchId: batchId), cancel);
|
||||
return !any;
|
||||
})
|
||||
.WithMessage(x => $"Cannot invoke rec actions for batch '{x.References.BatchId}' because there are already results associated with it.");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
using FluentValidation;
|
||||
using ReC.Application.Profile.Queries;
|
||||
|
||||
namespace ReC.Application.Common.Validations;
|
||||
|
||||
public class ReadProfileViewQueryValidator : AbstractValidator<ReadProfileViewQuery>
|
||||
{
|
||||
public ReadProfileViewQueryValidator()
|
||||
{
|
||||
RuleFor(x => x.Id)
|
||||
.GreaterThan(0)
|
||||
.When(x => x.Id.HasValue)
|
||||
.WithMessage("Id must be greater than 0.");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
using FluentValidation;
|
||||
using ReC.Application.RecActions.Queries;
|
||||
|
||||
namespace ReC.Application.Common.Validations;
|
||||
|
||||
public class ReadRecActionViewQueryValidator : AbstractValidator<ReadRecActionViewQuery>
|
||||
{
|
||||
public ReadRecActionViewQueryValidator()
|
||||
{
|
||||
RuleFor(x => x.ProfileId)
|
||||
.GreaterThan(0)
|
||||
.When(x => x.ProfileId.HasValue)
|
||||
.WithMessage("ProfileId must be greater than 0.");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
using FluentValidation;
|
||||
using ReC.Application.Results.Queries;
|
||||
|
||||
namespace ReC.Application.Common.Validations;
|
||||
|
||||
public class ReadResultViewQueryValidator : AbstractValidator<ReadResultViewQuery>
|
||||
{
|
||||
public ReadResultViewQueryValidator()
|
||||
{
|
||||
RuleFor(x => x)
|
||||
.Must(x => x.Id.HasValue || x.ActionId.HasValue || x.ProfileId.HasValue || x.BatchId is not null)
|
||||
.WithMessage("At least one filter (Id, ActionId, ProfileId or BatchId) must be provided.");
|
||||
|
||||
RuleFor(x => x.Id)
|
||||
.GreaterThan(0)
|
||||
.When(x => x.Id.HasValue)
|
||||
.WithMessage("Id must be greater than 0.");
|
||||
|
||||
RuleFor(x => x.ActionId)
|
||||
.GreaterThan(0)
|
||||
.When(x => x.ActionId.HasValue)
|
||||
.WithMessage("ActionId must be greater than 0.");
|
||||
|
||||
RuleFor(x => x.ProfileId)
|
||||
.GreaterThan(0)
|
||||
.When(x => x.ProfileId.HasValue)
|
||||
.WithMessage("ProfileId must be greater than 0.");
|
||||
|
||||
RuleFor(x => x.BatchId)
|
||||
.NotEmpty()
|
||||
.When(x => x.BatchId is not null)
|
||||
.WithMessage("BatchId must not be empty when provided.");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
using FluentValidation;
|
||||
using ReC.Application.Common.Procedures.UpdateProcedure;
|
||||
|
||||
namespace ReC.Application.Common.Validations;
|
||||
|
||||
public class UpdateObjectProcedureValidator : AbstractValidator<UpdateObjectProcedure>
|
||||
{
|
||||
public UpdateObjectProcedureValidator()
|
||||
{
|
||||
RuleFor(x => x.Entity)
|
||||
.IsInEnum()
|
||||
.WithMessage("ENTITY must be a valid EntityType value.");
|
||||
|
||||
RuleFor(x => x.Id)
|
||||
.GreaterThan(0)
|
||||
.WithMessage("Target GUID/ID must be greater than 0.");
|
||||
|
||||
RuleFor(x => x.ChangedWho)
|
||||
.MaximumLength(50)
|
||||
.When(x => x.ChangedWho != null);
|
||||
|
||||
When(x => x.Endpoint is { Description: not null }, () =>
|
||||
{
|
||||
RuleFor(x => x.Endpoint.Description)
|
||||
.MaximumLength(250);
|
||||
});
|
||||
|
||||
When(x => x.EndpointAuth is { Description: not null }, () =>
|
||||
{
|
||||
RuleFor(x => x.EndpointAuth.Description)
|
||||
.MaximumLength(250);
|
||||
});
|
||||
|
||||
When(x => x.Profile is { Name: not null }, () =>
|
||||
{
|
||||
RuleFor(x => x.Profile.Name)
|
||||
.MaximumLength(50);
|
||||
});
|
||||
|
||||
When(x => x.Profile is { Mandantor: not null }, () =>
|
||||
{
|
||||
RuleFor(x => x.Profile.Mandantor)
|
||||
.MaximumLength(50);
|
||||
});
|
||||
|
||||
When(x => x.Profile is { Description: not null }, () =>
|
||||
{
|
||||
RuleFor(x => x.Profile.Description)
|
||||
.MaximumLength(250);
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
using MediatR;
|
||||
using ReC.Application.Common.Procedures.DeleteProcedure;
|
||||
using ReC.Application.Common.Procedures;
|
||||
|
||||
namespace ReC.Application.EndpointAuth.Commands;
|
||||
|
||||
@@ -27,7 +28,7 @@ public class DeleteEndpointAuthProcedureHandler(ISender sender) : IRequestHandle
|
||||
{
|
||||
return await sender.Send(new DeleteObjectProcedure
|
||||
{
|
||||
Entity = "ENDPOINT_AUTH",
|
||||
Entity = EntityType.EndpointAuth,
|
||||
Start = request.Start,
|
||||
End = request.End,
|
||||
Force = request.Force
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
using MediatR;
|
||||
using MediatR;
|
||||
using ReC.Application.Common.Procedures.InsertProcedure;
|
||||
using ReC.Application.Common.Procedures;
|
||||
|
||||
namespace ReC.Application.EndpointAuth.Commands;
|
||||
|
||||
@@ -24,7 +25,7 @@ public class InsertEndpointAuthProcedureHandler(ISender sender) : IRequestHandle
|
||||
{
|
||||
return await sender.Send(new InsertObjectProcedure
|
||||
{
|
||||
Entity = "ENDPOINT_AUTH",
|
||||
Entity = EntityType.EndpointAuth,
|
||||
EndpointAuth = request
|
||||
}, cancel);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
using MediatR;
|
||||
using ReC.Application.Common.Procedures.UpdateProcedure;
|
||||
using ReC.Application.Common.Procedures.UpdateProcedure.Dto;
|
||||
using ReC.Application.Common.Procedures;
|
||||
|
||||
namespace ReC.Application.EndpointAuth.Commands;
|
||||
|
||||
@@ -17,7 +18,7 @@ public class UpdateEndpointAuthProcedureHandler(ISender sender) : IRequestHandle
|
||||
{
|
||||
return await sender.Send(new UpdateObjectProcedure
|
||||
{
|
||||
Entity = "ENDPOINT_AUTH",
|
||||
Entity = EntityType.EndpointAuth,
|
||||
Id = request.Id,
|
||||
EndpointAuth = request.Data
|
||||
}, cancel);
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
using MediatR;
|
||||
using ReC.Application.Common.Procedures.DeleteProcedure;
|
||||
using ReC.Application.Common.Procedures;
|
||||
|
||||
namespace ReC.Application.EndpointParams.Commands;
|
||||
|
||||
@@ -27,7 +28,7 @@ public class DeleteEndpointParamsProcedureHandler(ISender sender) : IRequestHand
|
||||
{
|
||||
return await sender.Send(new DeleteObjectProcedure
|
||||
{
|
||||
Entity = "ENDPOINT_PARAMS",
|
||||
Entity = EntityType.EndpointParams,
|
||||
Start = request.Start,
|
||||
End = request.End,
|
||||
Force = request.Force
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
using MediatR;
|
||||
using MediatR;
|
||||
using ReC.Application.Common.Procedures.InsertProcedure;
|
||||
using ReC.Application.Common.Procedures;
|
||||
|
||||
namespace ReC.Application.EndpointParams.Commands;
|
||||
|
||||
@@ -19,7 +20,7 @@ public class InsertEndpointParamsProcedureHandler(ISender sender) : IRequestHand
|
||||
{
|
||||
return await sender.Send(new InsertObjectProcedure
|
||||
{
|
||||
Entity = "ENDPOINT_PARAMS",
|
||||
Entity = EntityType.EndpointParams,
|
||||
EndpointParams = request
|
||||
}, cancel);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
using MediatR;
|
||||
using ReC.Application.Common.Procedures.UpdateProcedure;
|
||||
using ReC.Application.Common.Procedures.UpdateProcedure.Dto;
|
||||
using ReC.Application.Common.Procedures;
|
||||
|
||||
namespace ReC.Application.EndpointParams.Commands;
|
||||
|
||||
@@ -17,7 +18,7 @@ public class UpdateEndpointParamsProcedureHandler(ISender sender) : IRequestHand
|
||||
{
|
||||
return await sender.Send(new UpdateObjectProcedure
|
||||
{
|
||||
Entity = "ENDPOINT_PARAMS",
|
||||
Entity = EntityType.EndpointParams,
|
||||
Id = request.Id,
|
||||
EndpointParams = request.Data
|
||||
}, cancel);
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
using MediatR;
|
||||
using ReC.Application.Common.Procedures.DeleteProcedure;
|
||||
using ReC.Application.Common.Procedures;
|
||||
|
||||
namespace ReC.Application.Endpoints.Commands;
|
||||
|
||||
@@ -27,7 +28,7 @@ public class DeleteEndpointProcedureHandler(ISender sender) : IRequestHandler<De
|
||||
{
|
||||
return await sender.Send(new DeleteObjectProcedure
|
||||
{
|
||||
Entity = "ENDPOINT",
|
||||
Entity = EntityType.Endpoint,
|
||||
Start = request.Start,
|
||||
End = request.End,
|
||||
Force = request.Force
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
using MediatR;
|
||||
using MediatR;
|
||||
using ReC.Application.Common.Procedures.InsertProcedure;
|
||||
using ReC.Application.Common.Procedures;
|
||||
|
||||
namespace ReC.Application.Endpoints.Commands;
|
||||
|
||||
@@ -16,7 +17,7 @@ public class InsertEndpointProcedureHandler(ISender sender) : IRequestHandler<In
|
||||
{
|
||||
return await sender.Send(new InsertObjectProcedure
|
||||
{
|
||||
Entity = "ENDPOINT",
|
||||
Entity = EntityType.Endpoint,
|
||||
Endpoint = request
|
||||
}, cancel);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
using MediatR;
|
||||
using ReC.Application.Common.Procedures.UpdateProcedure;
|
||||
using ReC.Application.Common.Procedures.UpdateProcedure.Dto;
|
||||
using ReC.Application.Common.Procedures;
|
||||
|
||||
namespace ReC.Application.Endpoints.Commands;
|
||||
|
||||
@@ -17,7 +18,7 @@ public class UpdateEndpointProcedureHandler(ISender sender) : IRequestHandler<Up
|
||||
{
|
||||
return await sender.Send(new UpdateObjectProcedure
|
||||
{
|
||||
Entity = "ENDPOINT",
|
||||
Entity = EntityType.Endpoint,
|
||||
Id = request.Id,
|
||||
Endpoint = request.Data
|
||||
}, cancel);
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
using MediatR;
|
||||
using ReC.Application.Common.Procedures.DeleteProcedure;
|
||||
using ReC.Application.Common.Procedures;
|
||||
|
||||
namespace ReC.Application.Profile.Commands;
|
||||
|
||||
@@ -27,7 +28,7 @@ public class DeleteProfileProcedureHandler(ISender sender) : IRequestHandler<Del
|
||||
{
|
||||
return await sender.Send(new DeleteObjectProcedure
|
||||
{
|
||||
Entity = "PROFILE",
|
||||
Entity = EntityType.Profile,
|
||||
Start = request.Start,
|
||||
End = request.End,
|
||||
Force = request.Force
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
using MediatR;
|
||||
using MediatR;
|
||||
using ReC.Application.Common.Procedures.InsertProcedure;
|
||||
using ReC.Application.Common.Procedures;
|
||||
|
||||
namespace ReC.Application.Profile.Commands;
|
||||
|
||||
@@ -20,7 +21,7 @@ public class InsertProfileProcedureHandler(ISender sender) : IRequestHandler<Ins
|
||||
{
|
||||
return await sender.Send(new InsertObjectProcedure
|
||||
{
|
||||
Entity = "PROFILE",
|
||||
Entity = EntityType.Profile,
|
||||
Profile = request
|
||||
}, cancel);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
using MediatR;
|
||||
using ReC.Application.Common.Procedures.UpdateProcedure;
|
||||
using ReC.Application.Common.Procedures.UpdateProcedure.Dto;
|
||||
using ReC.Application.Common.Procedures;
|
||||
|
||||
namespace ReC.Application.Profile.Commands;
|
||||
|
||||
@@ -17,7 +18,7 @@ public class UpdateProfileProcedureHandler(ISender sender) : IRequestHandler<Upd
|
||||
{
|
||||
return await sender.Send(new UpdateObjectProcedure
|
||||
{
|
||||
Entity = "PROFILE",
|
||||
Entity = EntityType.Profile,
|
||||
Id = request.Id,
|
||||
Profile = request.Data
|
||||
}, cancel);
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="AutoMapper" Version="15.1.0" />
|
||||
<PackageReference Include="AutoMapper" Version="16.1.1" />
|
||||
<PackageReference Include="DigitalData.Core.Abstraction.Application" Version="1.6.0" />
|
||||
<PackageReference Include="DigitalData.Core.Application" Version="3.4.0" />
|
||||
<PackageReference Include="DigitalData.Core.Exceptions" Version="1.1.1" />
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
using MediatR;
|
||||
using ReC.Application.Common.Procedures.DeleteProcedure;
|
||||
using ReC.Application.Common.Procedures;
|
||||
|
||||
namespace ReC.Application.RecActions.Commands;
|
||||
|
||||
@@ -27,7 +28,7 @@ public class DeleteActionProcedureHandler(ISender sender) : IRequestHandler<Dele
|
||||
{
|
||||
return await sender.Send(new DeleteObjectProcedure
|
||||
{
|
||||
Entity = "ACTION",
|
||||
Entity = EntityType.Action,
|
||||
Start = request.Start,
|
||||
End = request.End,
|
||||
Force = request.Force
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
using MediatR;
|
||||
using MediatR;
|
||||
using ReC.Application.Common.Procedures.InsertProcedure;
|
||||
using ReC.Domain.Constants;
|
||||
using ReC.Application.Common.Procedures;
|
||||
|
||||
namespace ReC.Application.RecActions.Commands;
|
||||
|
||||
@@ -27,7 +28,7 @@ public class InsertActionProcedureHandler(ISender sender) : IRequestHandler<Inse
|
||||
{
|
||||
return await sender.Send(new InsertObjectProcedure
|
||||
{
|
||||
Entity = "ACTION",
|
||||
Entity = EntityType.Action,
|
||||
Action = request
|
||||
}, cancel);
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@ namespace ReC.Application.RecActions.Commands;
|
||||
public record InvokeBatchRecActionViewsCommand : IRequest
|
||||
{
|
||||
public long ProfileId { get; init; }
|
||||
public required InvokeReferences References { get; init; }
|
||||
}
|
||||
|
||||
public class InvokeRecActionViewsCommandHandler(ISender sender, ILogger<InvokeRecActionViewsCommandHandler>? logger = null) : IRequestHandler<InvokeBatchRecActionViewsCommand>
|
||||
@@ -21,7 +22,11 @@ public class InvokeRecActionViewsCommandHandler(ISender sender, ILogger<InvokeRe
|
||||
{
|
||||
try
|
||||
{
|
||||
await sender.Send(new InvokeRecActionViewCommand() { Action = action }, cancel);
|
||||
await sender.Send(new InvokeRecActionViewCommand()
|
||||
{
|
||||
Action = action,
|
||||
References = request.References
|
||||
}, cancel);
|
||||
}
|
||||
catch (RecActionException ex)
|
||||
{
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
using MediatR;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
using ReC.Application.Common.Constants;
|
||||
using ReC.Application.Common.Dto;
|
||||
@@ -7,6 +8,7 @@ using ReC.Application.Common.Exceptions;
|
||||
using ReC.Application.Common.Options;
|
||||
using ReC.Application.Results.Commands;
|
||||
using ReC.Domain.Constants;
|
||||
using ReC.Domain.Views;
|
||||
using System.Net;
|
||||
using System.Net.Http.Headers;
|
||||
using System.Text;
|
||||
@@ -17,13 +19,25 @@ namespace ReC.Application.RecActions.Commands;
|
||||
public record InvokeRecActionViewCommand : IRequest
|
||||
{
|
||||
public RecActionViewDto Action { get; set; } = null!;
|
||||
public required InvokeReferences References { get; set; }
|
||||
}
|
||||
|
||||
public record InvokeReferences
|
||||
{
|
||||
public required string BatchId { get; init; }
|
||||
public string? Reference1 { get; init; }
|
||||
public string? Reference2 { get; init; }
|
||||
public string? Reference3 { get; init; }
|
||||
public string? Reference4 { get; init; }
|
||||
public string? Reference5 { get; init; }
|
||||
}
|
||||
|
||||
public class InvokeRecActionViewCommandHandler(
|
||||
IOptions<RecActionOptions> options,
|
||||
ISender sender,
|
||||
IHttpClientFactory clientFactory,
|
||||
IConfiguration? config = null
|
||||
IConfiguration? config = null,
|
||||
ILogger<InvokeRecActionViewCommandHandler>? logger = null
|
||||
) : IRequestHandler<InvokeRecActionViewCommand>
|
||||
{
|
||||
private readonly RecActionOptions _options = options.Value;
|
||||
@@ -45,11 +59,47 @@ public class InvokeRecActionViewCommandHandler(
|
||||
using var httpReq = CreateHttpRequestMessage(restType, action.EndpointUri);
|
||||
|
||||
if (action.Body is not null)
|
||||
{
|
||||
httpReq.Content = new StringContent(action.Body);
|
||||
|
||||
var contentType = action.Headers?.FirstOrDefault(h => h.Key.Equals("Content-Type", StringComparison.OrdinalIgnoreCase));
|
||||
if (contentType is not null && !string.IsNullOrWhiteSpace(contentType.Value.Value))
|
||||
try { httpReq.Content.Headers.ContentType = MediaTypeHeaderValue.Parse(contentType.Value.Value); }
|
||||
catch (FormatException ex)
|
||||
{
|
||||
logger?.LogWarning(ex, "Content-Type '{Value}' could not be parsed with strict validation, falling back to TryAddWithoutValidation. ActionId: {ActionId}, ProfileId: {ProfileId}", contentType.Value.Value, action.Id, action.ProfileId);
|
||||
httpReq.Content.Headers.TryAddWithoutValidation("Content-Type", contentType.Value.Value);
|
||||
}
|
||||
else if (_options.AutoDetectHeaders)
|
||||
{
|
||||
var body = action.Body.TrimStart();
|
||||
if (body.StartsWith('{') || body.StartsWith('['))
|
||||
{
|
||||
httpReq.Content.Headers.ContentType = new MediaTypeHeaderValue("application/json") { CharSet = "utf-8" };
|
||||
logger?.LogWarning("Content-Type header was not specified. Auto-detected 'application/json; charset=utf-8' based on body content. ActionId: {ActionId}, ProfileId: {ProfileId}", action.Id, action.ProfileId);
|
||||
}
|
||||
else if (body.StartsWith('<'))
|
||||
{
|
||||
httpReq.Content.Headers.ContentType = new MediaTypeHeaderValue("application/xml") { CharSet = "utf-8" };
|
||||
logger?.LogWarning("Content-Type header was not specified. Auto-detected 'application/xml; charset=utf-8' based on body content. ActionId: {ActionId}, ProfileId: {ProfileId}", action.Id, action.ProfileId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (action.Headers is not null)
|
||||
foreach (var header in action.Headers)
|
||||
httpReq.Headers.Add(header.Key, header.Value);
|
||||
foreach (var header in action.Headers.Where(h => !h.Key.Equals("Content-Type", StringComparison.OrdinalIgnoreCase)))
|
||||
try { httpReq.Headers.Add(header.Key, header.Value); }
|
||||
catch (FormatException ex)
|
||||
{
|
||||
logger?.LogWarning(ex, "Header '{Key}' could not be added with strict validation, falling back to TryAddWithoutValidation. ActionId: {ActionId}, ProfileId: {ProfileId}", header.Key, action.Id, action.ProfileId);
|
||||
httpReq.Headers.TryAddWithoutValidation(header.Key, header.Value);
|
||||
}
|
||||
|
||||
if (_options.AutoDetectHeaders && !httpReq.Headers.Contains("Accept"))
|
||||
{
|
||||
httpReq.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json"));
|
||||
logger?.LogWarning("Accept header was not specified. Defaulting to 'application/json'. ActionId: {ActionId}, ProfileId: {ProfileId}", action.Id, action.ProfileId);
|
||||
}
|
||||
|
||||
switch (action.EndpointAuthType)
|
||||
{
|
||||
@@ -62,7 +112,12 @@ public class InvokeRecActionViewCommandHandler(
|
||||
switch (action.EndpointAuthApiKeyAddTo)
|
||||
{
|
||||
case ApiKeyLocation.Header:
|
||||
httpReq.Headers.Add(apiKey, apiValue);
|
||||
try { httpReq.Headers.Add(apiKey, apiValue); }
|
||||
catch (FormatException ex)
|
||||
{
|
||||
logger?.LogWarning(ex, "ApiKey header '{Key}' could not be added with strict validation, falling back to TryAddWithoutValidation. ActionId: {ActionId}, ProfileId: {ProfileId}", apiKey, action.Id, action.ProfileId);
|
||||
httpReq.Headers.TryAddWithoutValidation(apiKey, apiValue);
|
||||
}
|
||||
break;
|
||||
case ApiKeyLocation.Query:
|
||||
var uriBuilder = new UriBuilder(httpReq.RequestUri!);
|
||||
@@ -85,14 +140,24 @@ public class InvokeRecActionViewCommandHandler(
|
||||
case EndpointAuthType.JwtBearer:
|
||||
case EndpointAuthType.OAuth2:
|
||||
if (action.EndpointAuthToken is string authToken)
|
||||
httpReq.Headers.Authorization = new AuthenticationHeaderValue("Bearer", authToken);
|
||||
try { httpReq.Headers.Authorization = new AuthenticationHeaderValue("Bearer", authToken); }
|
||||
catch (FormatException ex)
|
||||
{
|
||||
logger?.LogWarning(ex, "Bearer token could not be set with strict validation, falling back to TryAddWithoutValidation. ActionId: {ActionId}, ProfileId: {ProfileId}", action.Id, action.ProfileId);
|
||||
httpReq.Headers.TryAddWithoutValidation("Authorization", $"Bearer {authToken}");
|
||||
}
|
||||
break;
|
||||
|
||||
case EndpointAuthType.BasicAuth:
|
||||
if (action.EndpointAuthUsername is string authUsername && action.EndpointAuthPassword is string authPassword)
|
||||
{
|
||||
var basicAuth = Convert.ToBase64String(Encoding.UTF8.GetBytes($"{authUsername}:{authPassword}"));
|
||||
httpReq.Headers.Authorization = new AuthenticationHeaderValue("Basic", basicAuth);
|
||||
try { httpReq.Headers.Authorization = new AuthenticationHeaderValue("Basic", basicAuth); }
|
||||
catch (FormatException ex)
|
||||
{
|
||||
logger?.LogWarning(ex, "Basic auth could not be set with strict validation, falling back to TryAddWithoutValidation. ActionId: {ActionId}, ProfileId: {ProfileId}", action.Id, action.ProfileId);
|
||||
httpReq.Headers.TryAddWithoutValidation("Authorization", $"Basic {basicAuth}");
|
||||
}
|
||||
}
|
||||
break;
|
||||
|
||||
@@ -142,25 +207,26 @@ public class InvokeRecActionViewCommandHandler(
|
||||
var resBody = await response.Content.ReadAsStringAsync(cancel);
|
||||
var resHeaders = response.Headers.ToDictionary();
|
||||
|
||||
var statusCode = (short)response.StatusCode;
|
||||
|
||||
await sender.Send(new InsertResultCommand()
|
||||
{
|
||||
Status = response.StatusCode.ToRecStatus(),
|
||||
ActionId = action.Id,
|
||||
Header = JsonSerializer.Serialize(resHeaders, options: new() { WriteIndented = false }),
|
||||
Body = resBody,
|
||||
Type = ResultType.Main
|
||||
Info = (short)response.StatusCode,
|
||||
Type = ResultType.Main,
|
||||
References = request.References
|
||||
}, cancel);
|
||||
}
|
||||
catch(Exception ex)
|
||||
{
|
||||
await sender.Send(new InsertResultCommand()
|
||||
{
|
||||
Status = RecStatus.Failed,
|
||||
Status = RecStatus.Error,
|
||||
ActionId = action.Id,
|
||||
Error = ex.ToString(),
|
||||
Type = ResultType.Main
|
||||
Type = ResultType.Main,
|
||||
References = request.References
|
||||
}, cancel);
|
||||
|
||||
if (action.ErrorAction == ErrorAction.Stop)
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
using MediatR;
|
||||
using ReC.Application.Common.Procedures.UpdateProcedure;
|
||||
using ReC.Application.Common.Procedures.UpdateProcedure.Dto;
|
||||
using ReC.Application.Common.Procedures;
|
||||
|
||||
namespace ReC.Application.RecActions.Commands;
|
||||
|
||||
@@ -17,7 +18,7 @@ public class UpdateActionProcedureHandler(ISender sender) : IRequestHandler<Upda
|
||||
{
|
||||
return await sender.Send(new UpdateObjectProcedure
|
||||
{
|
||||
Entity = "ACTION",
|
||||
Entity = EntityType.Action,
|
||||
Id = request.Id,
|
||||
Action = request.Data
|
||||
}, cancel);
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
using MediatR;
|
||||
using ReC.Application.Common.Procedures.DeleteProcedure;
|
||||
using ReC.Application.Common.Procedures;
|
||||
|
||||
namespace ReC.Application.Results.Commands;
|
||||
|
||||
@@ -27,7 +28,7 @@ public class DeleteResultProcedureHandler(ISender sender) : IRequestHandler<Dele
|
||||
{
|
||||
return await sender.Send(new DeleteObjectProcedure
|
||||
{
|
||||
Entity = "RESULT",
|
||||
Entity = EntityType.Result,
|
||||
Start = request.Start,
|
||||
End = request.End,
|
||||
Force = request.Force
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
using MediatR;
|
||||
using MediatR;
|
||||
using ReC.Application.Common.Procedures.InsertProcedure;
|
||||
using ReC.Application.RecActions.Commands;
|
||||
using ReC.Domain.Constants;
|
||||
using ReC.Application.Common.Procedures;
|
||||
|
||||
namespace ReC.Application.Results.Commands;
|
||||
|
||||
@@ -10,9 +12,11 @@ public record InsertResultCommand : IInsertProcedure
|
||||
public required RecStatus Status { get; set; }
|
||||
public string? Header { get; set; }
|
||||
public string? Body { get; set; }
|
||||
public string? Info { get; set; }
|
||||
public short Info { get; set; }
|
||||
public string? InfoDetail { get; set; }
|
||||
public string? Error { get; set; }
|
||||
public required ResultType Type { get; set; }
|
||||
public required InvokeReferences References { get; set; }
|
||||
}
|
||||
|
||||
public class InsertResultProcedureHandler(ISender sender) : IRequestHandler<InsertResultCommand, long>
|
||||
@@ -21,7 +25,7 @@ public class InsertResultProcedureHandler(ISender sender) : IRequestHandler<Inse
|
||||
{
|
||||
return await sender.Send(new InsertObjectProcedure
|
||||
{
|
||||
Entity = "RESULT",
|
||||
Entity = EntityType.Result,
|
||||
Result = request
|
||||
}, cancel);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
using MediatR;
|
||||
using ReC.Application.Common.Procedures.UpdateProcedure;
|
||||
using ReC.Application.Common.Procedures.UpdateProcedure.Dto;
|
||||
using ReC.Application.Common.Procedures;
|
||||
|
||||
namespace ReC.Application.Results.Commands;
|
||||
|
||||
@@ -17,7 +18,7 @@ public class UpdateResultProcedureHandler(ISender sender) : IRequestHandler<Upda
|
||||
{
|
||||
return await sender.Send(new UpdateObjectProcedure
|
||||
{
|
||||
Entity = "RESULT",
|
||||
Entity = EntityType.Result,
|
||||
Id = request.Id,
|
||||
Result = request.Data
|
||||
}, cancel);
|
||||
|
||||
35
src/ReC.Application/Results/Queries/AnyResultViewQuery.cs
Normal file
35
src/ReC.Application/Results/Queries/AnyResultViewQuery.cs
Normal file
@@ -0,0 +1,35 @@
|
||||
using DigitalData.Core.Abstraction.Application.Repository;
|
||||
using MediatR;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using ReC.Domain.Views;
|
||||
|
||||
namespace ReC.Application.Results.Queries;
|
||||
|
||||
public record AnyResultViewQuery(
|
||||
long? Id = null,
|
||||
long? ActionId = null,
|
||||
long? ProfileId = null,
|
||||
string? BatchId = null
|
||||
) : IRequest<bool>;
|
||||
|
||||
public class AnyResultViewQueryHandler(IRepository<ResultView> repo) : IRequestHandler<AnyResultViewQuery, bool>
|
||||
{
|
||||
public Task<bool> Handle(AnyResultViewQuery request, CancellationToken cancel)
|
||||
{
|
||||
var q = repo.Query;
|
||||
|
||||
if(request.Id is long id)
|
||||
q = q.Where(rv => rv.Id == id);
|
||||
|
||||
if(request.ActionId is long actionId)
|
||||
q = q.Where(rv => rv.ActionId == actionId);
|
||||
|
||||
if(request.ProfileId is long profileId)
|
||||
q = q.Where(rv => rv.ProfileId == profileId);
|
||||
|
||||
if(request.BatchId is string batchId)
|
||||
q = q.Where(rv => rv.BatchId == batchId);
|
||||
|
||||
return q.AnyAsync(cancel);
|
||||
}
|
||||
}
|
||||
@@ -17,11 +17,13 @@ public record ReadResultViewQuery : IRequest<IEnumerable<ResultViewDto>>
|
||||
|
||||
public long? ProfileId { get; init; } = null;
|
||||
|
||||
public string? BatchId { get; init; } = null;
|
||||
|
||||
public bool IncludeAction { get; init; } = true;
|
||||
|
||||
public bool IncludeProfile { get; init; } = false;
|
||||
|
||||
public bool Last { get; init; } = false;
|
||||
public bool LastBatch { get; init; } = false;
|
||||
}
|
||||
|
||||
public class ReadResultViewQueryHandler(IRepository<ResultView> repo, IMapper mapper) : IRequestHandler<ReadResultViewQuery, IEnumerable<ResultViewDto>>
|
||||
@@ -39,13 +41,18 @@ public class ReadResultViewQueryHandler(IRepository<ResultView> repo, IMapper ma
|
||||
if(request.ProfileId is long profileId)
|
||||
q = q.Where(rv => rv.ProfileId == profileId);
|
||||
|
||||
if(request.BatchId is string batchId)
|
||||
q = q.Where(rv => rv.BatchId == batchId);
|
||||
|
||||
if (request.IncludeAction)
|
||||
q = q.Include(rv => rv.Action);
|
||||
|
||||
if(request.IncludeProfile)
|
||||
q = q.Include(rv => rv.Profile);
|
||||
|
||||
var entities = request.Last ? [await q.OrderBy(rv => rv.AddedWhen).LastOrDefaultAsync(cancel)] : await q.ToListAsync(cancel);
|
||||
var entities = request.LastBatch
|
||||
? await GetLastBatchEntitiesAsync(q, cancel)
|
||||
: await q.ToListAsync(cancel);
|
||||
|
||||
if (entities.Count == 0)
|
||||
throw new NotFoundException($"No result views found for the given criteria. Criteria: {
|
||||
@@ -58,4 +65,20 @@ public class ReadResultViewQueryHandler(IRepository<ResultView> repo, IMapper ma
|
||||
|
||||
return mapper.Map<IEnumerable<ResultViewDto>>(entities);
|
||||
}
|
||||
|
||||
private static async Task<List<ResultView>> GetLastBatchEntitiesAsync(IQueryable<ResultView> q, CancellationToken cancel)
|
||||
{
|
||||
var lastBatchId = await q
|
||||
.Where(rv => rv.BatchId != null)
|
||||
.OrderByDescending(rv => rv.AddedWhen)
|
||||
.Select(rv => rv.BatchId)
|
||||
.FirstOrDefaultAsync(cancel);
|
||||
|
||||
if (lastBatchId is null)
|
||||
return [];
|
||||
|
||||
return await q
|
||||
.Where(rv => rv.BatchId == lastBatchId)
|
||||
.ToListAsync(cancel);
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,7 @@ using System;
|
||||
using System.Net.Http;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace ReC.Client.Api
|
||||
{
|
||||
@@ -20,15 +21,37 @@ namespace ReC.Client.Api
|
||||
/// </summary>
|
||||
protected readonly string ResourcePath;
|
||||
|
||||
/// <summary>
|
||||
/// An optional logger used to record API call outcomes. May be <see langword="null"/>.
|
||||
/// </summary>
|
||||
#if NETFRAMEWORK
|
||||
protected readonly ILogger Logger;
|
||||
#else
|
||||
protected readonly ILogger? Logger;
|
||||
#endif
|
||||
|
||||
/// <summary>
|
||||
/// The options controlling client behavior. Never <see langword="null"/>.
|
||||
/// </summary>
|
||||
protected readonly ReCClientOptions Options;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="BaseCrudApi"/> class.
|
||||
/// </summary>
|
||||
/// <param name="http">The HTTP client used for requests.</param>
|
||||
/// <param name="resourcePath">The base resource path for the API endpoint.</param>
|
||||
protected BaseCrudApi(HttpClient http, string resourcePath)
|
||||
/// <param name="logger">An optional logger used to record API call outcomes.</param>
|
||||
/// <param name="options">An optional set of client options. Defaults are used when omitted.</param>
|
||||
#if NETFRAMEWORK
|
||||
protected BaseCrudApi(HttpClient http, string resourcePath, ILogger logger = null, ReCClientOptions options = null)
|
||||
#else
|
||||
protected BaseCrudApi(HttpClient http, string resourcePath, ILogger? logger = null, ReCClientOptions? options = null)
|
||||
#endif
|
||||
{
|
||||
Http = http ?? throw new ArgumentNullException(nameof(http));
|
||||
ResourcePath = resourcePath ?? throw new ArgumentNullException(nameof(resourcePath));
|
||||
Logger = logger;
|
||||
Options = options ?? new ReCClientOptions();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -37,9 +60,15 @@ namespace ReC.Client.Api
|
||||
/// <typeparam name="T">The payload type.</typeparam>
|
||||
/// <param name="payload">The payload to send.</param>
|
||||
/// <param name="cancel">A token to cancel the operation.</param>
|
||||
/// <returns>The HTTP response message.</returns>
|
||||
public Task<HttpResponseMessage> CreateAsync<T>(T payload, CancellationToken cancel = default)
|
||||
=> Http.PostAsync(ResourcePath, ReCClientHelpers.ToJsonContent(payload), cancel);
|
||||
/// <exception cref="ReCApiException">Thrown when the API responds with a non-success status code.</exception>
|
||||
public async Task CreateAsync<T>(T payload, CancellationToken cancel = default)
|
||||
{
|
||||
using (var content = ReCClientHelpers.ToJsonContent(payload))
|
||||
using (var resp = await Http.PostAsync(ResourcePath, content, cancel))
|
||||
{
|
||||
await ReCClientHelpers.HandleResponseAsync(resp, Logger, Options.LogSuccessfulRequests, cancel).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Updates a resource by identifier.
|
||||
@@ -48,24 +77,31 @@ namespace ReC.Client.Api
|
||||
/// <param name="id">The resource identifier.</param>
|
||||
/// <param name="payload">The payload to send.</param>
|
||||
/// <param name="cancel">A token to cancel the operation.</param>
|
||||
/// <returns>The HTTP response message.</returns>
|
||||
public Task<HttpResponseMessage> UpdateAsync<T>(long id, T payload, CancellationToken cancel = default)
|
||||
=> Http.PutAsync($"{ResourcePath}/{id}", ReCClientHelpers.ToJsonContent(payload), cancel);
|
||||
/// <exception cref="ReCApiException">Thrown when the API responds with a non-success status code.</exception>
|
||||
public async Task UpdateAsync<T>(long id, T payload, CancellationToken cancel = default)
|
||||
{
|
||||
using (var content = ReCClientHelpers.ToJsonContent(payload))
|
||||
using (var resp = await Http.PutAsync($"{ResourcePath}/{id}", content, cancel))
|
||||
{
|
||||
await ReCClientHelpers.HandleResponseAsync(resp, Logger, Options.LogSuccessfulRequests, cancel).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Deletes resources with identifiers supplied in the payload.
|
||||
/// Deletes resources with identifiers supplied in the payload. The payload is serialized into
|
||||
/// the query string because the API binds delete payloads from the URL query.
|
||||
/// </summary>
|
||||
/// <typeparam name="T">The payload type containing identifiers.</typeparam>
|
||||
/// <param name="payload">The payload to send.</param>
|
||||
/// <param name="cancel">A token to cancel the operation.</param>
|
||||
/// <returns>The HTTP response message.</returns>
|
||||
public Task<HttpResponseMessage> DeleteAsync<T>(T payload, CancellationToken cancel = default)
|
||||
/// <exception cref="ReCApiException">Thrown when the API responds with a non-success status code.</exception>
|
||||
public async Task DeleteAsync<T>(T payload, CancellationToken cancel = default)
|
||||
{
|
||||
var request = new HttpRequestMessage(HttpMethod.Delete, ResourcePath)
|
||||
var query = ReCClientHelpers.BuildQueryFromObject(payload);
|
||||
using (var resp = await Http.DeleteAsync($"{ResourcePath}{query}", cancel))
|
||||
{
|
||||
Content = ReCClientHelpers.ToJsonContent(payload)
|
||||
};
|
||||
return Http.SendAsync(request, cancel);
|
||||
await ReCClientHelpers.HandleResponseAsync(resp, Logger, Options.LogSuccessfulRequests, cancel).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,11 +1,14 @@
|
||||
using System.Net.Http;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace ReC.Client.Api
|
||||
{
|
||||
/// <summary>
|
||||
/// Provides access to common object endpoints.
|
||||
/// Provides access to common object endpoints. The Common API binds update and delete
|
||||
/// payloads from the body / query string (no id route segment), so the inherited CRUD
|
||||
/// helpers from <see cref="BaseCrudApi"/> are hidden with overloads that match the API.
|
||||
/// </summary>
|
||||
public class CommonApi : BaseCrudApi
|
||||
{
|
||||
@@ -13,8 +16,32 @@ namespace ReC.Client.Api
|
||||
/// Initializes a new instance of the <see cref="CommonApi"/> class.
|
||||
/// </summary>
|
||||
/// <param name="http">The HTTP client used for requests.</param>
|
||||
public CommonApi(HttpClient http) : base(http, "api/Common")
|
||||
/// <param name="logger">An optional logger used to record API call outcomes.</param>
|
||||
/// <param name="options">An optional set of client options. Defaults are used when omitted.</param>
|
||||
#if NETFRAMEWORK
|
||||
public CommonApi(HttpClient http, ILogger logger = null, ReCClientOptions options = null) : base(http, "api/Common", logger, options)
|
||||
#else
|
||||
public CommonApi(HttpClient http, ILogger? logger = null, ReCClientOptions? options = null) : base(http, "api/Common", logger, options)
|
||||
#endif
|
||||
{
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Updates an object via the Common update procedure. The identifier is expected to be
|
||||
/// part of <paramref name="payload"/> rather than the URL.
|
||||
/// </summary>
|
||||
/// <typeparam name="T">The payload type.</typeparam>
|
||||
/// <param name="payload">The payload to send.</param>
|
||||
/// <param name="cancel">A token to cancel the operation.</param>
|
||||
/// <exception cref="ReCApiException">Thrown when the API responds with a non-success status code.</exception>
|
||||
public async Task UpdateAsync<T>(T payload, CancellationToken cancel = default)
|
||||
{
|
||||
using (var content = ReCClientHelpers.ToJsonContent(payload))
|
||||
using (var resp = await Http.PutAsync(ResourcePath, content, cancel))
|
||||
{
|
||||
await ReCClientHelpers.HandleResponseAsync(resp, Logger, Options.LogSuccessfulRequests, cancel).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
using System.Net.Http;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace ReC.Client.Api
|
||||
{
|
||||
@@ -13,7 +14,13 @@ namespace ReC.Client.Api
|
||||
/// Initializes a new instance of the <see cref="EndpointAuthApi"/> class.
|
||||
/// </summary>
|
||||
/// <param name="http">The HTTP client used for requests.</param>
|
||||
public EndpointAuthApi(HttpClient http) : base(http, "api/EndpointAuth")
|
||||
/// <param name="logger">An optional logger used to record API call outcomes.</param>
|
||||
/// <param name="options">An optional set of client options. Defaults are used when omitted.</param>
|
||||
#if NETFRAMEWORK
|
||||
public EndpointAuthApi(HttpClient http, ILogger logger = null, ReCClientOptions options = null) : base(http, "api/EndpointAuth", logger, options)
|
||||
#else
|
||||
public EndpointAuthApi(HttpClient http, ILogger? logger = null, ReCClientOptions? options = null) : base(http, "api/EndpointAuth", logger, options)
|
||||
#endif
|
||||
{
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
using System.Net.Http;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace ReC.Client.Api
|
||||
{
|
||||
@@ -13,7 +14,13 @@ namespace ReC.Client.Api
|
||||
/// Initializes a new instance of the <see cref="EndpointParamsApi"/> class.
|
||||
/// </summary>
|
||||
/// <param name="http">The HTTP client used for requests.</param>
|
||||
public EndpointParamsApi(HttpClient http) : base(http, "api/EndpointParams")
|
||||
/// <param name="logger">An optional logger used to record API call outcomes.</param>
|
||||
/// <param name="options">An optional set of client options. Defaults are used when omitted.</param>
|
||||
#if NETFRAMEWORK
|
||||
public EndpointParamsApi(HttpClient http, ILogger logger = null, ReCClientOptions options = null) : base(http, "api/EndpointParams", logger, options)
|
||||
#else
|
||||
public EndpointParamsApi(HttpClient http, ILogger? logger = null, ReCClientOptions? options = null) : base(http, "api/EndpointParams", logger, options)
|
||||
#endif
|
||||
{
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
using System.Net.Http;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace ReC.Client.Api
|
||||
{
|
||||
@@ -13,7 +14,13 @@ namespace ReC.Client.Api
|
||||
/// Initializes a new instance of the <see cref="EndpointsApi"/> class.
|
||||
/// </summary>
|
||||
/// <param name="http">The HTTP client used for requests.</param>
|
||||
public EndpointsApi(HttpClient http) : base(http, "api/Endpoints")
|
||||
/// <param name="logger">An optional logger used to record API call outcomes.</param>
|
||||
/// <param name="options">An optional set of client options. Defaults are used when omitted.</param>
|
||||
#if NETFRAMEWORK
|
||||
public EndpointsApi(HttpClient http, ILogger logger = null, ReCClientOptions options = null) : base(http, "api/Endpoints", logger, options)
|
||||
#else
|
||||
public EndpointsApi(HttpClient http, ILogger? logger = null, ReCClientOptions? options = null) : base(http, "api/Endpoints", logger, options)
|
||||
#endif
|
||||
{
|
||||
}
|
||||
}
|
||||
|
||||
50
src/ReC.Client/Api/InvokeReferences.cs
Normal file
50
src/ReC.Client/Api/InvokeReferences.cs
Normal file
@@ -0,0 +1,50 @@
|
||||
namespace ReC.Client.Api
|
||||
{
|
||||
/// <summary>
|
||||
/// Optional reference values that are passed through to all result records when invoking a profile.
|
||||
/// </summary>
|
||||
public class InvokeReferences
|
||||
{
|
||||
/// <summary>Batch identifier.</summary>
|
||||
public string
|
||||
#if NET
|
||||
?
|
||||
#endif
|
||||
BatchId { get; set; }
|
||||
|
||||
/// <summary>Reference value 1.</summary>
|
||||
public string
|
||||
#if NET
|
||||
?
|
||||
#endif
|
||||
Reference1 { get; set; }
|
||||
|
||||
/// <summary>Reference value 2.</summary>
|
||||
public string
|
||||
#if NET
|
||||
?
|
||||
#endif
|
||||
Reference2 { get; set; }
|
||||
|
||||
/// <summary>Reference value 3.</summary>
|
||||
public string
|
||||
#if NET
|
||||
?
|
||||
#endif
|
||||
Reference3 { get; set; }
|
||||
|
||||
/// <summary>Reference value 4.</summary>
|
||||
public string
|
||||
#if NET
|
||||
?
|
||||
#endif
|
||||
Reference4 { get; set; }
|
||||
|
||||
/// <summary>Reference value 5.</summary>
|
||||
public string
|
||||
#if NET
|
||||
?
|
||||
#endif
|
||||
Reference5 { get; set; }
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
using System.Net.Http;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace ReC.Client.Api
|
||||
{
|
||||
@@ -13,21 +14,45 @@ namespace ReC.Client.Api
|
||||
/// Initializes a new instance of the <see cref="ProfileApi"/> class.
|
||||
/// </summary>
|
||||
/// <param name="http">The HTTP client used for requests.</param>
|
||||
public ProfileApi(HttpClient http) : base(http, "api/Profile")
|
||||
/// <param name="logger">An optional logger used to record API call outcomes.</param>
|
||||
/// <param name="options">An optional set of client options. Defaults are used when omitted.</param>
|
||||
#if NETFRAMEWORK
|
||||
public ProfileApi(HttpClient http, ILogger logger = null, ReCClientOptions options = null) : base(http, "api/Profile", logger, options)
|
||||
#else
|
||||
public ProfileApi(HttpClient http, ILogger? logger = null, ReCClientOptions? options = null) : base(http, "api/Profile", logger, options)
|
||||
#endif
|
||||
{
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Retrieves a profile by identifier.
|
||||
/// Retrieves profiles and deserializes the JSON response into <typeparamref name="T"/>.
|
||||
/// </summary>
|
||||
/// <param name="id">The profile identifier.</param>
|
||||
/// <param name="includeActions">Whether to include related actions.</param>
|
||||
/// <param name="cancel">A token to cancel the operation.</param>
|
||||
/// <returns>The HTTP response message.</returns>
|
||||
public Task<HttpResponseMessage> GetAsync(long id, bool includeActions = false, CancellationToken cancel = default)
|
||||
#if NETFRAMEWORK
|
||||
public async Task<T> GetAsync<T>(long? id = null, bool includeActions = true, CancellationToken cancel = default)
|
||||
#else
|
||||
public async Task<T?> GetAsync<T>(long? id = null, bool includeActions = true, CancellationToken cancel = default)
|
||||
#endif
|
||||
{
|
||||
var query = ReCClientHelpers.BuildQuery(("Id", id), ("IncludeActions", includeActions));
|
||||
return Http.GetAsync($"{ResourcePath}{query}", cancel);
|
||||
using (var resp = await Http.GetAsync($"{ResourcePath}{query}", cancel).ConfigureAwait(false))
|
||||
{
|
||||
var body = await ReCClientHelpers.HandleResponseAsync(resp, Logger, Options.LogSuccessfulRequests, cancel).ConfigureAwait(false);
|
||||
return ReCClientHelpers.Deserialize<T>(body);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Retrieves profiles and returns a dynamically deserialized payload
|
||||
/// (typically a <see cref="System.Text.Json.JsonElement"/>). This is the non-generic
|
||||
/// overload of <see cref="GetAsync{T}"/>.
|
||||
/// </summary>
|
||||
#if NETFRAMEWORK
|
||||
public Task<dynamic> GetAsync(long? id = null, bool includeActions = true, CancellationToken cancel = default)
|
||||
#else
|
||||
public Task<dynamic?> GetAsync(long? id = null, bool includeActions = true, CancellationToken cancel = default)
|
||||
#endif
|
||||
{
|
||||
return GetAsync<object>(id, includeActions, cancel);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
using System.Net.Http;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace ReC.Client.Api
|
||||
{
|
||||
@@ -13,33 +14,78 @@ namespace ReC.Client.Api
|
||||
/// Initializes a new instance of the <see cref="RecActionApi"/> class.
|
||||
/// </summary>
|
||||
/// <param name="http">The HTTP client used for requests.</param>
|
||||
public RecActionApi(HttpClient http) : base(http, "api/RecAction")
|
||||
/// <param name="logger">An optional logger used to record API call outcomes.</param>
|
||||
/// <param name="options">An optional set of client options. Defaults are used when omitted.</param>
|
||||
#if NETFRAMEWORK
|
||||
public RecActionApi(HttpClient http, ILogger logger = null, ReCClientOptions options = null) : base(http, "api/RecAction", logger, options)
|
||||
#else
|
||||
public RecActionApi(HttpClient http, ILogger? logger = null, ReCClientOptions? options = null) : base(http, "api/RecAction", logger, options)
|
||||
#endif
|
||||
{
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Invokes a ReC action for the specified profile.
|
||||
/// Invokes a batch of RecActions for the specified profile.
|
||||
/// </summary>
|
||||
/// <param name="profileId">The profile identifier.</param>
|
||||
/// <param name="references">Optional reference values to pass through to all result records.</param>
|
||||
/// <param name="cancellationToken">A token to cancel the operation.</param>
|
||||
/// <returns><see langword="true"/> if the request succeeds; otherwise, <see langword="false"/>.</returns>
|
||||
public async Task<bool> InvokeAsync(int profileId, CancellationToken cancellationToken = default)
|
||||
/// <exception cref="ReCApiException">Thrown when the API responds with a non-success status code.</exception>
|
||||
#if NETFRAMEWORK
|
||||
public async Task InvokeAsync(long profileId, InvokeReferences references = null, CancellationToken cancellationToken = default)
|
||||
#else
|
||||
public async Task InvokeAsync(long profileId, InvokeReferences? references = null, CancellationToken cancellationToken = default)
|
||||
#endif
|
||||
{
|
||||
var resp = await Http.PostAsync($"{ResourcePath}/invoke/{profileId}", content: null, cancellationToken);
|
||||
return resp.IsSuccessStatusCode;
|
||||
var content = references != null ? ReCClientHelpers.ToJsonContent(references) : null;
|
||||
using (content)
|
||||
using (var resp = await Http.PostAsync($"{ResourcePath}/invoke/{profileId}", content, cancellationToken))
|
||||
{
|
||||
await ReCClientHelpers.HandleResponseAsync(resp, Logger, Options.LogSuccessfulRequests, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Retrieves Rec actions.
|
||||
/// Invokes a batch of RecActions for the specified profile.
|
||||
/// </summary>
|
||||
/// <param name="profileId">Optional profile filter.</param>
|
||||
/// <param name="invoked">Optional invoked filter.</param>
|
||||
/// <param name="cancel">A token to cancel the operation.</param>
|
||||
/// <returns>The HTTP response message.</returns>
|
||||
public Task<HttpResponseMessage> GetAsync(long? profileId = null, bool? invoked = null, CancellationToken cancel = default)
|
||||
/// <param name="profileId">The profile identifier.</param>
|
||||
/// <param name="batchId">Batch identifier.</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>
|
||||
public Task InvokeAsync(long profileId, string batchId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
return InvokeAsync(profileId, new InvokeReferences() { BatchId = batchId }, cancellationToken);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Retrieves Rec actions and deserializes the JSON response into <typeparamref name="T"/>.
|
||||
/// </summary>
|
||||
#if NETFRAMEWORK
|
||||
public async Task<T> GetAsync<T>(long? profileId = null, bool? invoked = null, CancellationToken cancel = default)
|
||||
#else
|
||||
public async Task<T?> GetAsync<T>(long? profileId = null, bool? invoked = null, CancellationToken cancel = default)
|
||||
#endif
|
||||
{
|
||||
var query = ReCClientHelpers.BuildQuery(("ProfileId", profileId), ("Invoked", invoked));
|
||||
return Http.GetAsync($"{ResourcePath}{query}", cancel);
|
||||
using (var resp = await Http.GetAsync($"{ResourcePath}{query}", cancel).ConfigureAwait(false))
|
||||
{
|
||||
var body = await ReCClientHelpers.HandleResponseAsync(resp, Logger, Options.LogSuccessfulRequests, cancel).ConfigureAwait(false);
|
||||
return ReCClientHelpers.Deserialize<T>(body);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Retrieves Rec actions and returns a dynamically deserialized payload
|
||||
/// (typically a <see cref="System.Text.Json.JsonElement"/>). This is the non-generic
|
||||
/// overload of <see cref="GetAsync{T}"/>.
|
||||
/// </summary>
|
||||
#if NETFRAMEWORK
|
||||
public Task<dynamic> GetAsync(long? profileId = null, bool? invoked = null, CancellationToken cancel = default)
|
||||
#else
|
||||
public Task<dynamic?> GetAsync(long? profileId = null, bool? invoked = null, CancellationToken cancel = default)
|
||||
#endif
|
||||
{
|
||||
return GetAsync<object>(profileId, invoked, cancel);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
using System.Net.Http;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace ReC.Client.Api
|
||||
{
|
||||
@@ -13,22 +14,53 @@ namespace ReC.Client.Api
|
||||
/// Initializes a new instance of the <see cref="ResultApi"/> class.
|
||||
/// </summary>
|
||||
/// <param name="http">The HTTP client used for requests.</param>
|
||||
public ResultApi(HttpClient http) : base(http, "api/Result")
|
||||
/// <param name="logger">An optional logger used to record API call outcomes.</param>
|
||||
/// <param name="options">An optional set of client options. Defaults are used when omitted.</param>
|
||||
#if NETFRAMEWORK
|
||||
public ResultApi(HttpClient http, ILogger logger = null, ReCClientOptions options = null) : base(http, "api/Result", logger, options)
|
||||
#else
|
||||
public ResultApi(HttpClient http, ILogger? logger = null, ReCClientOptions? options = null) : base(http, "api/Result", logger, options)
|
||||
#endif
|
||||
{
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Retrieves results with optional filters.
|
||||
/// Retrieves results with optional filters and deserializes the JSON response into <typeparamref name="T"/>.
|
||||
/// </summary>
|
||||
/// <param name="id">Optional result identifier.</param>
|
||||
/// <param name="actionId">Optional action identifier.</param>
|
||||
/// <param name="profileId">Optional profile identifier.</param>
|
||||
/// <param name="cancel">A token to cancel the operation.</param>
|
||||
/// <returns>The HTTP response message.</returns>
|
||||
public Task<HttpResponseMessage> GetAsync(long? id = null, long? actionId = null, long? profileId = null, CancellationToken cancel = default)
|
||||
#if NETFRAMEWORK
|
||||
public async Task<T> GetAsync<T>(long? id = null, long? actionId = null, long? profileId = null, string batchId = null, bool includeAction = true, bool includeProfile = false, bool lastBatch = false, CancellationToken cancel = default)
|
||||
#else
|
||||
public async Task<T?> GetAsync<T>(long? id = null, long? actionId = null, long? profileId = null, string? batchId = null, bool includeAction = true, bool includeProfile = false, bool lastBatch = false, CancellationToken cancel = default)
|
||||
#endif
|
||||
{
|
||||
var query = ReCClientHelpers.BuildQuery(("Id", id), ("ActionId", actionId), ("ProfileId", profileId));
|
||||
return Http.GetAsync($"{ResourcePath}{query}", cancel);
|
||||
var query = ReCClientHelpers.BuildQuery(
|
||||
("Id", id),
|
||||
("ActionId", actionId),
|
||||
("ProfileId", profileId),
|
||||
("BatchId", batchId),
|
||||
("IncludeAction", includeAction),
|
||||
("IncludeProfile", includeProfile),
|
||||
("LastBatch", lastBatch));
|
||||
|
||||
using (var resp = await Http.GetAsync($"{ResourcePath}{query}", cancel).ConfigureAwait(false))
|
||||
{
|
||||
var body = await ReCClientHelpers.HandleResponseAsync(resp, Logger, Options.LogSuccessfulRequests, cancel).ConfigureAwait(false);
|
||||
return ReCClientHelpers.Deserialize<T>(body);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Retrieves results with optional filters and returns a dynamically deserialized payload
|
||||
/// (typically a <see cref="System.Text.Json.JsonElement"/>). This is the non-generic
|
||||
/// overload of <see cref="GetAsync{T}"/>.
|
||||
/// </summary>
|
||||
#if NETFRAMEWORK
|
||||
public Task<dynamic> GetAsync(long? id = null, long? actionId = null, long? profileId = null, string batchId = null, bool includeAction = true, bool includeProfile = false, bool lastBatch = false, CancellationToken cancel = default)
|
||||
#else
|
||||
public Task<dynamic?> GetAsync(long? id = null, long? actionId = null, long? profileId = null, string? batchId = null, bool includeAction = true, bool includeProfile = false, bool lastBatch = false, CancellationToken cancel = default)
|
||||
#endif
|
||||
{
|
||||
return GetAsync<object>(id, actionId, profileId, batchId, includeAction, includeProfile, lastBatch, cancel);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
#if NETFRAMEWORK
|
||||
using System;
|
||||
#if NETFRAMEWORK
|
||||
using System.Net.Http;
|
||||
#endif
|
||||
|
||||
@@ -16,9 +16,15 @@ namespace ReC.Client
|
||||
/// </summary>
|
||||
/// <param name="services">The <see cref="IServiceCollection"/> to add the services to.</param>
|
||||
/// <param name="apiUri">The base URI of the ReC API.</param>
|
||||
/// <param name="configureOptions">An optional action to configure <see cref="ReCClientOptions"/>. When omitted, defaults are used.</param>
|
||||
/// <returns>An <see cref="IHttpClientBuilder"/> that can be used to configure the client.</returns>
|
||||
public static IHttpClientBuilder AddRecClient(this IServiceCollection services, string apiUri)
|
||||
#if NETFRAMEWORK
|
||||
public static IHttpClientBuilder AddRecClient(this IServiceCollection services, string apiUri, Action<ReCClientOptions> configureOptions = null)
|
||||
#else
|
||||
public static IHttpClientBuilder AddRecClient(this IServiceCollection services, string apiUri, Action<ReCClientOptions>? configureOptions = null)
|
||||
#endif
|
||||
{
|
||||
AddRecClientOptions(services, configureOptions);
|
||||
services.AddScoped<ReCClient>();
|
||||
return services.AddHttpClient(ReCClient.ClientName, client =>
|
||||
{
|
||||
@@ -31,11 +37,29 @@ namespace ReC.Client
|
||||
/// </summary>
|
||||
/// <param name="services">The <see cref="IServiceCollection"/> to add the services to.</param>
|
||||
/// <param name="configureClient">An action to configure the <see cref="HttpClient"/>.</param>
|
||||
/// <param name="configureOptions">An optional action to configure <see cref="ReCClientOptions"/>. When omitted, defaults are used.</param>
|
||||
/// <returns>An <see cref="IHttpClientBuilder"/> that can be used to configure the client.</returns>
|
||||
public static IHttpClientBuilder AddRecClient(this IServiceCollection services, Action<HttpClient> configureClient)
|
||||
#if NETFRAMEWORK
|
||||
public static IHttpClientBuilder AddRecClient(this IServiceCollection services, Action<HttpClient> configureClient, Action<ReCClientOptions> configureOptions = null)
|
||||
#else
|
||||
public static IHttpClientBuilder AddRecClient(this IServiceCollection services, Action<HttpClient> configureClient, Action<ReCClientOptions>? configureOptions = null)
|
||||
#endif
|
||||
{
|
||||
AddRecClientOptions(services, configureOptions);
|
||||
services.AddScoped<ReCClient>();
|
||||
return services.AddHttpClient(ReCClient.ClientName, configureClient);
|
||||
}
|
||||
|
||||
#if NETFRAMEWORK
|
||||
private static void AddRecClientOptions(IServiceCollection services, Action<ReCClientOptions> configureOptions)
|
||||
#else
|
||||
private static void AddRecClientOptions(IServiceCollection services, Action<ReCClientOptions>? configureOptions)
|
||||
#endif
|
||||
{
|
||||
// Ensure default options are always registered even when the caller does not configure anything.
|
||||
var builder = services.AddOptions<ReCClientOptions>();
|
||||
if (configureOptions != null)
|
||||
builder.Configure(configureOptions);
|
||||
}
|
||||
}
|
||||
}
|
||||
88
src/ReC.Client/ReCApiException.cs
Normal file
88
src/ReC.Client/ReCApiException.cs
Normal file
@@ -0,0 +1,88 @@
|
||||
using System;
|
||||
using System.Net;
|
||||
using System.Net.Http;
|
||||
|
||||
namespace ReC.Client
|
||||
{
|
||||
/// <summary>
|
||||
/// Represents an error returned by the ReC API.
|
||||
/// </summary>
|
||||
#if !NETFRAMEWORK
|
||||
[Serializable]
|
||||
#endif
|
||||
public class ReCApiException : Exception
|
||||
{
|
||||
/// <summary>
|
||||
/// The HTTP status code returned by the API.
|
||||
/// </summary>
|
||||
public HttpStatusCode StatusCode { get; }
|
||||
|
||||
/// <summary>
|
||||
/// The HTTP reason phrase returned by the API, if any.
|
||||
/// </summary>
|
||||
#if NETFRAMEWORK
|
||||
public string ReasonPhrase { get; }
|
||||
#else
|
||||
public string? ReasonPhrase { get; }
|
||||
#endif
|
||||
|
||||
/// <summary>
|
||||
/// The raw response body returned by the API, if any.
|
||||
/// </summary>
|
||||
#if NETFRAMEWORK
|
||||
public string ResponseBody { get; }
|
||||
#else
|
||||
public string? ResponseBody { get; }
|
||||
#endif
|
||||
|
||||
/// <summary>
|
||||
/// The HTTP method used for the failed request.
|
||||
/// </summary>
|
||||
#if NETFRAMEWORK
|
||||
public string Method { get; }
|
||||
#else
|
||||
public string? Method { get; }
|
||||
#endif
|
||||
|
||||
/// <summary>
|
||||
/// The request URI that was called.
|
||||
/// </summary>
|
||||
#if NETFRAMEWORK
|
||||
public Uri RequestUri { get; }
|
||||
#else
|
||||
public Uri? RequestUri { get; }
|
||||
#endif
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="ReCApiException"/> class.
|
||||
/// </summary>
|
||||
/// <param name="message">A summary message describing the error.</param>
|
||||
/// <param name="statusCode">The HTTP status code returned by the API.</param>
|
||||
/// <param name="reasonPhrase">The HTTP reason phrase returned by the API.</param>
|
||||
/// <param name="responseBody">The raw response body returned by the API.</param>
|
||||
/// <param name="method">The HTTP method used for the request.</param>
|
||||
/// <param name="requestUri">The request URI that was called.</param>
|
||||
public ReCApiException(
|
||||
string message,
|
||||
HttpStatusCode statusCode,
|
||||
#if NETFRAMEWORK
|
||||
string reasonPhrase,
|
||||
string responseBody,
|
||||
string method,
|
||||
Uri requestUri
|
||||
#else
|
||||
string? reasonPhrase,
|
||||
string? responseBody,
|
||||
string? method,
|
||||
Uri? requestUri
|
||||
#endif
|
||||
) : base(message)
|
||||
{
|
||||
StatusCode = statusCode;
|
||||
ReasonPhrase = reasonPhrase;
|
||||
ResponseBody = responseBody;
|
||||
Method = method;
|
||||
RequestUri = requestUri;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,6 @@
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
using System;
|
||||
using System.Net.Http;
|
||||
using ReC.Client.Api;
|
||||
@@ -56,43 +58,94 @@ namespace ReC.Client
|
||||
/// Initializes a new instance of the <see cref="ReCClient"/> class.
|
||||
/// </summary>
|
||||
/// <param name="httpClientFactory">The factory to create HttpClients.</param>
|
||||
public ReCClient(IHttpClientFactory httpClientFactory)
|
||||
/// <param name="options">An optional set of client options. Defaults are used when omitted.</param>
|
||||
/// <param name="logger">An optional logger used to record API call outcomes.</param>
|
||||
#if NETFRAMEWORK
|
||||
public ReCClient(IHttpClientFactory httpClientFactory, IOptions<ReCClientOptions> options = null, ILogger logger = null)
|
||||
#else
|
||||
public ReCClient(IHttpClientFactory httpClientFactory, IOptions<ReCClientOptions>? options = null, ILogger<ReCClient>? logger = null)
|
||||
#endif
|
||||
{
|
||||
_http = httpClientFactory.CreateClient(ClientName);
|
||||
RecActions = new RecActionApi(_http);
|
||||
Results = new ResultApi(_http);
|
||||
Profiles = new ProfileApi(_http);
|
||||
EndpointAuth = new EndpointAuthApi(_http);
|
||||
EndpointParams = new EndpointParamsApi(_http);
|
||||
Endpoints = new EndpointsApi(_http);
|
||||
Common = new CommonApi(_http);
|
||||
var opts = options?.Value ?? new ReCClientOptions();
|
||||
|
||||
if (opts.LogSuccessfulRequests && logger == null)
|
||||
throw new InvalidOperationException(
|
||||
$"{nameof(ReCClientOptions.LogSuccessfulRequests)} is enabled, but no {nameof(ILogger)} was injected into {nameof(ReCClient)}. " +
|
||||
$"Register a logging provider (e.g. services.AddLogging()) so that an {nameof(ILogger)} can be resolved, " +
|
||||
$"or set {nameof(ReCClientOptions.LogSuccessfulRequests)} to false.");
|
||||
|
||||
RecActions = new RecActionApi(_http, logger, opts);
|
||||
Results = new ResultApi(_http, logger, opts);
|
||||
Profiles = new ProfileApi(_http, logger, opts);
|
||||
EndpointAuth = new EndpointAuthApi(_http, logger, opts);
|
||||
EndpointParams = new EndpointParamsApi(_http, logger, opts);
|
||||
Endpoints = new EndpointsApi(_http, logger, opts);
|
||||
Common = new CommonApi(_http, logger, opts);
|
||||
}
|
||||
|
||||
#region Static
|
||||
private static readonly IServiceCollection Services = new ServiceCollection();
|
||||
|
||||
#if NET8_0_OR_GREATER
|
||||
private static IServiceProvider? Provider = null;
|
||||
private static Action<IServiceCollection>? _staticConfigure = null;
|
||||
#else
|
||||
private static IServiceProvider Provider = null;
|
||||
private static Action<IServiceCollection> _staticConfigure = null;
|
||||
#endif
|
||||
|
||||
private static readonly Lazy<IServiceProvider> LazyProvider = new Lazy<IServiceProvider>(() =>
|
||||
{
|
||||
var configure = _staticConfigure
|
||||
?? throw new InvalidOperationException("Static Provider is not built. Call BuildStaticClient first.");
|
||||
|
||||
var services = new ServiceCollection();
|
||||
configure(services);
|
||||
return services.BuildServiceProvider();
|
||||
}, System.Threading.LazyThreadSafetyMode.ExecutionAndPublication);
|
||||
|
||||
/// <summary>
|
||||
/// Configures and builds the static <see cref="IServiceProvider"/> for creating <see cref="ReCClient"/> instances.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// This method should only be called once during application startup.
|
||||
/// This method should only be called once during application startup. The underlying
|
||||
/// <see cref="IServiceProvider"/> is created lazily and thread-safely on first access via <see cref="Create"/>.
|
||||
/// </remarks>
|
||||
/// <param name="apiUri">The base URI of the ReC API.</param>
|
||||
/// <exception cref="InvalidOperationException">Thrown if the static provider has already been built.</exception>
|
||||
/// <param name="configure">Callback that populates a <see cref="StaticBuildConfiguration"/> instance.</param>
|
||||
/// <exception cref="ArgumentNullException">Thrown when <paramref name="configure"/> is null.</exception>
|
||||
/// <exception cref="InvalidOperationException">Thrown when neither <see cref="StaticBuildConfiguration.BaseAddress"/> nor <see cref="StaticBuildConfiguration.ConfigureClient"/> is set, when both are set, or when the static provider has already been built.</exception>
|
||||
[Obsolete("Use a local service collection instead of the static provider.")]
|
||||
public static void BuildStaticClient(string apiUri)
|
||||
public static void BuildStaticClient(Action<StaticBuildConfiguration> configure)
|
||||
{
|
||||
if(Provider != null)
|
||||
throw new InvalidOperationException("Static Provider is already built.");
|
||||
if (configure == null)
|
||||
throw new ArgumentNullException(nameof(configure));
|
||||
|
||||
Services.AddRecClient(apiUri);
|
||||
Provider = Services.BuildServiceProvider();
|
||||
var cfg = new StaticBuildConfiguration();
|
||||
configure(cfg);
|
||||
|
||||
var hasBaseAddress = !string.IsNullOrWhiteSpace(cfg.BaseAddress);
|
||||
var hasConfigureClient = cfg.ConfigureClient != null;
|
||||
|
||||
if (!hasBaseAddress && !hasConfigureClient)
|
||||
throw new InvalidOperationException(
|
||||
$"Either {nameof(StaticBuildConfiguration.BaseAddress)} or {nameof(StaticBuildConfiguration.ConfigureClient)} must be set on {nameof(StaticBuildConfiguration)}.");
|
||||
|
||||
if (hasBaseAddress && hasConfigureClient)
|
||||
throw new InvalidOperationException(
|
||||
$"{nameof(StaticBuildConfiguration.BaseAddress)} and {nameof(StaticBuildConfiguration.ConfigureClient)} are mutually exclusive on {nameof(StaticBuildConfiguration)}.");
|
||||
|
||||
Action<IServiceCollection> register = services =>
|
||||
{
|
||||
if (hasBaseAddress)
|
||||
services.AddRecClient(cfg.BaseAddress, cfg.ConfigureOptions);
|
||||
else
|
||||
services.AddRecClient(cfg.ConfigureClient, cfg.ConfigureOptions);
|
||||
|
||||
if (cfg.Logger != null)
|
||||
services.AddSingleton(cfg.Logger);
|
||||
|
||||
cfg.ConfigureServices?.Invoke(services);
|
||||
};
|
||||
|
||||
if (System.Threading.Interlocked.CompareExchange(ref _staticConfigure, register, null) != null)
|
||||
throw new InvalidOperationException("Static Provider is already built.");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -100,31 +153,62 @@ namespace ReC.Client
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// This method should only be called once during application startup.
|
||||
/// The underlying <see cref="IServiceProvider"/> is created lazily and thread-safely on first access via <see cref="Create"/>.
|
||||
/// </remarks>
|
||||
/// <param name="apiUri">The base URI of the ReC API.</param>
|
||||
/// <param name="configureOptions">An optional callback to configure <see cref="ReCClientOptions"/>.</param>
|
||||
/// <param name="logger">An optional <see cref="ILogger"/> instance to be used by the <see cref="ReCClient"/>. When provided, it is registered as a singleton in the internal service collection.</param>
|
||||
/// <exception cref="InvalidOperationException">Thrown if the static provider has already been built.</exception>
|
||||
[Obsolete("Use BuildStaticClient(Action<StaticBuildConfiguration>) instead.")]
|
||||
#if NETFRAMEWORK
|
||||
public static void BuildStaticClient(string apiUri, Action<ReCClientOptions> configureOptions = null, ILogger logger = null)
|
||||
#else
|
||||
public static void BuildStaticClient(string apiUri, Action<ReCClientOptions>? configureOptions = null, ILogger<ReCClient>? logger = null)
|
||||
#endif
|
||||
{
|
||||
BuildStaticClient(cfg =>
|
||||
{
|
||||
cfg.BaseAddress = apiUri;
|
||||
cfg.ConfigureOptions = configureOptions;
|
||||
cfg.Logger = logger;
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Configures and builds the static <see cref="IServiceProvider"/> for creating <see cref="ReCClient"/> instances.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// This method should only be called once during application startup.
|
||||
/// The underlying <see cref="IServiceProvider"/> is created lazily and thread-safely on first access via <see cref="Create"/>.
|
||||
/// </remarks>
|
||||
/// <param name="configureClient">An action to configure the <see cref="HttpClient"/>.</param>
|
||||
/// <param name="configureOptions">An optional callback to configure <see cref="ReCClientOptions"/>.</param>
|
||||
/// <param name="logger">An optional <see cref="ILogger"/> instance to be used by the <see cref="ReCClient"/>. When provided, it is registered as a singleton in the internal service collection.</param>
|
||||
/// <exception cref="InvalidOperationException">Thrown if the static provider has already been built.</exception>
|
||||
[Obsolete("Use a local service collection instead of the static provider.")]
|
||||
public static void BuildStaticClient(Action<HttpClient> configureClient)
|
||||
[Obsolete("Use BuildStaticClient(Action<StaticBuildConfiguration>) instead.")]
|
||||
#if NETFRAMEWORK
|
||||
public static void BuildStaticClient(Action<HttpClient> configureClient, Action<ReCClientOptions> configureOptions = null, ILogger logger = null)
|
||||
#else
|
||||
public static void BuildStaticClient(Action<HttpClient> configureClient, Action<ReCClientOptions>? configureOptions = null, ILogger<ReCClient>? logger = null)
|
||||
#endif
|
||||
{
|
||||
if (Provider != null)
|
||||
throw new InvalidOperationException("Static Provider is already built.");
|
||||
|
||||
Services.AddRecClient(configureClient);
|
||||
Provider = Services.BuildServiceProvider();
|
||||
BuildStaticClient(cfg =>
|
||||
{
|
||||
cfg.ConfigureClient = configureClient;
|
||||
cfg.ConfigureOptions = configureOptions;
|
||||
cfg.Logger = logger;
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates a new <see cref="ReCClient"/> instance using the statically configured provider.
|
||||
/// </summary>
|
||||
/// <returns>A new instance of the <see cref="ReCClient"/>.</returns>
|
||||
/// <exception cref="InvalidOperationException">Thrown if <see cref="BuildStaticClient(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.")]
|
||||
public static ReCClient Create()
|
||||
{
|
||||
if (Provider == null)
|
||||
throw new InvalidOperationException("Static Provider is not built. Call BuildStaticClient first.");
|
||||
|
||||
return Provider.GetRequiredService<ReCClient>();
|
||||
return LazyProvider.Value.GetRequiredService<ReCClient>();
|
||||
}
|
||||
#endregion
|
||||
}
|
||||
|
||||
@@ -1,11 +1,14 @@
|
||||
using System;
|
||||
using System.Collections;
|
||||
using System.Globalization;
|
||||
using System.Linq;
|
||||
using System.Net.Http.Json;
|
||||
|
||||
#if NETFRAMEWORK
|
||||
using System.Net.Http;
|
||||
#endif
|
||||
using System.Net.Http.Json;
|
||||
using System.Reflection;
|
||||
using System.Text.Json;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace ReC.Client
|
||||
{
|
||||
@@ -45,5 +48,110 @@ namespace ReC.Client
|
||||
/// <param name="payload">The payload to serialize.</param>
|
||||
/// <returns>A <see cref="JsonContent"/> instance ready for HTTP requests.</returns>
|
||||
public static JsonContent ToJsonContent<T>(T payload) => JsonContent.Create(payload);
|
||||
|
||||
/// <summary>
|
||||
/// Builds a query string from the public readable properties of <paramref name="payload"/>,
|
||||
/// skipping properties whose values are <see langword="null"/>.
|
||||
/// </summary>
|
||||
/// <typeparam name="T">The payload type.</typeparam>
|
||||
/// <param name="payload">The payload to serialize into a query string.</param>
|
||||
/// <returns>A query string beginning with '?', or an empty string if no values are provided.</returns>
|
||||
public static string BuildQueryFromObject<T>(T payload)
|
||||
{
|
||||
if (payload == null)
|
||||
return string.Empty;
|
||||
|
||||
var props = typeof(T).GetProperties(BindingFlags.Public | BindingFlags.Instance)
|
||||
.Where(p => p.CanRead && p.GetIndexParameters().Length == 0);
|
||||
|
||||
var parts = props
|
||||
.Select(p => new { p.Name, Value = p.GetValue(payload) })
|
||||
.Where(p => p.Value != null)
|
||||
.Select(p => $"{Uri.EscapeDataString(p.Name)}={Uri.EscapeDataString(Convert.ToString(p.Value, CultureInfo.InvariantCulture) ?? string.Empty)}");
|
||||
|
||||
var query = string.Join("&", parts);
|
||||
return string.IsNullOrWhiteSpace(query) ? string.Empty : $"?{query}";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// JSON serializer options used when deserializing API responses.
|
||||
/// </summary>
|
||||
public static readonly JsonSerializerOptions JsonOptions = new JsonSerializerOptions
|
||||
{
|
||||
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
|
||||
PropertyNameCaseInsensitive = true,
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Reads the response body and logs the outcome. Throws a <see cref="ReCApiException"/> when
|
||||
/// the response indicates a non-success status code.
|
||||
/// </summary>
|
||||
#if NETFRAMEWORK
|
||||
public static async Task<string> HandleResponseAsync(HttpResponseMessage response, ILogger logger = null, bool logSuccess = true, CancellationToken cancel = default)
|
||||
#else
|
||||
public static async Task<string?> HandleResponseAsync(HttpResponseMessage response, ILogger? logger = null, bool logSuccess = true, CancellationToken cancel = default)
|
||||
#endif
|
||||
{
|
||||
var request = response.RequestMessage;
|
||||
var method = request?.Method?.Method;
|
||||
var uri = request?.RequestUri;
|
||||
var statusCode = (int)response.StatusCode;
|
||||
|
||||
#if NETFRAMEWORK
|
||||
string body = null;
|
||||
#else
|
||||
string? body = null;
|
||||
#endif
|
||||
if (response.Content != null)
|
||||
{
|
||||
try
|
||||
{
|
||||
#if NETFRAMEWORK
|
||||
body = await response.Content.ReadAsStringAsync().ConfigureAwait(false);
|
||||
#else
|
||||
body = await response.Content.ReadAsStringAsync(cancel).ConfigureAwait(false);
|
||||
#endif
|
||||
}
|
||||
catch
|
||||
{
|
||||
}
|
||||
}
|
||||
|
||||
if (response.IsSuccessStatusCode)
|
||||
{
|
||||
if (logSuccess)
|
||||
{
|
||||
logger?.LogInformation(
|
||||
"ReC API request succeeded. {Method} {Uri} -> {StatusCode} ({ReasonPhrase})",
|
||||
method,
|
||||
uri,
|
||||
statusCode,
|
||||
response.ReasonPhrase);
|
||||
}
|
||||
|
||||
return body;
|
||||
}
|
||||
|
||||
var message = $"ReC API request failed with status {statusCode} ({response.ReasonPhrase}). "
|
||||
+ $"{method} {uri}"
|
||||
+ (string.IsNullOrWhiteSpace(body) ? string.Empty : $": {body}");
|
||||
|
||||
throw new ReCApiException(message, response.StatusCode, response.ReasonPhrase, body, method, uri);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Deserializes a JSON body string into <typeparamref name="T"/>.
|
||||
/// </summary>
|
||||
#if NETFRAMEWORK
|
||||
public static T Deserialize<T>(string body)
|
||||
#else
|
||||
public static T? Deserialize<T>(string? body)
|
||||
#endif
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(body))
|
||||
return default;
|
||||
|
||||
return JsonSerializer.Deserialize<T>(body, JsonOptions);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
16
src/ReC.Client/ReCClientOptions.cs
Normal file
16
src/ReC.Client/ReCClientOptions.cs
Normal file
@@ -0,0 +1,16 @@
|
||||
namespace ReC.Client
|
||||
{
|
||||
/// <summary>
|
||||
/// Options that control the behavior of the <see cref="ReCClient"/>.
|
||||
/// </summary>
|
||||
public class ReCClientOptions
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets or sets a value indicating whether successful API requests should be
|
||||
/// logged through the injected <see cref="Microsoft.Extensions.Logging.ILogger"/>.
|
||||
/// Failed requests always throw <see cref="ReCApiException"/> regardless of this setting.
|
||||
/// Defaults to <see langword="true"/>.
|
||||
/// </summary>
|
||||
public bool LogSuccessfulRequests { get; set; } = true;
|
||||
}
|
||||
}
|
||||
63
src/ReC.Client/StaticBuildConfiguration.cs
Normal file
63
src/ReC.Client/StaticBuildConfiguration.cs
Normal file
@@ -0,0 +1,63 @@
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using System;
|
||||
using System.Net.Http;
|
||||
|
||||
namespace ReC.Client
|
||||
{
|
||||
/// <summary>
|
||||
/// Configuration object for <see cref="ReCClient.BuildStaticClient(Action{StaticBuildConfiguration})"/>.
|
||||
/// Groups all optional settings for the static <see cref="ReCClient"/> bootstrap path.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Either <see cref="BaseAddress"/> or <see cref="ConfigureClient"/> must be set; setting both at the same time is not allowed.
|
||||
/// </remarks>
|
||||
public class StaticBuildConfiguration
|
||||
{
|
||||
/// <summary>
|
||||
/// Base URI of the ReC API. Mutually exclusive with <see cref="ConfigureClient"/>.
|
||||
/// </summary>
|
||||
#if NETFRAMEWORK
|
||||
public string BaseAddress { get; set; }
|
||||
#else
|
||||
public string? BaseAddress { get; set; }
|
||||
#endif
|
||||
|
||||
/// <summary>
|
||||
/// Callback that configures the underlying <see cref="HttpClient"/>. Mutually exclusive with <see cref="BaseAddress"/>.
|
||||
/// </summary>
|
||||
#if NETFRAMEWORK
|
||||
public Action<HttpClient> ConfigureClient { get; set; }
|
||||
#else
|
||||
public Action<HttpClient>? ConfigureClient { get; set; }
|
||||
#endif
|
||||
|
||||
/// <summary>
|
||||
/// Optional callback to configure <see cref="ReCClientOptions"/>.
|
||||
/// </summary>
|
||||
#if NETFRAMEWORK
|
||||
public Action<ReCClientOptions> ConfigureOptions { get; set; }
|
||||
#else
|
||||
public Action<ReCClientOptions>? ConfigureOptions { get; set; }
|
||||
#endif
|
||||
|
||||
/// <summary>
|
||||
/// Optional logger instance to be registered as a singleton in the internal service collection.
|
||||
/// </summary>
|
||||
#if NETFRAMEWORK
|
||||
public ILogger Logger { get; set; }
|
||||
#else
|
||||
public ILogger<ReCClient>? Logger { get; set; }
|
||||
#endif
|
||||
|
||||
/// <summary>
|
||||
/// Optional callback for additional service registrations on the internal <see cref="IServiceCollection"/>
|
||||
/// (e.g. <c>services.AddLogging(...)</c> or custom dependencies).
|
||||
/// </summary>
|
||||
#if NETFRAMEWORK
|
||||
public Action<IServiceCollection> ConfigureServices { get; set; }
|
||||
#else
|
||||
public Action<IServiceCollection>? ConfigureServices { get; set; }
|
||||
#endif
|
||||
}
|
||||
}
|
||||
@@ -7,12 +7,14 @@ namespace ReC.Client
|
||||
/// <summary>
|
||||
/// Provides synchronous wrappers for Task-based operations.
|
||||
/// </summary>
|
||||
[System.Obsolete("Synchronous blocking helpers (Task.Sync) are no longer recommended. These methods can cause deadlocks or unexpected behavior. Rewrite calling code to use async/await (e.g. async Task tests). This helper class will be removed in a future release.", false)]
|
||||
public static class TaskSyncExtensions
|
||||
{
|
||||
/// <summary>
|
||||
/// Blocks until the task completes and propagates any exception.
|
||||
/// </summary>
|
||||
/// <param name="task">The task to wait for.</param>
|
||||
[System.Obsolete("Use async/await instead of synchronous blocking. This method can cause deadlocks.", false)]
|
||||
public static void Sync(this Task task) => task.ConfigureAwait(false).GetAwaiter().GetResult();
|
||||
|
||||
/// <summary>
|
||||
@@ -21,6 +23,7 @@ namespace ReC.Client
|
||||
/// <typeparam name="TResult">The type of the task result.</typeparam>
|
||||
/// <param name="task">The task to wait for.</param>
|
||||
/// <returns>The result of the completed task.</returns>
|
||||
[System.Obsolete("Use async/await instead of synchronous blocking. This method can cause deadlocks.", false)]
|
||||
public static TResult Sync<TResult>(this Task<TResult> task) => task.ConfigureAwait(false).GetAwaiter().GetResult();
|
||||
}
|
||||
}
|
||||
@@ -1,431 +1,23 @@
|
||||
using ReC.Domain.Views;
|
||||
|
||||
namespace ReC.Domain.Constants;
|
||||
namespace ReC.Domain.Constants;
|
||||
|
||||
/// <summary>
|
||||
/// Represents status codes used to indicate the outcome of an operation.
|
||||
/// Represents the general outcome of an operation, independent of any specific technology or protocol.
|
||||
/// <para>
|
||||
/// Includes all standard HTTP status codes as defined in <see cref="System.Net.HttpStatusCode"/>,
|
||||
/// as well as custom non-HTTP status codes for internal operation results.
|
||||
/// Technology-specific details (e.g., HTTP status codes) are stored separately
|
||||
/// in the <c>RESULT_INFO</c> and <c>RESULT_INFO_DETAIL</c> fields.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
/// <seealso cref="RecStatusExtensions"/>
|
||||
public enum RecStatus : short
|
||||
public enum RecStatus : byte
|
||||
{
|
||||
/// <summary>
|
||||
/// Indicates that a SQL query executed successfully (value 0).
|
||||
/// Used as the result status when <see cref="RecActionView.PreprocessingQuery"/>
|
||||
/// or <see cref="RecActionView.PostprocessingQuery"/> completes without error.
|
||||
/// Indicates that the operation completed successfully (value 0).
|
||||
/// </summary>
|
||||
QuerySuccess = 0,
|
||||
OK = 0,
|
||||
|
||||
/// <summary>
|
||||
/// Indicates that an operation failed at any stage (value 999).
|
||||
/// This includes SQL query failures during preprocessing/postprocessing,
|
||||
/// HTTP request errors, or any other unhandled exception within the action pipeline.
|
||||
/// Indicates that the operation failed (value 1).
|
||||
/// When set, the <c>RESULT_ERROR</c> field should contain the error details.
|
||||
/// </summary>
|
||||
Failed = 999,
|
||||
|
||||
//
|
||||
// Summary:
|
||||
// Equivalent to HTTP status 100. System.Net.HttpStatusCode.Continue indicates that
|
||||
// the client can continue with its request.
|
||||
Continue = 100,
|
||||
//
|
||||
// Summary:
|
||||
// Equivalent to HTTP status 101. System.Net.HttpStatusCode.SwitchingProtocols indicates
|
||||
// that the protocol version or protocol is being changed.
|
||||
SwitchingProtocols = 101,
|
||||
//
|
||||
// Summary:
|
||||
// Equivalent to HTTP status 102. System.Net.HttpStatusCode.Processing indicates
|
||||
// that the server has accepted the complete request but hasn't completed it yet.
|
||||
Processing = 102,
|
||||
//
|
||||
// Summary:
|
||||
// Equivalent to HTTP status 103. System.Net.HttpStatusCode.EarlyHints indicates
|
||||
// to the client that the server is likely to send a final response with the header
|
||||
// fields included in the informational response.
|
||||
EarlyHints = 103,
|
||||
//
|
||||
// Summary:
|
||||
// Equivalent to HTTP status 200. System.Net.HttpStatusCode.OK indicates that the
|
||||
// request succeeded and that the requested information is in the response. This
|
||||
// is the most common status code to receive.
|
||||
OK = 200,
|
||||
//
|
||||
// Summary:
|
||||
// Equivalent to HTTP status 201. System.Net.HttpStatusCode.Created indicates that
|
||||
// the request resulted in a new resource created before the response was sent.
|
||||
Created = 201,
|
||||
//
|
||||
// Summary:
|
||||
// Equivalent to HTTP status 202. System.Net.HttpStatusCode.Accepted indicates that
|
||||
// the request has been accepted for further processing.
|
||||
Accepted = 202,
|
||||
//
|
||||
// Summary:
|
||||
// Equivalent to HTTP status 203. System.Net.HttpStatusCode.NonAuthoritativeInformation
|
||||
// indicates that the returned meta information is from a cached copy instead of
|
||||
// the origin server and therefore may be incorrect.
|
||||
NonAuthoritativeInformation = 203,
|
||||
//
|
||||
// Summary:
|
||||
// Equivalent to HTTP status 204. System.Net.HttpStatusCode.NoContent indicates
|
||||
// that the request has been successfully processed and that the response is intentionally
|
||||
// blank.
|
||||
NoContent = 204,
|
||||
//
|
||||
// Summary:
|
||||
// Equivalent to HTTP status 205. System.Net.HttpStatusCode.ResetContent indicates
|
||||
// that the client should reset (not reload) the current resource.
|
||||
ResetContent = 205,
|
||||
//
|
||||
// Summary:
|
||||
// Equivalent to HTTP status 206. System.Net.HttpStatusCode.PartialContent indicates
|
||||
// that the response is a partial response as requested by a GET request that includes
|
||||
// a byte range.
|
||||
PartialContent = 206,
|
||||
//
|
||||
// Summary:
|
||||
// Equivalent to HTTP status 207. System.Net.HttpStatusCode.MultiStatus indicates
|
||||
// multiple status codes for a single response during a Web Distributed Authoring
|
||||
// and Versioning (WebDAV) operation. The response body contains XML that describes
|
||||
// the status codes.
|
||||
MultiStatus = 207,
|
||||
//
|
||||
// Summary:
|
||||
// Equivalent to HTTP status 208. System.Net.HttpStatusCode.AlreadyReported indicates
|
||||
// that the members of a WebDAV binding have already been enumerated in a preceding
|
||||
// part of the multistatus response, and are not being included again.
|
||||
AlreadyReported = 208,
|
||||
//
|
||||
// Summary:
|
||||
// Equivalent to HTTP status 226. System.Net.HttpStatusCode.IMUsed indicates that
|
||||
// the server has fulfilled a request for the resource, and the response is a representation
|
||||
// of the result of one or more instance-manipulations applied to the current instance.
|
||||
IMUsed = 226,
|
||||
//
|
||||
// Summary:
|
||||
// Equivalent to HTTP status 300. System.Net.HttpStatusCode.Ambiguous indicates
|
||||
// that the requested information has multiple representations. The default action
|
||||
// is to treat this status as a redirect and follow the contents of the Location
|
||||
// header associated with this response. Ambiguous is a synonym for MultipleChoices.
|
||||
Ambiguous = 300,
|
||||
//
|
||||
// Summary:
|
||||
// Equivalent to HTTP status 300. System.Net.HttpStatusCode.MultipleChoices indicates
|
||||
// that the requested information has multiple representations. The default action
|
||||
// is to treat this status as a redirect and follow the contents of the Location
|
||||
// header associated with this response. MultipleChoices is a synonym for Ambiguous.
|
||||
MultipleChoices = 300,
|
||||
//
|
||||
// Summary:
|
||||
// Equivalent to HTTP status 301. System.Net.HttpStatusCode.Moved indicates that
|
||||
// the requested information has been moved to the URI specified in the Location
|
||||
// header. The default action when this status is received is to follow the Location
|
||||
// header associated with the response. When the original request method was POST,
|
||||
// the redirected request will use the GET method. Moved is a synonym for MovedPermanently.
|
||||
Moved = 301,
|
||||
//
|
||||
// Summary:
|
||||
// Equivalent to HTTP status 301. System.Net.HttpStatusCode.MovedPermanently indicates
|
||||
// that the requested information has been moved to the URI specified in the Location
|
||||
// header. The default action when this status is received is to follow the Location
|
||||
// header associated with the response. MovedPermanently is a synonym for Moved.
|
||||
MovedPermanently = 301,
|
||||
//
|
||||
// Summary:
|
||||
// Equivalent to HTTP status 302. System.Net.HttpStatusCode.Found indicates that
|
||||
// the requested information is located at the URI specified in the Location header.
|
||||
// The default action when this status is received is to follow the Location header
|
||||
// associated with the response. When the original request method was POST, the
|
||||
// redirected request will use the GET method. Found is a synonym for Redirect.
|
||||
Found = 302,
|
||||
//
|
||||
// Summary:
|
||||
// Equivalent to HTTP status 302. System.Net.HttpStatusCode.Redirect indicates that
|
||||
// the requested information is located at the URI specified in the Location header.
|
||||
// The default action when this status is received is to follow the Location header
|
||||
// associated with the response. When the original request method was POST, the
|
||||
// redirected request will use the GET method. Redirect is a synonym for Found.
|
||||
Redirect = 302,
|
||||
//
|
||||
// Summary:
|
||||
// Equivalent to HTTP status 303. System.Net.HttpStatusCode.RedirectMethod automatically
|
||||
// redirects the client to the URI specified in the Location header as the result
|
||||
// of a POST. The request to the resource specified by the Location header will
|
||||
// be made with a GET. RedirectMethod is a synonym for SeeOther.
|
||||
RedirectMethod = 303,
|
||||
//
|
||||
// Summary:
|
||||
// Equivalent to HTTP status 303. System.Net.HttpStatusCode.SeeOther automatically
|
||||
// redirects the client to the URI specified in the Location header as the result
|
||||
// of a POST. The request to the resource specified by the Location header will
|
||||
// be made with a GET. SeeOther is a synonym for RedirectMethod.
|
||||
SeeOther = 303,
|
||||
//
|
||||
// Summary:
|
||||
// Equivalent to HTTP status 304. System.Net.HttpStatusCode.NotModified indicates
|
||||
// that the client's cached copy is up to date. The contents of the resource are
|
||||
// not transferred.
|
||||
NotModified = 304,
|
||||
//
|
||||
// Summary:
|
||||
// Equivalent to HTTP status 305. System.Net.HttpStatusCode.UseProxy indicates that
|
||||
// the request should use the proxy server at the URI specified in the Location
|
||||
// header.
|
||||
UseProxy = 305,
|
||||
//
|
||||
// Summary:
|
||||
// Equivalent to HTTP status 306. System.Net.HttpStatusCode.Unused is a proposed
|
||||
// extension to the HTTP/1.1 specification that is not fully specified.
|
||||
Unused = 306,
|
||||
//
|
||||
// Summary:
|
||||
// Equivalent to HTTP status 307. System.Net.HttpStatusCode.RedirectKeepVerb indicates
|
||||
// that the request information is located at the URI specified in the Location
|
||||
// header. The default action when this status is received is to follow the Location
|
||||
// header associated with the response. When the original request method was POST,
|
||||
// the redirected request will also use the POST method. RedirectKeepVerb is a synonym
|
||||
// for TemporaryRedirect.
|
||||
RedirectKeepVerb = 307,
|
||||
//
|
||||
// Summary:
|
||||
// Equivalent to HTTP status 307. System.Net.HttpStatusCode.TemporaryRedirect indicates
|
||||
// that the request information is located at the URI specified in the Location
|
||||
// header. The default action when this status is received is to follow the Location
|
||||
// header associated with the response. When the original request method was POST,
|
||||
// the redirected request will also use the POST method. TemporaryRedirect is a
|
||||
// synonym for RedirectKeepVerb.
|
||||
TemporaryRedirect = 307,
|
||||
//
|
||||
// Summary:
|
||||
// Equivalent to HTTP status 308. System.Net.HttpStatusCode.PermanentRedirect indicates
|
||||
// that the request information is located at the URI specified in the Location
|
||||
// header. The default action when this status is received is to follow the Location
|
||||
// header associated with the response. When the original request method was POST,
|
||||
// the redirected request will also use the POST method.
|
||||
PermanentRedirect = 308,
|
||||
//
|
||||
// Summary:
|
||||
// Equivalent to HTTP status 400. System.Net.HttpStatusCode.BadRequest indicates
|
||||
// that the request could not be understood by the server. System.Net.HttpStatusCode.BadRequest
|
||||
// is sent when no other error is applicable, or if the exact error is unknown or
|
||||
// does not have its own error code.
|
||||
BadRequest = 400,
|
||||
//
|
||||
// Summary:
|
||||
// Equivalent to HTTP status 401. System.Net.HttpStatusCode.Unauthorized indicates
|
||||
// that the requested resource requires authentication. The WWW-Authenticate header
|
||||
// contains the details of how to perform the authentication.
|
||||
Unauthorized = 401,
|
||||
//
|
||||
// Summary:
|
||||
// Equivalent to HTTP status 402. System.Net.HttpStatusCode.PaymentRequired is reserved
|
||||
// for future use.
|
||||
PaymentRequired = 402,
|
||||
//
|
||||
// Summary:
|
||||
// Equivalent to HTTP status 403. System.Net.HttpStatusCode.Forbidden indicates
|
||||
// that the server refuses to fulfill the request.
|
||||
Forbidden = 403,
|
||||
//
|
||||
// Summary:
|
||||
// Equivalent to HTTP status 404. System.Net.HttpStatusCode.NotFound indicates that
|
||||
// the requested resource does not exist on the server.
|
||||
NotFound = 404,
|
||||
//
|
||||
// Summary:
|
||||
// Equivalent to HTTP status 405. System.Net.HttpStatusCode.MethodNotAllowed indicates
|
||||
// that the request method (POST or GET) is not allowed on the requested resource.
|
||||
MethodNotAllowed = 405,
|
||||
//
|
||||
// Summary:
|
||||
// Equivalent to HTTP status 406. System.Net.HttpStatusCode.NotAcceptable indicates
|
||||
// that the client has indicated with Accept headers that it will not accept any
|
||||
// of the available representations of the resource.
|
||||
NotAcceptable = 406,
|
||||
//
|
||||
// Summary:
|
||||
// Equivalent to HTTP status 407. System.Net.HttpStatusCode.ProxyAuthenticationRequired
|
||||
// indicates that the requested proxy requires authentication. The Proxy-authenticate
|
||||
// header contains the details of how to perform the authentication.
|
||||
ProxyAuthenticationRequired = 407,
|
||||
//
|
||||
// Summary:
|
||||
// Equivalent to HTTP status 408. System.Net.HttpStatusCode.RequestTimeout indicates
|
||||
// that the client did not send a request within the time the server was expecting
|
||||
// the request.
|
||||
RequestTimeout = 408,
|
||||
//
|
||||
// Summary:
|
||||
// Equivalent to HTTP status 409. System.Net.HttpStatusCode.Conflict indicates that
|
||||
// the request could not be carried out because of a conflict on the server.
|
||||
Conflict = 409,
|
||||
//
|
||||
// Summary:
|
||||
// Equivalent to HTTP status 410. System.Net.HttpStatusCode.Gone indicates that
|
||||
// the requested resource is no longer available.
|
||||
Gone = 410,
|
||||
//
|
||||
// Summary:
|
||||
// Equivalent to HTTP status 411. System.Net.HttpStatusCode.LengthRequired indicates
|
||||
// that the required Content-length header is missing.
|
||||
LengthRequired = 411,
|
||||
//
|
||||
// Summary:
|
||||
// Equivalent to HTTP status 412. System.Net.HttpStatusCode.PreconditionFailed indicates
|
||||
// that a condition set for this request failed, and the request cannot be carried
|
||||
// out. Conditions are set with conditional request headers like If-Match, If-None-Match,
|
||||
// or If-Unmodified-Since.
|
||||
PreconditionFailed = 412,
|
||||
//
|
||||
// Summary:
|
||||
// Equivalent to HTTP status 413. System.Net.HttpStatusCode.RequestEntityTooLarge
|
||||
// indicates that the request is too large for the server to process.
|
||||
RequestEntityTooLarge = 413,
|
||||
//
|
||||
// Summary:
|
||||
// Equivalent to HTTP status 414. System.Net.HttpStatusCode.RequestUriTooLong indicates
|
||||
// that the URI is too long.
|
||||
RequestUriTooLong = 414,
|
||||
//
|
||||
// Summary:
|
||||
// Equivalent to HTTP status 415. System.Net.HttpStatusCode.UnsupportedMediaType
|
||||
// indicates that the request is an unsupported type.
|
||||
UnsupportedMediaType = 415,
|
||||
//
|
||||
// Summary:
|
||||
// Equivalent to HTTP status 416. System.Net.HttpStatusCode.RequestedRangeNotSatisfiable
|
||||
// indicates that the range of data requested from the resource cannot be returned,
|
||||
// either because the beginning of the range is before the beginning of the resource,
|
||||
// or the end of the range is after the end of the resource.
|
||||
RequestedRangeNotSatisfiable = 416,
|
||||
//
|
||||
// Summary:
|
||||
// Equivalent to HTTP status 417. System.Net.HttpStatusCode.ExpectationFailed indicates
|
||||
// that an expectation given in an Expect header could not be met by the server.
|
||||
ExpectationFailed = 417,
|
||||
//
|
||||
// Summary:
|
||||
// Equivalent to HTTP status 421. System.Net.HttpStatusCode.MisdirectedRequest indicates
|
||||
// that the request was directed at a server that is not able to produce a response.
|
||||
MisdirectedRequest = 421,
|
||||
//
|
||||
// Summary:
|
||||
// Equivalent to HTTP status 422. System.Net.HttpStatusCode.UnprocessableEntity
|
||||
// indicates that the request was well-formed but was unable to be followed due
|
||||
// to semantic errors. UnprocessableEntity is a synonym for UnprocessableContent.
|
||||
UnprocessableEntity = 422,
|
||||
//
|
||||
// Summary:
|
||||
// Equivalent to HTTP status 422. System.Net.HttpStatusCode.UnprocessableContent
|
||||
// indicates that the request was well-formed but was unable to be followed due
|
||||
// to semantic errors. UnprocessableContent is a synonym for UnprocessableEntity.
|
||||
UnprocessableContent = 422,
|
||||
//
|
||||
// Summary:
|
||||
// Equivalent to HTTP status 423. System.Net.HttpStatusCode.Locked indicates that
|
||||
// the source or destination resource is locked.
|
||||
Locked = 423,
|
||||
//
|
||||
// Summary:
|
||||
// Equivalent to HTTP status 424. System.Net.HttpStatusCode.FailedDependency indicates
|
||||
// that the method couldn't be performed on the resource because the requested action
|
||||
// depended on another action and that action failed.
|
||||
FailedDependency = 424,
|
||||
//
|
||||
// Summary:
|
||||
// Equivalent to HTTP status 426. System.Net.HttpStatusCode.UpgradeRequired indicates
|
||||
// that the client should switch to a different protocol such as TLS/1.0.
|
||||
UpgradeRequired = 426,
|
||||
//
|
||||
// Summary:
|
||||
// Equivalent to HTTP status 428. System.Net.HttpStatusCode.PreconditionRequired
|
||||
// indicates that the server requires the request to be conditional.
|
||||
PreconditionRequired = 428,
|
||||
//
|
||||
// Summary:
|
||||
// Equivalent to HTTP status 429. System.Net.HttpStatusCode.TooManyRequests indicates
|
||||
// that the user has sent too many requests in a given amount of time.
|
||||
TooManyRequests = 429,
|
||||
//
|
||||
// Summary:
|
||||
// Equivalent to HTTP status 431. System.Net.HttpStatusCode.RequestHeaderFieldsTooLarge
|
||||
// indicates that the server is unwilling to process the request because its header
|
||||
// fields (either an individual header field or all the header fields collectively)
|
||||
// are too large.
|
||||
RequestHeaderFieldsTooLarge = 431,
|
||||
//
|
||||
// Summary:
|
||||
// Equivalent to HTTP status 451. System.Net.HttpStatusCode.UnavailableForLegalReasons
|
||||
// indicates that the server is denying access to the resource as a consequence
|
||||
// of a legal demand.
|
||||
UnavailableForLegalReasons = 451,
|
||||
//
|
||||
// Summary:
|
||||
// Equivalent to HTTP status 500. System.Net.HttpStatusCode.InternalServerError
|
||||
// indicates that a generic error has occurred on the server.
|
||||
InternalServerError = 500,
|
||||
//
|
||||
// Summary:
|
||||
// Equivalent to HTTP status 501. System.Net.HttpStatusCode.NotImplemented indicates
|
||||
// that the server does not support the requested function.
|
||||
NotImplemented = 501,
|
||||
//
|
||||
// Summary:
|
||||
// Equivalent to HTTP status 502. System.Net.HttpStatusCode.BadGateway indicates
|
||||
// that an intermediate proxy server received a bad response from another proxy
|
||||
// or the origin server.
|
||||
BadGateway = 502,
|
||||
//
|
||||
// Summary:
|
||||
// Equivalent to HTTP status 503. System.Net.HttpStatusCode.ServiceUnavailable indicates
|
||||
// that the server is temporarily unavailable, usually due to high load or maintenance.
|
||||
ServiceUnavailable = 503,
|
||||
//
|
||||
// Summary:
|
||||
// Equivalent to HTTP status 504. System.Net.HttpStatusCode.GatewayTimeout indicates
|
||||
// that an intermediate proxy server timed out while waiting for a response from
|
||||
// another proxy or the origin server.
|
||||
GatewayTimeout = 504,
|
||||
//
|
||||
// Summary:
|
||||
// Equivalent to HTTP status 505. System.Net.HttpStatusCode.HttpVersionNotSupported
|
||||
// indicates that the requested HTTP version is not supported by the server.
|
||||
HttpVersionNotSupported = 505,
|
||||
//
|
||||
// Summary:
|
||||
// Equivalent to HTTP status 506. System.Net.HttpStatusCode.VariantAlsoNegotiates
|
||||
// indicates that the chosen variant resource is configured to engage in transparent
|
||||
// content negotiation itself and, therefore, isn't a proper endpoint in the negotiation
|
||||
// process.
|
||||
VariantAlsoNegotiates = 506,
|
||||
//
|
||||
// Summary:
|
||||
// Equivalent to HTTP status 507. System.Net.HttpStatusCode.InsufficientStorage
|
||||
// indicates that the server is unable to store the representation needed to complete
|
||||
// the request.
|
||||
InsufficientStorage = 507,
|
||||
//
|
||||
// Summary:
|
||||
// Equivalent to HTTP status 508. System.Net.HttpStatusCode.LoopDetected indicates
|
||||
// that the server terminated an operation because it encountered an infinite loop
|
||||
// while processing a WebDAV request with "Depth: infinity". This status code is
|
||||
// meant for backward compatibility with clients not aware of the 208 status code
|
||||
// System.Net.HttpStatusCode.AlreadyReported appearing in multistatus response bodies.
|
||||
LoopDetected = 508,
|
||||
//
|
||||
// Summary:
|
||||
// Equivalent to HTTP status 510. System.Net.HttpStatusCode.NotExtended indicates
|
||||
// that further extensions to the request are required for the server to fulfill
|
||||
// it.
|
||||
NotExtended = 510,
|
||||
//
|
||||
// Summary:
|
||||
// Equivalent to HTTP status 511. System.Net.HttpStatusCode.NetworkAuthenticationRequired
|
||||
// indicates that the client needs to authenticate to gain network access; it's
|
||||
// intended for use by intercepting proxies used to control access to the network.
|
||||
NetworkAuthenticationRequired = 511
|
||||
Error = 1
|
||||
}
|
||||
@@ -4,31 +4,13 @@ namespace ReC.Domain.Constants;
|
||||
|
||||
public static class RecStatusExtensions
|
||||
{
|
||||
public static HttpStatusCode? ToHttpStatusCode(this RecStatus status)
|
||||
{
|
||||
int code = (int)status;
|
||||
|
||||
if (Enum.IsDefined(typeof(HttpStatusCode), code))
|
||||
{
|
||||
return (HttpStatusCode)code;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
public static bool IsSuccess(this HttpStatusCode code)
|
||||
/// <summary>
|
||||
/// Converts an <see cref="HttpStatusCode"/> to a general <see cref="RecStatus"/>
|
||||
/// based on whether the HTTP status represents a success (2xx) or an error.
|
||||
/// </summary>
|
||||
public static RecStatus ToRecStatus(this HttpStatusCode code)
|
||||
{
|
||||
int value = (int)code;
|
||||
return value >= 200 && value <= 299;
|
||||
return value >= 200 && value <= 299 ? RecStatus.OK : RecStatus.Error;
|
||||
}
|
||||
|
||||
public static bool IsSuccess(this RecStatus status)
|
||||
=> status switch
|
||||
{
|
||||
RecStatus.QuerySuccess => true,
|
||||
RecStatus.Failed => false,
|
||||
_ => status.ToHttpStatusCode() is HttpStatusCode httpStatus && httpStatus.IsSuccess()
|
||||
};
|
||||
|
||||
public static RecStatus ToRecStatus(this HttpStatusCode code) => (RecStatus)(short)code;
|
||||
}
|
||||
@@ -33,6 +33,9 @@ public class RecActionView
|
||||
[Column("PROFILE_TYPE_ID")]
|
||||
public ProfileType? ProfileType { get; set; }
|
||||
|
||||
[Column("PROFILE_TYPE")]
|
||||
public string? ProfileTypeName { get; set; }
|
||||
|
||||
[Column("SEQUENCE")]
|
||||
public byte? Sequence { get; set; }
|
||||
|
||||
|
||||
@@ -42,12 +42,36 @@ public class ResultView
|
||||
[Column("RESULT_BODY")]
|
||||
public string? Body { get; set; }
|
||||
|
||||
[Column("RESULT_INFO_ID")]
|
||||
public short? InfoId { get; set; }
|
||||
|
||||
[Column("RESULT_INFO")]
|
||||
public string? Info { get; set; }
|
||||
|
||||
[Column("RESULT_INFO_DETAIL")]
|
||||
public string? InfoDetail { get; set; }
|
||||
|
||||
[Column("RESULT_ERROR")]
|
||||
public string? Error { get; set; }
|
||||
|
||||
[Column("BATCH_ID")]
|
||||
public string? BatchId { get; set; }
|
||||
|
||||
[Column("REFERENCE1")]
|
||||
public string? Reference1 { get; set; }
|
||||
|
||||
[Column("REFERENCE2")]
|
||||
public string? Reference2 { get; set; }
|
||||
|
||||
[Column("REFERENCE3")]
|
||||
public string? Reference3 { get; set; }
|
||||
|
||||
[Column("REFERENCE4")]
|
||||
public string? Reference4 { get; set; }
|
||||
|
||||
[Column("REFERENCE5")]
|
||||
public string? Reference5 { get; set; }
|
||||
|
||||
[Column("ADDED_WHO")]
|
||||
public string? AddedWho { get; set; }
|
||||
|
||||
|
||||
@@ -29,7 +29,7 @@ public static class DependencyInjection
|
||||
opt.RegisterDefaultRepository<TRecDbContext>();
|
||||
});
|
||||
|
||||
services.AddValidatorsFromAssembly(typeof(AuthScopedValidator).Assembly);
|
||||
services.AddValidatorsFromAssembly(typeof(InsertObjectProcedureValidator).Assembly);
|
||||
|
||||
return services;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
using System.Linq;
|
||||
using System.Threading.Tasks;
|
||||
using FluentValidation;
|
||||
using MediatR;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using NUnit.Framework;
|
||||
using ReC.Application.RecActions.Commands;
|
||||
using ReC.Application.Results.Queries;
|
||||
|
||||
namespace ReC.Tests.Application.RecActions;
|
||||
|
||||
[TestFixture]
|
||||
public class InvokeBatchDuplicateGuardTests : RecApplicationTestBase
|
||||
{
|
||||
private const long ProfileId = 3;
|
||||
|
||||
private (ISender Sender, IServiceScope Scope) CreateScopedSender()
|
||||
{
|
||||
var scope = ServiceProvider.CreateScope();
|
||||
var sender = scope.ServiceProvider.GetRequiredService<ISender>();
|
||||
return (sender, scope);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Invoke_with_existing_batchId_throws_ValidationException()
|
||||
{
|
||||
var (sender, scope) = CreateScopedSender();
|
||||
using var _ = scope;
|
||||
|
||||
// Arrange: read an existing result to get a real BatchId from the database
|
||||
var results = await sender.Send(new ReadResultViewQuery
|
||||
{
|
||||
ProfileId = ProfileId,
|
||||
IncludeAction = false,
|
||||
LastBatch = true
|
||||
});
|
||||
|
||||
var existingBatchId = results.FirstOrDefault()?.BatchId;
|
||||
Assert.That(existingBatchId, Is.Not.Null.And.Not.Empty,
|
||||
$"No results with a BatchId found for ProfileId {ProfileId}. Ensure test data exists in the database.");
|
||||
|
||||
// Act & Assert: invoking with the same BatchId should throw ValidationException
|
||||
var ex = Assert.ThrowsAsync<ValidationException>(async () =>
|
||||
await sender.Send(new InvokeBatchRecActionViewsCommand
|
||||
{
|
||||
ProfileId = ProfileId,
|
||||
References = new InvokeReferences
|
||||
{
|
||||
BatchId = existingBatchId!
|
||||
}
|
||||
}));
|
||||
|
||||
Assert.That(ex!.Errors.Any(e => e.PropertyName.Contains("BatchId")));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Invoke_with_new_batchId_does_not_throw_duplicate_guard()
|
||||
{
|
||||
var (sender, scope) = CreateScopedSender();
|
||||
using var _ = scope;
|
||||
|
||||
var uniqueBatchId = $"test-{System.Guid.NewGuid():N}";
|
||||
|
||||
// This should NOT throw ValidationException for duplicate BatchId.
|
||||
// It may throw other exceptions (e.g., no actions found, endpoint errors),
|
||||
// but the duplicate guard should pass.
|
||||
try
|
||||
{
|
||||
sender.Send(new InvokeBatchRecActionViewsCommand
|
||||
{
|
||||
ProfileId = ProfileId,
|
||||
References = new InvokeReferences
|
||||
{
|
||||
BatchId = uniqueBatchId
|
||||
}
|
||||
}).GetAwaiter().GetResult();
|
||||
}
|
||||
catch (ValidationException valEx) when (valEx.Errors.Any(e => e.PropertyName.Contains("BatchId")))
|
||||
{
|
||||
Assert.Fail("Duplicate guard should not trigger for a unique BatchId.");
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Other exceptions (endpoint errors, etc.) are acceptable
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -6,6 +6,7 @@ using NUnit.Framework;
|
||||
using ReC.Application.Common.Procedures.DeleteProcedure;
|
||||
using ReC.Application.Common.Procedures.InsertProcedure;
|
||||
using ReC.Application.Common.Procedures.UpdateProcedure;
|
||||
using ReC.Application.RecActions.Commands;
|
||||
using ReC.Application.Results.Commands;
|
||||
using ReC.Domain.Constants;
|
||||
using ReC.Tests.Application;
|
||||
@@ -25,7 +26,7 @@ public class ResultProcedureTests : RecApplicationTestBase
|
||||
[Test]
|
||||
public async Task InsertResultProcedure_runs_via_mediator()
|
||||
{
|
||||
var procedure = new InsertResultCommand { ActionId = 1, Status = HttpStatusCode.OK.ToRecStatus(), Header = "h", Body = "b", Type = Domain.Constants.ResultType.Main };
|
||||
var procedure = new InsertResultCommand { ActionId = 1, Status = HttpStatusCode.OK.ToRecStatus(), Header = "h", Body = "b", Info = 200, Type = ResultType.Main, References = new () { BatchId = DateTime.Now.ToString() } };
|
||||
|
||||
var (sender, scope) = CreateScopedSender();
|
||||
using var _ = scope;
|
||||
|
||||
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>
|
||||
<PackageReference Include="coverlet.collector" Version="6.0.0" />
|
||||
<PackageReference Include="MediatR" Version="14.0.0" />
|
||||
<PackageReference Include="Microsoft.AspNetCore.Mvc.Testing" Version="8.0.0" />
|
||||
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.8.0" />
|
||||
<PackageReference Include="Microsoft.Extensions.Configuration.Json" Version="10.0.0" />
|
||||
<PackageReference Include="Moq" Version="4.20.72" />
|
||||
@@ -21,7 +22,9 @@
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\src\ReC.API\ReC.API.csproj" />
|
||||
<ProjectReference Include="..\..\src\ReC.Application\ReC.Application.csproj" />
|
||||
<ProjectReference Include="..\..\src\ReC.Client\ReC.Client.csproj" />
|
||||
<ProjectReference Include="..\..\src\ReC.Infrastructure\ReC.Infrastructure.csproj" />
|
||||
<ProjectReference Include="..\..\src\ReC.Domain\ReC.Domain.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
Reference in New Issue
Block a user