diff --git a/Directory.Build.props b/Directory.Build.props
index 00eb0ba..250ad39 100644
--- a/Directory.Build.props
+++ b/Directory.Build.props
@@ -18,6 +18,6 @@
MIT
True
- 0.6.2
+ 0.6.3
\ No newline at end of file
diff --git a/README.md b/README.md
index 373f557..2ab446a 100644
--- a/README.md
+++ b/README.md
@@ -10,7 +10,7 @@
To make consuming a [ServiceEndpoint](#serviceendpoint) easier, which is a very specialized endpoint more suitable for internal services, a specific [client implementation](#serviceendpoint-clients) along with extensions required for client registration is implemented in ModEndpoints.RemoteServices package, and interfaces required for ServiceEndpoint request models are in ModEndpoints.RemoteServices.Core package.
-[ShowcaseWebApi](https://github.com/modabas/ModEndpoints/tree/main/samples/ShowcaseWebApi) project demonstrates various kinds of endpoint implementations and configurations. [Client](https://github.com/modabas/ModEndpoints/tree/main/samples/Client) project is a sample ServiceEndpoint consumer.
+Each of them are demonstrated in [sample projects](#samples).
All endpoint abstractions are a structured approach to defining endpoints in ASP.NET Core applications. They extend the Minimal Api pattern with reusable, testable, and consistent components for request handling, validation, and response mapping.
@@ -308,6 +308,16 @@ internal class CreateBook(ServiceDbContext db, ILocationStore location)
}
```
+## Samples
+
+[ShowcaseWebApi](https://github.com/modabas/ModEndpoints/tree/main/samples/ShowcaseWebApi) project demonstrates various kinds of endpoint implementations and configurations:
+ - MinimalEnpoints samples are in [Customers](https://github.com/modabas/ModEndpoints/tree/main/samples/ShowcaseWebApi/Features/Customers) subfolder,
+ - WebResultEndpoints samples are in [Books](https://github.com/modabas/ModEndpoints/tree/main/samples/ShowcaseWebApi/Features/Books) subfolder,
+ - BusinessResultEndpoints samples are in [Stores](https://github.com/modabas/ModEndpoints/tree/main/samples/ShowcaseWebApi/Features/Stores) subfolder,
+ - ServiceEndpoints samples are in [StoresWithServiceEndpoint](https://github.com/modabas/ModEndpoints/tree/main/samples/ShowcaseWebApi/Features/StoresWithServiceEndpoint) subfolder.
+
+[ServiceEndpointClient](https://github.com/modabas/ModEndpoints/tree/main/samples/ServiceEndpointClient) project demonstrates how to consume ServiceEndpoints.
+
## Performance
WebResultEndpoints have a slight overhead (3-4%) over regular Minimal Apis on request/sec metric under load tests with 100 virtual users.
@@ -326,9 +336,9 @@ MinimalEndpoint within ModEndpoints.Core package, is closest to barebones Minima
- string
- T (Any other type)
-- Minimal Api IResult based
+- Minimal Api IResult based (Including TypedResults with Results return value)
-Other features described previously are common for all of them.
+See (How to create responses in Minimal API apps)[https://learn.microsoft.com/en-us/aspnet/core/fundamentals/minimal-apis/responses?view=aspnetcore-8.0] for detailed information. Other features described previously are common for all of them.
Each type of endpoint has various implementations that accept a request model or not, that has a response model or not.
diff --git a/samples/ShowcaseWebApi/Data/ServiceDbContext.cs b/samples/ShowcaseWebApi/Data/ServiceDbContext.cs
index 1297e5e..dc8a840 100644
--- a/samples/ShowcaseWebApi/Data/ServiceDbContext.cs
+++ b/samples/ShowcaseWebApi/Data/ServiceDbContext.cs
@@ -1,5 +1,6 @@
using Microsoft.EntityFrameworkCore;
using ShowcaseWebApi.Features.Books.Data;
+using ShowcaseWebApi.Features.Customers.Data;
using ShowcaseWebApi.Features.Stores.Data;
namespace ShowcaseWebApi.Data;
@@ -23,5 +24,6 @@ protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder)
#region DbSets
public DbSet Books => Set();
public DbSet Stores => Set();
+ public DbSet Customers => Set();
#endregion
}
diff --git a/samples/ShowcaseWebApi/Extensions/WebApplicationExtensions.cs b/samples/ShowcaseWebApi/Extensions/WebApplicationExtensions.cs
index 2b12c49..20b0741 100644
--- a/samples/ShowcaseWebApi/Extensions/WebApplicationExtensions.cs
+++ b/samples/ShowcaseWebApi/Extensions/WebApplicationExtensions.cs
@@ -1,5 +1,6 @@
using ShowcaseWebApi.Data;
using ShowcaseWebApi.Features.Books.Data;
+using ShowcaseWebApi.Features.Customers.Data;
using ShowcaseWebApi.Features.Stores.Data;
namespace ShowcaseWebApi.Extensions;
@@ -41,6 +42,23 @@ public static void SeedData(this WebApplication app)
{
Name = "Middling Evil Store"
});
+ db.Customers.Add(new CustomerEntity()
+ {
+ FirstName = "Willie",
+ MiddleName = "Jonathan",
+ LastName = "Normand"
+ });
+ db.Customers.Add(new CustomerEntity()
+ {
+ FirstName = "Leslie",
+ MiddleName = "Lois",
+ LastName = "Coffman"
+ });
+ db.Customers.Add(new CustomerEntity()
+ {
+ FirstName = "Oliver",
+ LastName = "Rogers"
+ });
db.SaveChanges();
}
}
diff --git a/samples/ShowcaseWebApi/Features/Customers/Configuration/CustomersV1RouteGroup.cs b/samples/ShowcaseWebApi/Features/Customers/Configuration/CustomersV1RouteGroup.cs
new file mode 100644
index 0000000..6e41b9f
--- /dev/null
+++ b/samples/ShowcaseWebApi/Features/Customers/Configuration/CustomersV1RouteGroup.cs
@@ -0,0 +1,16 @@
+using ModEndpoints.Core;
+
+namespace ShowcaseWebApi.Features.Customers.Configuration;
+
+[MapToGroup()]
+internal class CustomersV1RouteGroup : RouteGroupConfigurator
+{
+ protected override void Configure(
+ IServiceProvider serviceProvider,
+ IRouteGroupConfigurator? parentRouteGroup)
+ {
+ MapGroup("/customers")
+ .MapToApiVersion(1)
+ .WithTags("/CustomersV1");
+ }
+}
diff --git a/samples/ShowcaseWebApi/Features/Customers/CreateCustomer.cs b/samples/ShowcaseWebApi/Features/Customers/CreateCustomer.cs
new file mode 100644
index 0000000..4f9eded
--- /dev/null
+++ b/samples/ShowcaseWebApi/Features/Customers/CreateCustomer.cs
@@ -0,0 +1,55 @@
+using FluentValidation;
+using Microsoft.AspNetCore.Http.HttpResults;
+using Microsoft.AspNetCore.Mvc;
+using ModEndpoints.Core;
+using ShowcaseWebApi.Data;
+using ShowcaseWebApi.Features.Customers.Configuration;
+using ShowcaseWebApi.Features.Customers.Data;
+
+namespace ShowcaseWebApi.Features.Customers;
+public record CreateCustomerRequest([FromBody] CreateCustomerRequestBody Body);
+
+public record CreateCustomerRequestBody(string FirstName, string? MiddleName, string LastName);
+
+public record CreateCustomerResponse(Guid Id);
+
+internal class CreateCustomerRequestValidator : AbstractValidator
+{
+ public CreateCustomerRequestValidator()
+ {
+ RuleFor(x => x.Body.FirstName).NotEmpty();
+ RuleFor(x => x.Body.LastName).NotEmpty();
+ }
+}
+
+[MapToGroup()]
+internal class CreateCustomer(ServiceDbContext db)
+ : MinimalEndpoint, ValidationProblem>>
+{
+ protected override void Configure(
+ IServiceProvider serviceProvider,
+ IRouteGroupConfigurator? parentRouteGroup)
+ {
+ MapPost("/");
+ }
+
+ protected override async Task, ValidationProblem>> HandleAsync(
+ CreateCustomerRequest req,
+ CancellationToken ct)
+ {
+ var customer = new CustomerEntity()
+ {
+ FirstName = req.Body.FirstName,
+ MiddleName = req.Body.MiddleName,
+ LastName = req.Body.LastName
+ };
+
+ db.Customers.Add(customer);
+ await db.SaveChangesAsync(ct);
+
+ return TypedResults.CreatedAtRoute(
+ new CreateCustomerResponse(customer.Id),
+ typeof(GetCustomerById).FullName,
+ new { id = customer.Id });
+ }
+}
diff --git a/samples/ShowcaseWebApi/Features/Customers/Data/CustomerEntity.cs b/samples/ShowcaseWebApi/Features/Customers/Data/CustomerEntity.cs
new file mode 100644
index 0000000..ceb96b8
--- /dev/null
+++ b/samples/ShowcaseWebApi/Features/Customers/Data/CustomerEntity.cs
@@ -0,0 +1,10 @@
+using ShowcaseWebApi.Data;
+
+namespace ShowcaseWebApi.Features.Customers.Data;
+
+internal class CustomerEntity : BaseEntity
+{
+ public string FirstName { get; set; } = string.Empty;
+ public string? MiddleName { get; set; }
+ public string LastName { get; set; } = string.Empty;
+}
diff --git a/samples/ShowcaseWebApi/Features/Customers/DeleteBook.cs b/samples/ShowcaseWebApi/Features/Customers/DeleteBook.cs
new file mode 100644
index 0000000..c1acdb9
--- /dev/null
+++ b/samples/ShowcaseWebApi/Features/Customers/DeleteBook.cs
@@ -0,0 +1,45 @@
+using FluentValidation;
+using Microsoft.EntityFrameworkCore;
+using ModEndpoints.Core;
+using ShowcaseWebApi.Data;
+using ShowcaseWebApi.Features.Customers.Configuration;
+
+namespace ShowcaseWebApi.Features.Customers;
+public record DeleteCustomerRequest(Guid Id);
+
+internal class DeleteCustomerRequestValidator : AbstractValidator
+{
+ public DeleteCustomerRequestValidator()
+ {
+ RuleFor(x => x.Id).NotEmpty();
+ }
+}
+
+[MapToGroup()]
+internal class DeleteCustomer(ServiceDbContext db)
+ : MinimalEndpoint
+{
+ protected override void Configure(
+ IServiceProvider serviceProvider,
+ IRouteGroupConfigurator? parentRouteGroup)
+ {
+ MapDelete("/{Id}")
+ .Produces(StatusCodes.Status204NoContent);
+ }
+
+ protected override async Task HandleAsync(
+ DeleteCustomerRequest req,
+ CancellationToken ct)
+ {
+ var entity = await db.Customers.FirstOrDefaultAsync(b => b.Id == req.Id, ct);
+
+ if (entity is null)
+ {
+ return Results.NotFound();
+ }
+
+ db.Customers.Remove(entity);
+ var deleted = await db.SaveChangesAsync(ct);
+ return deleted > 0 ? Results.NoContent() : Results.NotFound();
+ }
+}
diff --git a/samples/ShowcaseWebApi/Features/Customers/GetCustomerById.cs b/samples/ShowcaseWebApi/Features/Customers/GetCustomerById.cs
new file mode 100644
index 0000000..ee9eb15
--- /dev/null
+++ b/samples/ShowcaseWebApi/Features/Customers/GetCustomerById.cs
@@ -0,0 +1,47 @@
+using FluentValidation;
+using Microsoft.AspNetCore.Http.HttpResults;
+using Microsoft.EntityFrameworkCore;
+using ModEndpoints.Core;
+using ShowcaseWebApi.Data;
+using ShowcaseWebApi.Features.Customers.Configuration;
+
+namespace ShowcaseWebApi.Features.Customers;
+
+public record GetCustomerByIdRequest(Guid Id);
+
+public record GetCustomerByIdResponse(Guid Id, string FirstName, string? MiddleName, string LastName);
+
+internal class GetCustomerByIdRequestValidator : AbstractValidator
+{
+ public GetCustomerByIdRequestValidator()
+ {
+ RuleFor(x => x.Id).NotEmpty();
+ }
+}
+
+[MapToGroup()]
+internal class GetCustomerById(ServiceDbContext db)
+ : MinimalEndpoint, NotFound, ValidationProblem>>
+{
+ protected override void Configure(
+ IServiceProvider serviceProvider,
+ IRouteGroupConfigurator? parentRouteGroup)
+ {
+ MapGet("/{Id}");
+ }
+
+ protected override async Task, NotFound, ValidationProblem>> HandleAsync(
+ GetCustomerByIdRequest req,
+ CancellationToken ct)
+ {
+ var entity = await db.Customers.FirstOrDefaultAsync(b => b.Id == req.Id, ct);
+
+ return entity is null ?
+ TypedResults.NotFound() :
+ TypedResults.Ok(new GetCustomerByIdResponse(
+ Id: entity.Id,
+ FirstName: entity.FirstName,
+ MiddleName: entity.MiddleName,
+ LastName: entity.LastName));
+ }
+}
diff --git a/samples/ShowcaseWebApi/Features/Customers/ListCustomers.cs b/samples/ShowcaseWebApi/Features/Customers/ListCustomers.cs
new file mode 100644
index 0000000..59f061a
--- /dev/null
+++ b/samples/ShowcaseWebApi/Features/Customers/ListCustomers.cs
@@ -0,0 +1,39 @@
+using Microsoft.EntityFrameworkCore;
+using ModEndpoints.Core;
+using ShowcaseWebApi.Data;
+using ShowcaseWebApi.Features.Customers.Configuration;
+
+namespace ShowcaseWebApi.Features.Customers;
+
+public record ListCustomersResponse(List Customers);
+public record ListCustomersResponseItem(
+ Guid Id,
+ string FirstName,
+ string? MiddleName,
+ string LastName);
+
+[MapToGroup()]
+internal class ListCustomers(ServiceDbContext db)
+ : MinimalEndpoint
+{
+ protected override void Configure(
+ IServiceProvider serviceProvider,
+ IRouteGroupConfigurator? parentRouteGroup)
+ {
+ MapGet("/");
+ }
+
+ protected override async Task HandleAsync(
+ CancellationToken ct)
+ {
+ var customers = await db.Customers
+ .Select(c => new ListCustomersResponseItem(
+ c.Id,
+ c.FirstName,
+ c.MiddleName,
+ c.LastName))
+ .ToListAsync(ct);
+
+ return new ListCustomersResponse(Customers: customers);
+ }
+}
diff --git a/samples/ShowcaseWebApi/Features/Customers/UpdateCustomer.cs b/samples/ShowcaseWebApi/Features/Customers/UpdateCustomer.cs
new file mode 100644
index 0000000..f3aeab8
--- /dev/null
+++ b/samples/ShowcaseWebApi/Features/Customers/UpdateCustomer.cs
@@ -0,0 +1,62 @@
+using FluentValidation;
+using Microsoft.AspNetCore.Mvc;
+using Microsoft.EntityFrameworkCore;
+using ModEndpoints.Core;
+using ShowcaseWebApi.Data;
+using ShowcaseWebApi.Features.Customers.Configuration;
+
+namespace ShowcaseWebApi.Features.Customers;
+public record UpdateCustomerRequest(Guid Id, [FromBody] UpdateCustomerRequestBody Body);
+
+public record UpdateCustomerRequestBody(string FirstName, string? MiddleName, string LastName);
+
+public record UpdateCustomerResponse(Guid Id, string FirstName, string? MiddleName, string LastName);
+
+internal class UpdateCustomerRequestValidator : AbstractValidator
+{
+ public UpdateCustomerRequestValidator()
+ {
+ RuleFor(x => x.Id).NotEmpty();
+ RuleFor(x => x.Body.FirstName).NotEmpty();
+ RuleFor(x => x.Body.LastName).NotEmpty();
+
+ }
+}
+
+[MapToGroup()]
+internal class UpdateCustomer(ServiceDbContext db)
+ : MinimalEndpoint
+{
+ protected override void Configure(
+ IServiceProvider serviceProvider,
+ IRouteGroupConfigurator? parentRouteGroup)
+ {
+ MapPut("/{Id}")
+ .Produces();
+ }
+
+ protected override async Task HandleAsync(
+ UpdateCustomerRequest req,
+ CancellationToken ct)
+ {
+ var entity = await db.Customers.FirstOrDefaultAsync(b => b.Id == req.Id, ct);
+
+ if (entity is null)
+ {
+ return Results.NotFound();
+ }
+
+ entity.FirstName = req.Body.FirstName;
+ entity.MiddleName = req.Body.MiddleName;
+ entity.LastName = req.Body.LastName;
+
+ var updated = await db.SaveChangesAsync(ct);
+ return updated > 0 ?
+ Results.Ok(new UpdateCustomerResponse(
+ Id: req.Id,
+ FirstName: req.Body.FirstName,
+ MiddleName: req.Body.MiddleName,
+ LastName: req.Body.LastName))
+ : Results.NotFound();
+ }
+}
diff --git a/src/ModEndpoints.Core/ValidationResultExtensions.cs b/src/ModEndpoints.Core/ValidationResultExtensions.cs
index d5c251a..1663adb 100644
--- a/src/ModEndpoints.Core/ValidationResultExtensions.cs
+++ b/src/ModEndpoints.Core/ValidationResultExtensions.cs
@@ -1,15 +1,42 @@
using FluentValidation.Results;
using Microsoft.AspNetCore.Http;
+using Microsoft.AspNetCore.Http.HttpResults;
+using Microsoft.AspNetCore.Mvc;
namespace ModEndpoints.Core;
public static class ValidationResultExtensions
{
public static IResult ToMinimalApiResult(this ValidationResult validationResult)
{
- var errors = validationResult.Errors
+ var errors = GetErrors(validationResult);
+ return Results.ValidationProblem(errors);
+ }
+ public static ValidationProblem ToTypedValidationProblem(this ValidationResult validationResult)
+ {
+ var errors = GetErrors(validationResult);
+ return TypedResults.ValidationProblem(errors);
+ }
+ public static ProblemHttpResult ToTypedProblem(this ValidationResult validationResult)
+ {
+ var errors = GetErrors(validationResult);
+ return TypedResults.Problem(new HttpValidationProblemDetails(errors));
+ }
+ public static BadRequest ToTypedBadRequestWithValidationProblem(this ValidationResult validationResult)
+ {
+ var errors = GetErrors(validationResult);
+ return TypedResults.BadRequest(new HttpValidationProblemDetails(errors));
+ }
+ public static BadRequest ToTypedBadRequestWithProblem(this ValidationResult validationResult)
+ {
+ var errors = GetErrors(validationResult);
+ return TypedResults.BadRequest((ProblemDetails)new HttpValidationProblemDetails(errors));
+ }
+
+ private static Dictionary GetErrors(ValidationResult validationResult)
+ {
+ return validationResult.Errors
.GroupBy(e => e.PropertyName)
.Select(g => new { g.Key, Values = g.Select(e => e.ErrorMessage).ToArray() })
.ToDictionary(pair => pair.Key, pair => pair.Values);
- return Results.ValidationProblem(errors);
}
}
diff --git a/src/ModEndpoints.Core/[Endpoints]/MinimalEndpoint.cs b/src/ModEndpoints.Core/[Endpoints]/MinimalEndpoint.cs
index 2990aff..32ee4c0 100644
--- a/src/ModEndpoints.Core/[Endpoints]/MinimalEndpoint.cs
+++ b/src/ModEndpoints.Core/[Endpoints]/MinimalEndpoint.cs
@@ -1,6 +1,9 @@
-using FluentValidation;
+using System.Diagnostics.CodeAnalysis;
+using FluentValidation;
using FluentValidation.Results;
using Microsoft.AspNetCore.Http;
+using Microsoft.AspNetCore.Http.HttpResults;
+using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.DependencyInjection;
namespace ModEndpoints.Core;
@@ -60,7 +63,56 @@ protected virtual ValueTask HandleInvalidValidationResultAsync(
HttpContext context,
CancellationToken ct)
{
- if (typeof(TResponse).IsAssignableFrom(typeof(IResult)))
+ var responseType = typeof(TResponse);
+
+ //Is using TypedResults
+ if (responseType.IsGenericType &&
+ responseType.Name.StartsWith("Results`") &&
+ (responseType.Namespace?.Equals("Microsoft.AspNetCore.Http.HttpResults") ?? false))
+ {
+ if (TryUseImplicitOperatorFor(
+ responseType,
+ validationResult,
+ vr => vr.ToTypedValidationProblem(),
+ out var validationProblem))
+ {
+ return new ValueTask(validationProblem);
+ }
+ if (TryUseImplicitOperatorFor>(
+ responseType,
+ validationResult,
+ vr => vr.ToTypedBadRequestWithValidationProblem(),
+ out var badRequestWithValidationProblems))
+ {
+ return new ValueTask(badRequestWithValidationProblems);
+ }
+ if (TryUseImplicitOperatorFor>(
+ responseType,
+ validationResult,
+ vr => vr.ToTypedBadRequestWithProblem(),
+ out var badRequestWithProblems))
+ {
+ return new ValueTask(badRequestWithProblems);
+ }
+ if (TryUseImplicitOperatorFor(
+ responseType,
+ validationResult,
+ vr => vr.ToTypedProblem(),
+ out var problem))
+ {
+ return new ValueTask(problem);
+ }
+ if (TryUseImplicitOperatorFor(
+ responseType,
+ validationResult,
+ _ => TypedResults.BadRequest(),
+ out var badRequest))
+ {
+ return new ValueTask(badRequest);
+ }
+ }
+
+ if (responseType.IsAssignableFrom(typeof(IResult)))
{
return new ValueTask((TResponse)validationResult.ToMinimalApiResult());
}
@@ -69,6 +121,27 @@ protected virtual ValueTask HandleInvalidValidationResultAsync(
throw new ValidationException(validationResult.Errors);
}
}
+
+ private static bool TryUseImplicitOperatorFor(
+ Type responseType,
+ ValidationResult validationResult,
+ Func conversionFunc,
+ [NotNullWhen(true)] out TResponse? response)
+ {
+ var converter = responseType.GetMethod("op_Implicit", new[] { typeof(T) });
+
+ if (converter is not null)
+ {
+ var result = converter.Invoke(null, new[] { (object?)conversionFunc(validationResult) });
+ if (result is not null)
+ {
+ response = (TResponse)result;
+ return true;
+ }
+ }
+ response = default;
+ return false;
+ }
}
public abstract class MinimalEndpoint