Similar to lab 1, a database has already been defined to store order details for eShop, along with an Entity Framework Core model, and a web app that ensures the database is created and updated to the latest schema by running migrations on startup.
-
Open the
eShop.lab5.sln
in Visual Studio or VS Code. -
An Entity Framework Core model is already defined for this database in the
Ordering.Data
project. Open theOrderingDbContext.cs
file in this project and look at the code to see that the the various tables are defined via properties and classes implementingIEntityTypeConfiguration<TEntity>
. -
The
Ordering.Data
project only defines theDbContext
and entity types. The EF Core migrations are defined and managed in theOrdering.Data.Manager
project. This is a web project that includes some custom code to facilitate creating and seeding the database when the application starts. -
The AppHost has already been configured to create a PostgreSQL container resource named
OrderingDB
and had theOrdering.Data.Manager
project added to it as a resource namedordering-db-mgr
with a reference to theOrderingDB
database. -
Run the AppHost project and verify using the dashboard and the pgAdmin tool that the
OrderingDB
database has been created and contains the tables defined by the Entity Framework Core migrations.
Now that we've verified the Ordering database is working, let's add an HTTP API that provides the ordering capabilities to our system.
-
Add a new project to the solution using the ASP.NET Core Web API project template and call it
Ordering.API
, and ensure the following options are configured- Framework: .NET 8.0 (Long Term Support)
- Configure for HTTPS: disabled
- Enable Docker: disabled
- Enable OpenAPI support: enabled
- Do not use top-level statements: disabled
- Use controllers: disabled
- Enlist in .NET Aspire orchestration: enabled
-
In the newly created project, update the package reference to
Swashbuckle.AspNetCore
to version6.5.0
-
Open the
Program.cs
file of theeShop.AppHost
project, and update it so the API project you just added is named"ordering-api"
and has a reference to theOrderingDB
and the IdP:var orderingApi = builder.AddProject<Projects.Ordering_API>("ordering-api") .WithReference(orderDb) .WithReference(idp);
-
Towards the bottom of the
Program.cs
file, udpate the line that adds the"ORDERINGAPI_HTTP"
envionment variable to theidp
resource so that it now passes in thehttp
endpoint from theorderingApi
resource. This will ensure the IdP is configured correctly to support authentication requests from theOrdering.API
project:idp.WithEnvironment("ORDERINGAPI_HTTP", orderingApi.GetEndpoint("http"));
-
Add a project reference from the
Ordering.API
project to theOrdering.Data
project so that it can use Entity Framework Core to access the database. -
Open the
Program.cs
file of theOrdering.API
project and delete the sample code that defines the weather forecasts API. -
Immediately after the line that calls
builder.AddServiceDefaults()
, add lines to register the default OpenAPI services, and the default authentication services. Reminder, these methods are defined in theeShop.ServiceDefaults
project and make it easy to add common services to an API project and ensure they're configured consistently:builder.AddServiceDefaults(); builder.AddDefaultOpenApi(); builder.AddDefaultAuthentication();
-
Add a line to configure the
OrderingDbContext
in the application's DI container using the Npgsql Entity Framework Core Provider for PostgreSQL. Ensure that the name passed to the method matches the name defined for the database in the AppHost project'sProgram.cs
file ("OrderingDB"
). TheAddNpgsqlDbContext
method comes from theAspire.Npgsql.EntityFrameworkCore.PostgreSQL
Aspire component:builder.AddNpgsqlDbContext<OrderingDbContext>("OrderingDB");
-
Create a new file called
OrdersApi.cs
and define a static class inside of it calledOrderingApi
in theMicrosoft.AspNetCore.Builder
namespace:namespace Microsoft.AspNetCore.Builder; public static class OrderingApi { }
-
In this class, add an extension method named
MapOrdersApi
on theRouteGroupBuilder
type, returning that same type:public static RouteGroupBuilder MapOrdersApi(this RouteGroupBuilder app) { return app; }
This method will define the endpoint routes for the Ordering API.
-
Create a
Models
directory and inside it create a new fileOrderSummary.cs
. In this, define a class calledOrderSummary
with properties to represent the order number, date, status, and total price, and a static method to create an instance of this class from anOrder
entity from theOrdering.Data
project:using eShop.Ordering.Data; namespace eShop.Ordering.API.Model; public class OrderSummary { public int OrderNumber { get; init; } public DateTime Date { get; init; } public required string Status { get; init; } public decimal Total { get; init; } public static OrderSummary FromOrder(Order order) { return new OrderSummary { OrderNumber = order.Id, Date = order.OrderDate, Status = order.OrderStatus.ToString(), Total = order.GetTotal() }; } }
This class will be used to represent a summary of an order in the API responses.
-
Back in the
OrdersApi.cs
file, in theMapOrdersApi
method, add a call toapp.MapGet
to define an endpoint that responds to GET requests to the/
path, and is handled by an async lambda that accepts two parameters: aClaimsPrincipal
type that will be auto-populated with the current user, and theOrderingDbContext
instance that will come from the DI container:app.MapGet("/", async (ClaimsPrincipal user, OrderingDbContext dbContext) => { });
-
Add code to the lambda body to extract the user ID from the
ClaimsPrincipal
and use it to query the database for the orders that belong to that user. If the user ID is null, throw an exception with a relevant message, otherwise return an instance ofOrderSummary
representing the user's orders:app.MapGet("/", async (ClaimsPrincipal user, OrderingDbContext dbContext) => { var userId = user.GetUserId() ?? throw new InvalidOperationException("User identity could not be found. This endpoint requires authorization."); var orders = await dbContext.Orders .Include(o => o.OrderItems) .Include(o => o.Buyer) .Where(o => o.Buyer.IdentityGuid == userId) .Select(o => OrderSummary.FromOrder(o)) .AsNoTracking() .ToArrayAsync(); return TypedResults.Ok(orders); });
-
In
Program.cs
, immediately before the call toapp.Run()
at the end of the file, add a line to map a route group for the path/api/v1/orders
and call theMapOrdersApi
method on it, followed by a call toRequireAuthorization
to ensure that the endpoint requires a user to be authenticated to access it. This will ensure that the endpoint is only accessible to authenticated users, and that the user's identity is available in theClaimsPrincipal
parameter of the lambda in theMapGet
method:app.MapGroup("/api/v1/orders") .MapOrdersApi() .RequireAuthorization();
-
Open the
appsettings.json
file and add the following configuration sections:{ // Add the following sections "OpenApi": { "Endpoint": { "Name": "Ordering.API v1" }, "Document": { "Description": "The Ordering Service HTTP API", "Title": "eShop - Ordering HTTP API", "Version": "v1" }, "Auth": { "ClientId": "orderingswaggerui", "AppName": "Ordering Swagger UI" } }, "Identity": { "Audience": "orders" } }
The
"OpenApi"
section will ensure the call tobuilder.AddDefaultOpenApi()
in theProgram.cs
file configures the Swagger UI for authentication against our IdP. The"Identity"
section will ensure the call tobuilder.AddDefaultAuthentication()
in theProgram.cs
file configures the API for authentication bia JWT Bearer (similar to what we did for the Basket API). -
Run the AppHost project and verify that the
Ordering.API
project is running and that the/api/v1/orders
endpoint is visible in the Swagger UI. There should be an Authorize button displayed with an open padlock, indicating that the endpoint requires authentication. -
Click the Authorize button and in the Available authorizations dialog opened, click the Authorize button to be taken to the sign-in form for the IdP. Sign in with the same test user you used to sign in to the web site in the previous lab ([email protected] / P@$$w0rd1). You should be redirected back to the Swagger UI and see the Available authorizations dialog again indicating you are now authorized.
-
Click the Close button to close the Available authorizations dialog and then click the Try it out button for the
/api/v1/orders
endpoint. You should see a response with an empty array of orders, indicating that the endpoint is working and returning the expected response.
-
In the
Ordering.API
project, add a new file calledBasketItem.cs
in theModels
directory. In this file, define a class calledBasketItem
with properties to represent an item in a shopping basket that will be added to an order. Annotate all the properties with the[Required]
attribute from theSystem.ComponentModel.DataAnnotations
namespace, and use the[Range]
attribute to specify a range of valid values for the numerical properties:using System.ComponentModel.DataAnnotations; namespace eShop.Ordering.API.Models; public class BasketItem { [Required] public int ProductId { get; init; } [Required] public required string ProductName { get; init; } [Required, Range(0, double.MaxValue)] public decimal UnitPrice { get; init; } [Required] [Range(0, 10000)] public int Quantity { get; init; } }
-
Add another new file called
CreateOrderRequest.cs
in theModels
directory. In this file, define a class calledCreateOrderRequest
with properties to represent the details of the order. Annotate all the properties with the[Required]
attribute from theSystem.ComponentModel.DataAnnotations
namespace:using System.ComponentModel.DataAnnotations; namespace eShop.Ordering.API.Models; public class CreateOrderRequest { [Required] public required string UserName { get; set; } [Required] public required string City { get; set; } [Required] public required string Street { get; set; } [Required] public required string State { get; set; } [Required] public required string Country { get; set; } [Required] public required string ZipCode { get; set; } [Required] public required string CardNumber { get; set; } [Required] public required string CardHolderName { get; set; } [Required] public DateTime CardExpiration { get; set; } [Required] public required string CardSecurityNumber { get; set; } [Required] public int CardTypeId { get; set; } [Required] public required IReadOnlyCollection<BasketItem> Items { get; set; } }
-
Back in the
OrdersApi.cs
file, in theMapOrdersApi
method, add a call toapp.MapGet
to define an endpoint that responds to POST requests to the/
path, and is handled by an async lambda that accepts three parameters: aCreateOrderRequest
that will be deserialized from JSON in the POST request body, aClaimsPrincipal
type that will be auto-populated with the current user, and theOrderingDbContext
instance that will come from the DI container:app.MapPost("/", async (CreateOrderRequest request, ClaimsPrincipal user, OrderingDbContext dbContext) => { });
-
We'll build up the body of this lambda over a few steps as the process of creating an order from a basket is a bit more complex than just querying the database for orders. First, add code to the lambda body to extract the user ID from the
ClaimsPrincipal
. If the user ID is null, throw an exception with a relevant message:app.MapPost("/", async (CreateOrderRequest request, ClaimsPrincipal user, OrderingDbContext dbContext) => { var userId = user.GetUserId() ?? throw new InvalidOperationException("User identity could not be found. This endpoint requires authorization."); // ... more code to come });
-
Next, add code to validate that the
CardTypeId
property of therequest
parameter is a valid card type ID. If it's not, return a validation problem response with an appropriate message:if (!Enumeration.IsValid<CardType>(request.CardTypeId)) { var errors = new Dictionary<string, string[]> { { nameof(CreateOrderRequest.CardTypeId), [$"Card type ID '{request.CardTypeId}' is invalid."] } }; return Results.ValidationProblem(errors); }
-
Next, we'll query the database to find the buyer that corresponds to the current user, and retrieve the requested payment method at the same time if the buyer has used it previously:
var requestPaymentMethod = new PaymentMethod { CardTypeId = request.CardTypeId, CardHolderName = request.CardHolderName, CardNumber = request.CardNumber, Expiration = request.CardExpiration, SecurityNumber = request.CardSecurityNumber, }; var buyer = await dbContext.Buyers .Where(b => b.IdentityGuid == userId) // Include the payment method to check if it already exists .Include(b => b.PaymentMethods .Where(pm => pm.CardTypeId == requestPaymentMethod.CardTypeId && pm.CardNumber == requestPaymentMethod.CardNumber && pm.Expiration == requestPaymentMethod.Expiration)) .SingleOrDefaultAsync();
-
Now check if the buyer was found, and if not, create a new buyer for this user and add it to the database:
if (buyer is null) { buyer = new Buyer { IdentityGuid = userId, Name = request.UserName }; dbContext.Buyers.Add(buyer); }
-
Next, check if the payment method was found, and if not, add it to the buyer's payment methods:
var paymentMethod = buyer.PaymentMethods.SingleOrDefault(); if (paymentMethod is null) { paymentMethod = new PaymentMethod { CardTypeId = request.CardTypeId, CardNumber = request.CardNumber, CardHolderName = request.CardHolderName, Expiration = request.CardExpiration, SecurityNumber = request.CardSecurityNumber }; buyer.PaymentMethods.Add(paymentMethod); }
-
Now that the buyer and payment method are dealth with, we can actually create the order:
var order = new Order { Buyer = buyer, Address = new Address(request.Street, request.City, request.State, request.Country, request.ZipCode), OrderItems = request.Items.Select(i => new OrderItem { ProductId = i.ProductId, ProductName = i.ProductName, UnitPrice = i.UnitPrice, Units = i.Quantity, Discount = 0 }).ToList(), PaymentMethod = paymentMethod }; dbContext.Orders.Add(order);
-
Finally, save the changes to the database and return an OK response to indicate that the operation completed successfully:
await dbContext.SaveChangesAsync(); return TypedResults.Ok();
We decorated some of the properties of the types that will be deserialized from the requests to the API with the [Required]
and [Range]
attributes, but ASP.NET Core Minimal APIs currently doesn't support validation out of the box. We can add this functionality by referencing a NuGet package that provides an endpoint filter to handle validating endpoint parameters:
-
Add a reference to the
MinimalApis.Extensions
NuGet package, version0.11.0
to theOrdering.API
project. -
Update the
MapPost
call inOrdersApi.cs
so that it callsWithParameterValidation
on the returnedRouteHandlerBuilder
instance to enable parameter validation for the endpoint.You can optionally set the
requireParameterAttribute
parameter totrue
to ensure that the[Validate]
attribute is required on the endpoint parameters to enable validation. This makes parameter validation more explicit so that other parameters are not uncessarily validated, potenially improving performance. If you opt to set this parameter totrue
, you'll need to ensure add the[Validate]
attribute to theCreateOrderRequest
parameter too:app.MapPost("/", async ([Validate] CreateOrderRequest request, ClaimsPrincipal user, OrderingDbContext dbContext) => { // ... existing code }) .WithParameterValidation(requireParameterAttribute: true); // <-- Add this line
-
Run the AppHost project and verify that the
Ordering.API
project is running and that the/api/v1/orders
endpoint is visible in the Swagger UI. Experiment with authorizing and making requests to the endpoint to ensure that it's working as expected, including the parameter validation. Once you've created an order via a POST to the/api/v1/orders
endpoint, you should see the order returned when making a GET request to the same endpoint.Here's an example of successfully creating an order using the Swagger UI:
Here's an example of the type of response you'll receive if you try to create an order with invalid parameters:
If you want an extra challenge, try updating the web site to call the Ordering API. You can use the complete eShop
solution in the root of this repo to help guide you.