-
Abdelsamad, Mouaz R (UG - SISC) authoredAbdelsamad, Mouaz R (UG - SISC) authored
Libraries Used:
- NLog. We used NLog for logging. By default all logs with level Informational are saved. This setting can be changed in the appsettings.json
nlog.config contains the file that is used to configure our NLog.
-
Automapper: This is a great utility tool that allows mapping one model or DTO (Data Transfer Object) to another. It helps to us avoid repetitve code. To use, we first add a line in the
ConfigureServices
method of ourStartup.cs
:services.AddAutoMapper(typeof(Startup));
The we declare a class that contains our mappings:
public class FlightBookingProfile : Profile
{
public FlightBookingProfile()
{
CreateMap<FlightFare, FlightFareDTO>()
.ForMember(dest => dest.AvailableSeats, opt => opt.MapFrom(src => src.SeatCapacity - src.SeatReserved))
.ForMember(dest => dest.FlightNumber, opt => opt.MapFrom(src => src.FlightInformation.FlightNumber));
}
}
-
We also installed NewtonSoftJson library for working with Json input and output.
-
Swashbuckle was also installed so we can generate Swagger documentation from our controllers.
-
Some libraries for using Jwt for authentication were also added.
Program Flow.
Controller -> Services -> Repository -> EntityFramework -> Data
Controllers
They represent our endpoints based on the MVC pattern.
Each controller has been decorated with attributes that makes it easy to read what input and output to expect.
For example, our ReservedSeatController
:
[Route("api/[controller]")]
[ApiController]
public class SeatsController : ControllerBase
{
private readonly IReservedSeatService _service;
public SeatsController(IReservedSeatService service)
{
_service = service;
}
[HttpGet]
[ProducesResponseType(StatusCodes.Status200OK, Type = typeof(IEnumerable<ReservedSeatDTO>))]
[ProducesResponseType(StatusCodes.Status400BadRequest, Type = typeof(ProblemDetails))]
public IActionResult GetAvailableSeats([FromQuery] string flightNumber)
{
ServiceResponse<IEnumerable<ReservedSeatDTO>> result = _service.GetAvailableSeatsByFlightNumber(flightNumber);
return result.FormatResponse();
}
[HttpPost]
[Consumes(MediaTypeNames.Application.Json)]
[ProducesResponseType(StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status422UnprocessableEntity, Type = typeof(ProblemDetails))]
[ProducesResponseType(StatusCodes.Status400BadRequest, Type = typeof(ProblemDetails))]
public async Task<IActionResult> ReserveSeat([FromBody] ReservedSeatRequestDTO requestDTO)
{
ServiceResponse<string> result = await _service.ReserveSeatAsync(requestDTO);
return result.FormatResponse();
}
}
In the above, we have to methods in the controller which corresponds to 2 endpoints.
First, we inject our IReservedSeatService
into the controller.
The first endpoint is a GET endpoint: And the attributes show that it can return a 200, 400 and 422 response. The 400 and 422 responses are in types of ProblemDetails which is a specification for returning API responses.
This part: ([FromQuery] string flightNumber)
indicates that a query string named flightNumber
is passed to the endpoint generated by this method.
The _service
returns a type of ServiceResponse
which is then formatted to return the appropriate response. More on this later
The second endpoint is a POST endpoint:
The attributes show it accepts a body ([FromBody]
) of type application/json (MediaTypeNames.Application.Json
) and response produced are 200, 422, 400.
Services
They represent the logic for our app. We this pattern to make it easy to test the services. By abstracting the services to use Interfaces, we can easily write tests that can be flexible.
We rely on the built-in Dependency Injection framework to resolve service dependencies.
In each service, we inject Repository classes and other Services.
The services also have to be registered and we do this by declaring a static class that will do the registration:
public static class ServicesModule
{
public static void AddServices(this IServiceCollection services)
{
services.AddScoped<IBookingService, BookingService>();
services.AddScoped<IBookingOrderService, BookingOrderService>();
services.AddScoped<IReservedSeatService, ReservedSeatService>();
services.AddScoped<IFlightFareService, FlightFareService>();
services.AddScoped<IFlightService, FlightService>();
services.AddScoped<IStripeService, StripeService>();
}
}
Here: We register each service as a Scoped dependency. Scoped means that the service will only be active for the duration of a request (i.e Http Request).
Then we call the method in our Startup.cs:
services.AddServices();
Each service also returns a type ServiceResponse<T>
where T is a class
The ServiceResponse<T>
is declared in the ResponseFormatter.cs
public class ServiceResponse<T>
{
public InternalCode ServiceCode { get; set; } = InternalCode.Failed;
public T Data { get; set; }// = null;
public string Message { get; set; }
public ServiceResponse(T data, InternalCode serviceCode = InternalCode.Failed, string message = "")
{
Message = message;
ServiceCode = serviceCode;
Data = data;
}
}
We declare the ServiceResponse
to be a generic class. It's properties are:
InternalCode
that indicates the status. InternalCode is an enum
Data
: which is a type of T
Message
: optional message
The FormatResponse
is an extension method that accepts a ServiceResponse<T>
and then checks the Internal Code and uses that to
return an appropriate response.
This makes it easy for us to return a uniform type of response.
2xx responses return a simple 2xx and an optional data
4xx and 5xx responses return a type of ProblemDetails
.
The helps to give more context to the nature of the response.
Repository
We use a Repository pattern that wraps EntityFramework unit of work pattern. The class is declared as a Generic class so that we can pass any model to it. We then declare helper methods that in turn call EntityFramework methods:
public class GenericRepository<T> : IGenericRepository<T> where T : class, new()
{
//Responses: failed=0, success=1
//IEnumerable iterates over an in-memory collection while IQueryable does so on the DB
// call to .ToList to enable instant query against DB
protected FlightBookingContext _db;
protected ILogger _logger;
//...omitted for brevity
public async Task<T?> GetByGuidAsync(Guid id)
{
var entity = await _db.Set<T>().FindAsync(id);
return entity;
}
}
In the aboved, we pass a type of DbContext
and a Logger
.
In the GetByGuidAsync()
method, we use the _db
of that model to find the data.
We must also inject the Repository of each model so we can use anywhere in our project:
public static class RepositoryModule
{
public static void AddRepository(this IServiceCollection services)
{
services.AddScoped<IGenericRepository<Booking>, GenericRepository<Booking>>();
services.AddScoped<IGenericRepository<FlightInformation>, GenericRepository<FlightInformation>>();
services.AddScoped<IGenericRepository<FlightFare>, GenericRepository<FlightFare>>();
services.AddScoped<IGenericRepository<Payment>, GenericRepository<Payment>>();
services.AddScoped<IGenericRepository<BookingOrder>, GenericRepository<BookingOrder>>();
services.AddScoped<IGenericRepository<ReservedSeat>, GenericRepository<ReservedSeat>>();
}
}
Just like in the Services, we also add the dependencies as a Scoped service.
This is important because we want to quickly use a DbContext which in turn holds a connection to the database. If we use it and quickly return it to the connection pool, we can avoid issues with resource exhaustion.
Data
We are using MySql. So we must install the following MySqlConnector and the Pomelo.EntityFramework.MySql libraries. Let's add these 2 lines to our Package reference.
<PackageReference Include="MySqlConnector" Version="2.3.5" />
<PackageReference Include="Pomelo.EntityFrameworkCore.MySql" Version="8.0.1" />
To use our model, we use classes to represent our data. Each class in the model is used to create tables in the database.
We declare properties of the class and also configure relationships.
The models are in Model
folder.
We also wish to do some relations that are not automatically done by the framework.
These configurations are applied to the model and is used to configure how the tables should be created during table creation: e.g:
public class BookingConfiguration : IEntityTypeConfiguration<Booking>
{
public void Configure(EntityTypeBuilder<Booking> entity)
{
entity.HasOne(d => d.BookingOrder).WithMany(p => p.Bookings)
.HasPrincipalKey(p => p.Id)
.HasForeignKey(d => d.BookingOrderId)
.OnDelete(DeleteBehavior.ClientSetNull);
entity.HasOne(d => d.FlightInformation).WithMany(p => p.Bookings)
.HasPrincipalKey(p => p.Id)
.HasForeignKey(d => d.FlightId)
.OnDelete(DeleteBehavior.ClientSetNull);
entity.HasOne(d => d.FlightFare).WithMany(p => p.Bookings)
.HasPrincipalKey(p => p.Id)
.HasForeignKey(d => d.FlightFareId)
.OnDelete(DeleteBehavior.ClientSetNull);
}
}
The above code for the Booking.cs
model configures the foreign key relationship.
To make use of our models, we create a FlightBookingDbContext.cs
where we declare all our models and also apply the configurations:
We also added a change in the OnModelCreating()
method of the FlightDbContext
to make sure that when a model (data in the database) is updated, the UpdatedAt
and CreatedAt
is saved as UTC
.
foreach (var entityType in modelBuilder.Model.GetEntityTypes())
{
foreach (var property in entityType.GetProperties())
{
if (property.ClrType == typeof(DateTime))
{
modelBuilder.Entity(entityType.ClrType)
.Property<DateTime>(property.Name)
.HasConversion(
v => v.ToUniversalTime(),
v => DateTime.SpecifyKind(v, DateTimeKind.Utc));
}
else if (property.ClrType == typeof(DateTime?))
{
modelBuilder.Entity(entityType.ClrType)
.Property<DateTime?>(property.Name)
.HasConversion(
v => v.HasValue ? v.Value.ToUniversalTime() : v,
v => v.HasValue ? DateTime.SpecifyKind(v.Value, DateTimeKind.Utc) : v);
}
}
}
To get our database schema updated:
Once we have configured our models, and added the FlightBookingContext
.
We also configure the FlightBookingContext
in the ConfigureService
method in our Startup.cs
and initialize it.
string mysqlConnectionString = Configuration.GetConnectionString("FlightBookingServiceDb_Mysql")!;
var mySqlServerVersion = new MySqlServerVersion(new Version(8, 0, 36));
services.AddDbContext<FlightBookingContext>(options =>
{
options.UseMySql(mysqlConnectionString, mySqlServerVersion, opt => opt.EnableRetryOnFailure())
.LogTo(Console.WriteLine, LogLevel.Warning)
.EnableSensitiveDataLogging()
.EnableDetailedErrors();
});
The code indicates that we get our connection string from appsettings.json To get the version of MySql, run this on the MySql server:
SELECT VERSION();
Once we have our models, configuration and DbContext ready, we need to run Migrations.
Migrations take a snapshot of our models and configuration and defines how they will be used to updated the database schema at that point in time.
To run migrations, open the Package Manager Console and run:
Add-Migration InitialCreate
This will create a migration named InitialCreateMysql
.
We then run
Update-Database
This will configure the database with the code generated in InitialCreateMysql
.
Whenever we make a change to our models and configuration, we must create a new migration and update our database so that the database schema is kept updated
If we wish to use a different database, we first configure the DbContext. E.g if using Sql Server:
services.AddDbContext<FlightBookingContext>(options =>
{
options.UseSqlServer(Configuration.GetConnectionString("FlightBookingServiceDb"));
});
Then we must create a new migration. It is important to note that Migrations are scoped to a database. So we need a new migration when we switched to a different database