20 Commits

Author SHA1 Message Date
6d8e51ad70 Add ReCClientOptions for configurable client behavior
Introduced a new `ReCClientOptions` property to `BaseCrudApi`
and its derived classes to enhance flexibility and control
over client behavior. Updated constructors to accept an
optional `ReCClientOptions` parameter, with default options
applied when omitted.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

A constructor was added to initialize these properties. Conditional compilation directives ensure compatibility between .NET Framework and other .NET targets. The class is marked as `[Serializable]` for non-.NET Framework targets.
2026-05-19 18:56:43 +02:00
992395dec3 Ensure proper disposal of resources in InvokeAsync
Updated the `InvokeAsync` method in the `ReC.Client.Api` namespace to use `using` statements for the `content` and `resp` objects. This change ensures proper disposal of these resources, improving memory management and preventing potential leaks. The functional behavior of the method remains unchanged.
2026-05-19 18:44:55 +02:00
82ec333f23 Refactor API methods to return bool for success status
Updated `CreateAsync<T>`, `UpdateAsync<T>`, and `DeleteAsync<T>`
methods to return a `bool` indicating success instead of
`HttpResponseMessage`. Added `using` statements to ensure proper
disposal of HTTP content and response objects. Simplified the
interface for better usability by leveraging `IsSuccessStatusCode`
to determine operation success.
2026-05-19 18:44:44 +02:00
a924e32291 Bump version to 2.4.0-beta in ReC.API.csproj
Updated the project version, assembly version, file version, and informational version from 2.3.0-beta to 2.4.0-beta in the ReC.API.csproj file. This prepares the project for the next beta release.
2026-04-17 15:14:32 +02:00
28a4146069 Auto-detect Content-Type and Accept headers if enabled
Add AutoDetectHeaders option to RecAction config and handler. When enabled, automatically set Content-Type based on body content (JSON or XML) and default Accept to application/json if missing. Log warnings when headers are auto-detected. Improves robustness and makes header detection configurable.
2026-04-17 15:13:33 +02:00
17d40817f2 Update version to 2.3.0-beta
Bump Version, AssemblyVersion, FileVersion, and InformationalVersion to 2.3.0-beta in ReC.API.csproj.
2026-04-17 12:42:04 +02:00
330443d2c9 Add config options for EF Core logging and error details
EnableSensitiveDataLogging and EnableDetailedErrors are now configurable via appsettings.json under the "EfCore" section. Program.cs reads these values from configuration instead of hardcoding them, allowing runtime control of EF Core logging and error detail behavior.
2026-04-17 12:41:45 +02:00
6ca876c762 Improve HTTP header handling and error resilience
Enhance InvokeRecActionViewCommandHandler to robustly handle invalid or non-strict HTTP header values. "Content-Type" and "Authorization" headers are now set using strict parsing with fallback to TryAddWithoutValidation on failure, logging warnings for easier debugging. General headers and API key headers also use this strict-then-fallback approach, making HTTP request construction more tolerant of malformed values and reducing runtime errors.
2026-04-17 12:17:41 +02:00
e89af1cbcd Improve HTTP header handling and add logging to handler
Inject optional ILogger into InvokeRecActionViewCommandHandler for enhanced logging. When adding HTTP headers, catch FormatException and log a warning with relevant context, then fall back to TryAddWithoutValidation. This increases robustness and observability for malformed or non-standard headers.
2026-04-17 12:01:15 +02:00
18 changed files with 405 additions and 55 deletions

View File

@@ -48,10 +48,13 @@ try
?? throw new InvalidOperationException("Connection string is not found.");
var logger = provider.GetRequiredService<ILogger<RecDbContext>>();
var enableSensitiveDataLogging = config.GetValue("EfCore:EnableSensitiveDataLogging", true);
var enableDetailedErrors = config.GetValue("EfCore:EnableDetailedErrors", false);
opt.UseSqlServer(cnnStr)
.LogTo(log => logger.LogInformation("{log}", log), LogLevel.Trace)
.EnableSensitiveDataLogging()
.EnableDetailedErrors();
.EnableSensitiveDataLogging(enableSensitiveDataLogging)
.EnableDetailedErrors(enableDetailedErrors);
});
});

View File

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

View File

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

View File

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

View File

@@ -1,5 +1,6 @@
using MediatR;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using ReC.Application.Common.Constants;
using ReC.Application.Common.Dto;
@@ -35,7 +36,8 @@ public class InvokeRecActionViewCommandHandler(
IOptions<RecActionOptions> options,
ISender sender,
IHttpClientFactory clientFactory,
IConfiguration? config = null
IConfiguration? config = null,
ILogger<InvokeRecActionViewCommandHandler>? logger = null
) : IRequestHandler<InvokeRecActionViewCommand>
{
private readonly RecActionOptions _options = options.Value;
@@ -57,11 +59,47 @@ public class InvokeRecActionViewCommandHandler(
using var httpReq = CreateHttpRequestMessage(restType, action.EndpointUri);
if (action.Body is not null)
{
httpReq.Content = new StringContent(action.Body);
var contentType = action.Headers?.FirstOrDefault(h => h.Key.Equals("Content-Type", StringComparison.OrdinalIgnoreCase));
if (contentType is not null && !string.IsNullOrWhiteSpace(contentType.Value.Value))
try { httpReq.Content.Headers.ContentType = MediaTypeHeaderValue.Parse(contentType.Value.Value); }
catch (FormatException ex)
{
logger?.LogWarning(ex, "Content-Type '{Value}' could not be parsed with strict validation, falling back to TryAddWithoutValidation. ActionId: {ActionId}, ProfileId: {ProfileId}", contentType.Value.Value, action.Id, action.ProfileId);
httpReq.Content.Headers.TryAddWithoutValidation("Content-Type", contentType.Value.Value);
}
else if (_options.AutoDetectHeaders)
{
var body = action.Body.TrimStart();
if (body.StartsWith('{') || body.StartsWith('['))
{
httpReq.Content.Headers.ContentType = new MediaTypeHeaderValue("application/json") { CharSet = "utf-8" };
logger?.LogWarning("Content-Type header was not specified. Auto-detected 'application/json; charset=utf-8' based on body content. ActionId: {ActionId}, ProfileId: {ProfileId}", action.Id, action.ProfileId);
}
else if (body.StartsWith('<'))
{
httpReq.Content.Headers.ContentType = new MediaTypeHeaderValue("application/xml") { CharSet = "utf-8" };
logger?.LogWarning("Content-Type header was not specified. Auto-detected 'application/xml; charset=utf-8' based on body content. ActionId: {ActionId}, ProfileId: {ProfileId}", action.Id, action.ProfileId);
}
}
}
if (action.Headers is not null)
foreach (var header in action.Headers)
httpReq.Headers.Add(header.Key, header.Value);
foreach (var header in action.Headers.Where(h => !h.Key.Equals("Content-Type", StringComparison.OrdinalIgnoreCase)))
try { httpReq.Headers.Add(header.Key, header.Value); }
catch (FormatException ex)
{
logger?.LogWarning(ex, "Header '{Key}' could not be added with strict validation, falling back to TryAddWithoutValidation. ActionId: {ActionId}, ProfileId: {ProfileId}", header.Key, action.Id, action.ProfileId);
httpReq.Headers.TryAddWithoutValidation(header.Key, header.Value);
}
if (_options.AutoDetectHeaders && !httpReq.Headers.Contains("Accept"))
{
httpReq.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json"));
logger?.LogWarning("Accept header was not specified. Defaulting to 'application/json'. ActionId: {ActionId}, ProfileId: {ProfileId}", action.Id, action.ProfileId);
}
switch (action.EndpointAuthType)
{
@@ -74,7 +112,12 @@ public class InvokeRecActionViewCommandHandler(
switch (action.EndpointAuthApiKeyAddTo)
{
case ApiKeyLocation.Header:
httpReq.Headers.Add(apiKey, apiValue);
try { httpReq.Headers.Add(apiKey, apiValue); }
catch (FormatException ex)
{
logger?.LogWarning(ex, "ApiKey header '{Key}' could not be added with strict validation, falling back to TryAddWithoutValidation. ActionId: {ActionId}, ProfileId: {ProfileId}", apiKey, action.Id, action.ProfileId);
httpReq.Headers.TryAddWithoutValidation(apiKey, apiValue);
}
break;
case ApiKeyLocation.Query:
var uriBuilder = new UriBuilder(httpReq.RequestUri!);
@@ -97,14 +140,24 @@ public class InvokeRecActionViewCommandHandler(
case EndpointAuthType.JwtBearer:
case EndpointAuthType.OAuth2:
if (action.EndpointAuthToken is string authToken)
httpReq.Headers.Authorization = new AuthenticationHeaderValue("Bearer", authToken);
try { httpReq.Headers.Authorization = new AuthenticationHeaderValue("Bearer", authToken); }
catch (FormatException ex)
{
logger?.LogWarning(ex, "Bearer token could not be set with strict validation, falling back to TryAddWithoutValidation. ActionId: {ActionId}, ProfileId: {ProfileId}", action.Id, action.ProfileId);
httpReq.Headers.TryAddWithoutValidation("Authorization", $"Bearer {authToken}");
}
break;
case EndpointAuthType.BasicAuth:
if (action.EndpointAuthUsername is string authUsername && action.EndpointAuthPassword is string authPassword)
{
var basicAuth = Convert.ToBase64String(Encoding.UTF8.GetBytes($"{authUsername}:{authPassword}"));
httpReq.Headers.Authorization = new AuthenticationHeaderValue("Basic", basicAuth);
try { httpReq.Headers.Authorization = new AuthenticationHeaderValue("Basic", basicAuth); }
catch (FormatException ex)
{
logger?.LogWarning(ex, "Basic auth could not be set with strict validation, falling back to TryAddWithoutValidation. ActionId: {ActionId}, ProfileId: {ProfileId}", action.Id, action.ProfileId);
httpReq.Headers.TryAddWithoutValidation("Authorization", $"Basic {basicAuth}");
}
}
break;

View File

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

View File

@@ -1,6 +1,7 @@
using System.Net.Http;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.Logging;
namespace ReC.Client.Api
{
@@ -13,7 +14,13 @@ namespace ReC.Client.Api
/// Initializes a new instance of the <see cref="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
{
}
}

View File

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

View File

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

View File

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

View File

@@ -1,6 +1,7 @@
using System.Net.Http;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.Logging;
namespace ReC.Client.Api
{
@@ -13,7 +14,13 @@ 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
{
}

View File

@@ -1,6 +1,7 @@
using System.Net.Http;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.Logging;
namespace ReC.Client.Api
{
@@ -13,7 +14,13 @@ namespace ReC.Client.Api
/// Initializes a new instance of the <see cref="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
{
}
@@ -23,12 +30,15 @@ namespace ReC.Client.Api
/// <param name="profileId">The profile identifier.</param>
/// <param name="references">Optional reference values to pass through to all result records.</param>
/// <param name="cancellationToken">A token to cancel the operation.</param>
/// <returns><see langword="true"/> if the request succeeds; otherwise, <see langword="false"/>.</returns>
public async Task<bool> InvokeAsync(int profileId, InvokeReferences references, CancellationToken cancellationToken = default)
/// <exception cref="ReCApiException">Thrown when the API responds with a non-success status code.</exception>
public async Task InvokeAsync(int profileId, InvokeReferences references, CancellationToken cancellationToken = default)
{
var content = references != null ? ReCClientHelpers.ToJsonContent(references) : null;
var resp = await Http.PostAsync($"{ResourcePath}/invoke/{profileId}", content, cancellationToken);
return resp.IsSuccessStatusCode;
using (content)
using (var resp = await Http.PostAsync($"{ResourcePath}/invoke/{profileId}", content, cancellationToken))
{
await ReCClientHelpers.HandleResponseAsync(resp, Logger, Options.LogSuccessfulRequests, cancellationToken).ConfigureAwait(false);
}
}
/// <summary>
@@ -37,8 +47,8 @@ namespace ReC.Client.Api
/// <param name="profileId">The profile identifier.</param>
/// <param name="batchId">Batch identifier.</param>
/// <param name="cancellationToken">A token to cancel the operation.</param>
/// <returns><see langword="true"/> if the request succeeds; otherwise, <see langword="false"/>.</returns>
public Task<bool> InvokeAsync(int profileId, string batchId, CancellationToken cancellationToken = default)
/// <exception cref="ReCApiException">Thrown when the API responds with a non-success status code.</exception>
public Task InvokeAsync(int profileId, string batchId, CancellationToken cancellationToken = default)
{
return InvokeAsync(profileId, new InvokeReferences() { BatchId = batchId }, cancellationToken);
}

View File

@@ -1,6 +1,7 @@
using System.Net.Http;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.Logging;
namespace ReC.Client.Api
{
@@ -13,7 +14,13 @@ namespace ReC.Client.Api
/// Initializes a new instance of the <see cref="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
{
}

View File

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

View File

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

View File

@@ -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,16 +58,23 @@ 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();
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

View File

@@ -1,11 +1,11 @@
using System;
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.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.Logging;
namespace ReC.Client
{
@@ -45,5 +45,67 @@ 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>
/// Logs the outcome of an HTTP response. Throws a <see cref="ReCApiException"/> when the
/// response indicates a non-success status code; otherwise (optionally) writes an informational
/// log entry containing the request and response details.
/// </summary>
/// <param name="response">The HTTP response to inspect.</param>
/// <param name="logger">An optional logger used to record the outcome. May be <see langword="null"/>.</param>
/// <param name="logSuccess">When <see langword="false"/>, successful responses are not logged.</param>
/// <param name="cancel">A token to cancel the operation.</param>
#if NETFRAMEWORK
public static async Task HandleResponseAsync(HttpResponseMessage response, ILogger logger = null, bool logSuccess = true, CancellationToken cancel = default)
#else
public static async Task 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 (response.IsSuccessStatusCode)
{
if (logSuccess)
{
logger?.LogInformation(
"ReC API request succeeded. {Method} {Uri} -> {StatusCode} ({ReasonPhrase})",
method,
uri,
statusCode,
response.ReasonPhrase);
}
return;
}
#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
{
// Swallow body read failures; status info is still propagated.
}
}
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);
}
}
}

View File

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