Skip to content

Commit

Permalink
- Enhance/simplify WebApiPublisher. (#88)
Browse files Browse the repository at this point in the history
- Code optimizations in AspNetCore.
- Validation UseJsonName setting.
- Documentation tweaks.

Signed-off-by: Eric Sibly [chullybun] <[email protected]>
  • Loading branch information
chullybun authored Jan 26, 2024
1 parent 2981d4c commit 40b4fa8
Show file tree
Hide file tree
Showing 34 changed files with 738 additions and 1,075 deletions.
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,10 @@

Represents the **NuGet** versions.

## v3.10.0
- *Enhancement*: The `WebApiPublisher` publishing methods have been simplified (breaking change), primarily through the use of a new _argument_ that encapsulates the various related options. This will enable the addition of further options in the future without resulting in breaking changes or adding unneccessary complexities. The related [`README`](./src/CoreEx.AspNetCore/WebApis/README.md) has been updated to document.
- *Enhancement*: Added `ValidationUseJsonNames` to `SettingsBase` (defaults to `true`) to allow setting `ValidationArgs.DefaultUseJsonNames` to be configurable.

## v3.9.0
- *Enhancement*: A new `Abstractions.ServiceBusMessageActions` has been created to encapsulate either a `Microsoft.Azure.WebJobs.ServiceBus.ServiceBusMessageActions` (existing [_in-process_](https://learn.microsoft.com/en-us/azure/azure-functions/functions-dotnet-class-library) function support) or `Microsoft.Azure.Functions.Worker.ServiceBusMessageActions` (new [_isolated_](https://learn.microsoft.com/en-us/azure/azure-functions/dotnet-isolated-process-guide) function support) and used internally. Implicit conversion is enabled to simplify usage; existing projects will need to be recompiled. The latter capability does not support `RenewAsync` and as such this capability is no longer leveraged for consistency; review documented [`PeekLock`](https://learn.microsoft.com/en-us/azure/azure-functions/functions-bindings-service-bus-trigger?tabs=python-v2%2Cisolated-process%2Cnodejs-v4%2Cextensionv5&pivots=programming-language-csharp#peeklock-behavior) behavior to get desired outcome.
- *Enhancement*: The `Result`, `Result<T>`, `PagingArgs` and `PagingResult` have had `IEquatable` added to enable equality comparisons.
Expand Down
2 changes: 1 addition & 1 deletion Common.targets
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
<Project>
<PropertyGroup>
<Version>3.9.0</Version>
<Version>3.10.0</Version>
<LangVersion>preview</LangVersion>
<Authors>Avanade</Authors>
<Company>Avanade</Company>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -33,5 +33,5 @@ public HttpTriggerQueueVerificationFunction(WebApiPublisher webApiPublisher, HrS
[OpenApiRequestBody(MediaTypeNames.Application.Json, typeof(EmployeeVerificationRequest), Description = "The **EmployeeVerification** payload")]
[OpenApiResponseWithBody(statusCode: HttpStatusCode.Accepted, contentType: MediaTypeNames.Text.Plain, bodyType: typeof(string), Description = "The OK response")]
public Task<IActionResult> RunAsync([HttpTrigger(AuthorizationLevel.Function, "post", Route = "employee/verify")] HttpRequest request)
=> _webApiPublisher.PublishAsync(request, _settings.VerificationQueueName, validator: new EmployeeVerificationValidator().Wrap());
=> _webApiPublisher.PublishAsync(request, new WebApiPublisherArgs<EmployeeVerificationRequest>(_settings.VerificationQueueName) { Validator = new EmployeeVerificationValidator().Wrap() });
}
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ public static class AspNetCoreServiceCollectionExtensions
/// <summary>
/// Checks that the <see cref="IServiceCollection"/> is not null.
/// </summary>
private static IServiceCollection CheckServices(IServiceCollection services) => services ?? throw new ArgumentNullException(nameof(services));
private static IServiceCollection CheckServices(IServiceCollection services) => services.ThrowIfNull(nameof(services));

/// <summary>
/// Adds the <see cref="WebApi"/> as a scoped service.
Expand Down
18 changes: 4 additions & 14 deletions src/CoreEx.AspNetCore/HealthChecks/HealthService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -15,21 +15,11 @@ namespace CoreEx.AspNetCore.HealthChecks
/// <summary>
/// Provides the Health Check service.
/// </summary>
public class HealthService
public class HealthService(SettingsBase settings, HealthCheckService healthCheckService, IJsonSerializer jsonSerializer)
{
private readonly SettingsBase _settings;
private readonly HealthCheckService _healthCheckService;
private readonly IJsonSerializer _jsonSerializer;

/// <summary>
/// Initializes a new instance of the <see cref="HealthService"/> class.
/// </summary>
public HealthService(SettingsBase settings, HealthCheckService healthCheckService, IJsonSerializer jsonSerializer)
{
_settings = settings ?? throw new ArgumentNullException(nameof(settings));
_healthCheckService = healthCheckService ?? throw new ArgumentNullException(nameof(healthCheckService));
_jsonSerializer = jsonSerializer ?? throw new ArgumentNullException(nameof(jsonSerializer));
}
private readonly SettingsBase _settings = settings.ThrowIfNull(nameof(settings));
private readonly HealthCheckService _healthCheckService = healthCheckService.ThrowIfNull(nameof(healthCheckService));
private readonly IJsonSerializer _jsonSerializer = jsonSerializer.ThrowIfNull(nameof(jsonSerializer));

/// <summary>
/// Runs the health check and returns JSON result.
Expand Down
10 changes: 4 additions & 6 deletions src/CoreEx.AspNetCore/Http/HttpResultExtensions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -38,8 +38,7 @@ public static class HttpResultExtensions
/// <remarks>This will automatically invoke <see cref="ApplyETag(HttpRequest, string)"/> where there is an <see cref="HttpRequestOptions.ETag"/> value.</remarks>
public static HttpRequest ApplyRequestOptions(this HttpRequest httpRequest, HttpRequestOptions? requestOptions)
{
if (httpRequest == null)
throw new ArgumentNullException(nameof(httpRequest));
httpRequest.ThrowIfNull(nameof(httpRequest));

if (requestOptions == null)
return httpRequest;
Expand Down Expand Up @@ -87,8 +86,7 @@ public static HttpRequest ApplyETag(this HttpRequest httpRequest, string? etag)
/// <returns>The <see cref="HttpRequestJsonValue{T}"/>.</returns>
public static async Task<HttpRequestJsonValue<T>> ReadAsJsonValueAsync<T>(this HttpRequest httpRequest, IJsonSerializer jsonSerializer, bool valueIsRequired = true, IValidator<T>? validator = null, CancellationToken cancellationToken = default)
{
if (httpRequest == null)
throw new ArgumentNullException(nameof(httpRequest));
httpRequest.ThrowIfNull(nameof(httpRequest));

var content = await BinaryData.FromStreamAsync(httpRequest.Body, cancellationToken).ConfigureAwait(false);
var jv = new HttpRequestJsonValue<T>();
Expand All @@ -97,7 +95,7 @@ public static async Task<HttpRequestJsonValue<T>> ReadAsJsonValueAsync<T>(this H
try
{
if (content.ToMemory().Length > 0)
jv.Value = (jsonSerializer ?? throw new ArgumentNullException(nameof(jsonSerializer))).Deserialize<T>(content)!;
jv.Value = jsonSerializer.ThrowIfNull(nameof(jsonSerializer)).Deserialize<T>(content)!;

if (valueIsRequired && jv.Value == null)
jv.ValidationException = new ValidationException($"{InvalidJsonMessagePrefix} Value is mandatory.");
Expand Down Expand Up @@ -138,7 +136,7 @@ public static async Task<HttpRequestJsonValue<T>> ReadAsJsonValueAsync<T>(this H
/// </summary>
/// <param name="httpRequest">The <see cref="HttpRequest"/>.</param>
/// <returns>The <see cref="WebApiRequestOptions"/>.</returns>
public static WebApiRequestOptions GetRequestOptions(this HttpRequest httpRequest) => new(httpRequest ?? throw new ArgumentNullException(nameof(httpRequest)));
public static WebApiRequestOptions GetRequestOptions(this HttpRequest httpRequest) => new(httpRequest.ThrowIfNull(nameof(httpRequest)));

/// <summary>
/// Adds the <see cref="PagingArgs"/> to the <see cref="IHeaderDictionary"/>.
Expand Down
47 changes: 40 additions & 7 deletions src/CoreEx.AspNetCore/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -171,7 +171,7 @@ public class EmployeeFunction

## WebApiPublish

The [`WebApiPublisher`](./WebApis/WebApiPublisher.cs) class should be leveraged for fire-and-forget style APIs, where the message is received, validated and then published as an event for out-of-process decoupled processing.
The [`WebApiPublisher`](./WebApis/WebApiPublisher.cs) class should be leveraged for _fire-and-forget_ style APIs, where the message is received, validated and then published as an event for out-of-process decoupled processing.

The `WebApiPublish` extends (inherits) [`WebApiBase`](./WebApis/WebApiBase.cs) that provides the base `RunAsync` method described [above](#WebApi).

Expand All @@ -181,12 +181,46 @@ The `WebApiPublisher` constructor takes an [`IEventPublisher`](../CoreEx/Events/

### Supported HTTP methods

A publish should be performed using an HTTP `POST` and as such this is the only HTTP method supported. The `WebApiPublish` provides the following overloads depending on need. Where a generic `Type` is specified, either `TValue` being the request content body and/or `TResult` being the response body, this signifies that `WebApi` will manage the underlying JSON serialization:
A publish should be performed using an HTTP `POST` and as such this is the only HTTP method supported. The `WebApiPublish` provides the following overloads depending on need.

HTTP | Method | Description
-|-|-
`POST` | `PublishAsync<TValue>()` | Publish a single message/event with `TValue` being the request content body.
`POST` | `PublishAsync<TColl, TItem>()` | Publish zero or more message/event(s) from the `TColl` collection with an item type of `TItem`.
`POST` | `PublishValueAsync<TValue>()` | Publish a single message/event with `TValue` being the specified value (preivously deserialized).
`POST` | `PublishAsync<TValue, TEventValue>()` | Publish a single message/event with `TValue` being the request content body mapping to the specified event value type.
`POST` | `PublishValueAsync<TValue, TEventValue>()` | Publish a single message/event with `TValue` being the specified value (preivously deserialized) mapping to the specified event value type.
- | -
`POST` | `PublishCollectionAsync<TColl, TItem>()` | Publish zero or more message/event(s) from the `TColl` collection with an item type of `TItem` being the request content body.
`POST` | `PublishCollectionValueAsync<TColl, TItem>()` | Publish zero or more message/event(s) from the `TColl` collection with an item type of `TItem` being the specified value (preivously deserialized).
`POST` | `PublishCollectionAsync<TColl, TItem, TEventItem>()` | Publish zero or more message/event(s) from the `TColl` collection with an item type of `TItem` being the request content body mapping to the specified event value type.
`POST` | `PublishCollectionValueAsync<TColl, TItem, TEventItem>()` | Publish zero or more message/event(s) from the `TColl` collection with an item type of `TItem` being the specified value (preivously deserialized) mapping to the specified event value type.

<br/>

### Argument

Depending on the overload used (as defined above), an optional _argument_ can be specified that provides additional opportunities to configure and add additional logic into the underlying publishing orchestration.

The following argurment types are supported:
- [`WebApiPublisherArgs<TValue>`](./WebApis/WebApiPublisherArgsT.cs) - single message with no mapping.
- [`WebApiPublisherArgs<TValue, TEventValue>`](./WebApis/WebApiPublisherArgsT2.cs) - single message _with_ mapping.
- [`WebApiPublisherCollectionArgs<TColl, TItem>`](./WebApis/WebApiPublisherCollectionArgsT.cs) - collection of messages with no mapping.
- [`WebApiPublisherCollectionArgs<TColl, TItem, TEventItem>`](./WebApis/WebApiPublisherCollectionArgsT2.cs) - collection of messages _with_ mapping.

The arguments will have the following properties depending on the supported functionality. The sequence defines the order in which each of the properties is enacted (orchestrated) internally. Where a failure or exception occurs then the execution will be aborted and the corresponding `IActionResult` returned (including the likes of logging etc. where applicable).

Property | Description | Sequence
-|-
`EventName` | The event destintion name (e.g. Queue or Topic name) where applicable. | N/A
`StatusCode` | The resulting status code where successful. Defaults to `204-Accepted`. | N/A
`OperationType` | The [`OperationType`](../CoreEx/OperationType.cs). Defaults to `OperationType.Unspecified`. | N/A
`MaxCollectionSize` | The maximum collection size allowed/supported. | 1
`OnBeforeValidateAsync` | The function to be invoked before the request value is validated; opportunity to modify contents. | 2
`Validator` | The `IValidator<T>` to validate the request value. | 3
`OnBeforeEventAsync` | The function to be invoked after validation / before event; opportunity to modify contents. | 4
`Mapper` | The `IMapper<TSource, TDestination>` override. | 5
`OnEvent` | The action to be invoked once converted to an [`EventData`](../CoreEx/Events/EventData.cs); opportunity to modify contents. | 6
`CreateSuccessResult` | The function to be invoked to create/override the success `IActionResult`. | 7

<br/>

Expand All @@ -198,7 +232,7 @@ A request body is mandatory and must be serialized JSON as per the specified gen

### Response

The response HTTP status code is `204-Accepted` (default) with no content.
The response HTTP status code is `204-Accepted` (default) with no content. This can be overridden using the arguments `StatusCode` property.

<br/>

Expand All @@ -218,7 +252,6 @@ public class HttpTriggerQueueVerificationFunction
_settings = settings;
}

[FunctionName(nameof(HttpTriggerQueueVerificationFunction))]
public Task<IActionResult> RunAsync([HttpTrigger(AuthorizationLevel.Function, "post", Route = "employee/verify")] HttpRequest request)
=> _webApiPublisher.PublishAsync(request, _settings.VerificationQueueName, validator: new EmployeeVerificationValidator().Wrap());
```
=> _webApiPublisher.PublishAsync(request, new WebApiPublisherArgs<EmployeeVerificationRequest>(_settings.VerificationQueueName) { Validator = new EmployeeVerificationValidator().Wrap() });
}
21 changes: 6 additions & 15 deletions src/CoreEx.AspNetCore/WebApis/AcceptsBodyAttribute.cs
Original file line number Diff line number Diff line change
Expand Up @@ -9,29 +9,20 @@ namespace CoreEx.AspNetCore.WebApis
/// An attribute that specifies the expected request <b>body</b> <see cref="Type"/> that the action/operation accepts and the supported request content types.
/// </summary>
/// <remarks>The is used to enable <i>Swagger/Swashbuckle</i> generated documentation where the operation does not explicitly define the body as a method parameter; i.e. via <see cref="Microsoft.AspNetCore.Mvc.FromBodyAttribute"/>.</remarks>
/// <param name="type">The <b>body</b> <see cref="Type"/>.</param>
/// <param name="contentTypes">The <b>body</b> content type(s). Defaults to <see cref="MediaTypeNames.Application.Json"/>.</param>
/// <exception cref="ArgumentNullException"></exception>
[AttributeUsage(AttributeTargets.Method, AllowMultiple = false)]
public sealed class AcceptsBodyAttribute : Attribute
public sealed class AcceptsBodyAttribute(Type type, params string[] contentTypes) : Attribute
{
/// <summary>
/// Initializes a new instance of the <see cref="AcceptsBodyAttribute"/> class.
/// </summary>
/// <param name="type">The <b>body</b> <see cref="Type"/>.</param>
/// <param name="contentTypes">The <b>body</b> content type(s). Defaults to <see cref="MediaTypeNames.Application.Json"/>.</param>
/// <exception cref="ArgumentNullException"></exception>
public AcceptsBodyAttribute(Type type, params string[] contentTypes)
{
BodyType = type ?? throw new ArgumentNullException(nameof(type));
ContentTypes = contentTypes.Length == 0 ? new string[] { MediaTypeNames.Application.Json } : contentTypes;
}

/// <summary>
/// Gets the <b>body</b> <see cref="Type"/>.
/// </summary>
public Type BodyType { get; }
public Type BodyType { get; } = type.ThrowIfNull(nameof(type));

/// <summary>
/// Gets the <b>body</b> content type(s).
/// </summary>
public string[] ContentTypes { get; }
public string[] ContentTypes { get; } = contentTypes.Length == 0 ? [MediaTypeNames.Application.Json] : contentTypes;
}
}
9 changes: 2 additions & 7 deletions src/CoreEx.AspNetCore/WebApis/ExtendedStatusCodeResult.cs
Original file line number Diff line number Diff line change
Expand Up @@ -12,14 +12,9 @@ namespace CoreEx.AspNetCore.WebApis
/// <summary>
/// Represents an extended <see cref="StatusCodeResult"/> that enables customization of the <see cref="HttpResponse"/>.
/// </summary>
public class ExtendedStatusCodeResult : StatusCodeResult
/// <param name="statusCode">The <see cref="HttpStatusCode"/>.</param>
public class ExtendedStatusCodeResult(HttpStatusCode statusCode) : StatusCodeResult((int)statusCode)
{
/// <summary>
/// Initializes a new instance of the <see cref="ExtendedStatusCodeResult"/> class.
/// </summary>
/// <param name="statusCode">The <see cref="HttpStatusCode"/>.</param>
public ExtendedStatusCodeResult(HttpStatusCode statusCode) : base((int)statusCode) { }

/// <summary>
/// Gets or sets the <see cref="Microsoft.AspNetCore.Http.Headers.ResponseHeaders.Location"/> <see cref="Uri"/>.
/// </summary>
Expand Down
Loading

0 comments on commit 40b4fa8

Please sign in to comment.