Skip to content
Merged

Dev #12

Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion Directory.Build.props
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,6 @@
<PackageLicenseExpression>MIT</PackageLicenseExpression>
<PackageRequireLicenseAcceptance>True</PackageRequireLicenseAcceptance>

<Version>0.6.2</Version>
<Version>0.6.3</Version>
</PropertyGroup>
</Project>
16 changes: 13 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down Expand Up @@ -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.
Expand All @@ -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<TResult1, TResultN> 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.

Expand Down
2 changes: 2 additions & 0 deletions samples/ShowcaseWebApi/Data/ServiceDbContext.cs
Original file line number Diff line number Diff line change
@@ -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;
Expand All @@ -23,5 +24,6 @@ protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder)
#region DbSets
public DbSet<BookEntity> Books => Set<BookEntity>();
public DbSet<StoreEntity> Stores => Set<StoreEntity>();
public DbSet<CustomerEntity> Customers => Set<CustomerEntity>();
#endregion
}
18 changes: 18 additions & 0 deletions samples/ShowcaseWebApi/Extensions/WebApplicationExtensions.cs
Original file line number Diff line number Diff line change
@@ -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;
Expand Down Expand Up @@ -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();
}
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
using ModEndpoints.Core;

namespace ShowcaseWebApi.Features.Customers.Configuration;

[MapToGroup<FeaturesRouteGroup>()]
internal class CustomersV1RouteGroup : RouteGroupConfigurator
{
protected override void Configure(
IServiceProvider serviceProvider,
IRouteGroupConfigurator? parentRouteGroup)
{
MapGroup("/customers")
.MapToApiVersion(1)
.WithTags("/CustomersV1");
}
}
55 changes: 55 additions & 0 deletions samples/ShowcaseWebApi/Features/Customers/CreateCustomer.cs
Original file line number Diff line number Diff line change
@@ -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<CreateCustomerRequest>
{
public CreateCustomerRequestValidator()
{
RuleFor(x => x.Body.FirstName).NotEmpty();
RuleFor(x => x.Body.LastName).NotEmpty();
}
}

[MapToGroup<CustomersV1RouteGroup>()]
internal class CreateCustomer(ServiceDbContext db)
: MinimalEndpoint<CreateCustomerRequest, Results<CreatedAtRoute<CreateCustomerResponse>, ValidationProblem>>
{
protected override void Configure(
IServiceProvider serviceProvider,
IRouteGroupConfigurator? parentRouteGroup)
{
MapPost("/");
}

protected override async Task<Results<CreatedAtRoute<CreateCustomerResponse>, 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 });
}
}
10 changes: 10 additions & 0 deletions samples/ShowcaseWebApi/Features/Customers/Data/CustomerEntity.cs
Original file line number Diff line number Diff line change
@@ -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;
}
45 changes: 45 additions & 0 deletions samples/ShowcaseWebApi/Features/Customers/DeleteBook.cs
Original file line number Diff line number Diff line change
@@ -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<DeleteCustomerRequest>
{
public DeleteCustomerRequestValidator()
{
RuleFor(x => x.Id).NotEmpty();
}
}

[MapToGroup<CustomersV1RouteGroup>()]
internal class DeleteCustomer(ServiceDbContext db)
: MinimalEndpoint<DeleteCustomerRequest, IResult>
{
protected override void Configure(
IServiceProvider serviceProvider,
IRouteGroupConfigurator? parentRouteGroup)
{
MapDelete("/{Id}")
.Produces(StatusCodes.Status204NoContent);
}

protected override async Task<IResult> 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();
}
}
47 changes: 47 additions & 0 deletions samples/ShowcaseWebApi/Features/Customers/GetCustomerById.cs
Original file line number Diff line number Diff line change
@@ -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<GetCustomerByIdRequest>
{
public GetCustomerByIdRequestValidator()
{
RuleFor(x => x.Id).NotEmpty();
}
}

[MapToGroup<CustomersV1RouteGroup>()]
internal class GetCustomerById(ServiceDbContext db)
: MinimalEndpoint<GetCustomerByIdRequest, Results<Ok<GetCustomerByIdResponse>, NotFound, ValidationProblem>>
{
protected override void Configure(
IServiceProvider serviceProvider,
IRouteGroupConfigurator? parentRouteGroup)
{
MapGet("/{Id}");
}

protected override async Task<Results<Ok<GetCustomerByIdResponse>, 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));
}
}
39 changes: 39 additions & 0 deletions samples/ShowcaseWebApi/Features/Customers/ListCustomers.cs
Original file line number Diff line number Diff line change
@@ -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<ListCustomersResponseItem> Customers);
public record ListCustomersResponseItem(
Guid Id,
string FirstName,
string? MiddleName,
string LastName);

[MapToGroup<CustomersV1RouteGroup>()]
internal class ListCustomers(ServiceDbContext db)
: MinimalEndpoint<ListCustomersResponse>
{
protected override void Configure(
IServiceProvider serviceProvider,
IRouteGroupConfigurator? parentRouteGroup)
{
MapGet("/");
}

protected override async Task<ListCustomersResponse> 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);
}
}
62 changes: 62 additions & 0 deletions samples/ShowcaseWebApi/Features/Customers/UpdateCustomer.cs
Original file line number Diff line number Diff line change
@@ -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<UpdateCustomerRequest>
{
public UpdateCustomerRequestValidator()
{
RuleFor(x => x.Id).NotEmpty();
RuleFor(x => x.Body.FirstName).NotEmpty();
RuleFor(x => x.Body.LastName).NotEmpty();

}
}

[MapToGroup<CustomersV1RouteGroup>()]
internal class UpdateCustomer(ServiceDbContext db)
: MinimalEndpoint<UpdateCustomerRequest, IResult>
{
protected override void Configure(
IServiceProvider serviceProvider,
IRouteGroupConfigurator? parentRouteGroup)
{
MapPut("/{Id}")
.Produces<UpdateCustomerResponse>();
}

protected override async Task<IResult> 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();
}
}
Loading