Skip to content

Commit

Permalink
Implement controller for accessing database with airports with follow…
Browse files Browse the repository at this point in the history
…ing middleware and filters:

- caching
- validation of input IATA code
- global exception handler
- invalid request to database filter
- URI disconitinuation filter
Use CQRS pattern to decouple any logic from controllers by using MediatR NuGet package.
Add Repository pattern to decouple access to database from CQRS pattern.
  • Loading branch information
ivan_nizic committed Dec 2, 2024
1 parent e254787 commit f77c02f
Show file tree
Hide file tree
Showing 22 changed files with 425 additions and 12 deletions.
11 changes: 10 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,15 @@
- **CLI project** - it is responsible for scraping data from wikipedia page and exporting it to database file. This is done since IATA codes sholdn't be taken from resource that almost everyone can edit. This database file can afterwards be used on server side of application.
- CLI application has clean decoupling of input received from CLI UI and business logic, which enables good testability.
- SQLite is used as database because of simlicity. There is no need for separate server installation, it's stored in single file which simplifies deployment and enhances it's portability. It also has support for Entity Framework which will afterwards be used as database manager.
- **Domain** - contains entities and abstractions for business services.
- **Persistence** - implements communication to SQLite database.
- implements Repository pattern to decouple access to database from application logic.
- **Application** - contains implementations for domain service abstractions, repositories, CQRS, caching...
- **Web API** - used for communication with flight APIs by controllers, and for communication to client application.
- uses CQRS pattern for decoupling controllers from outer infrastructure code.
- uses Repository pattern for decoupling access to database.
- contains various middleware: caching, validation of input IATA code, invalid request oarameter to database filter, URI disconitinuation filter

# Possible improvements
- Implement deploymnet for CLI project so that database file is uploaded to git repo
- Implement deploymnet for CLI project so that database file is uploaded to git repo
- Use Infrastructure project to better decouple some logic like caching from Application
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
namespace FlightScanner.Domain.Models;
namespace FlightScanner.Domain.Entities;

public class Airport
public class AirportEntity
{
public Airport(string iataCode, string? airportName, string? location)
public AirportEntity(string iataCode, string? airportName, string? location)
{
IataCode = iataCode;
AirportName = airportName;
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
namespace FlightScanner.Domain.Exceptions;

public class InvalidResponseException : Exception
{
public InvalidResponseException(string? message) : base(message)
{
}
}
9 changes: 9 additions & 0 deletions source/FlightScanner.Domain/Exceptions/NotFoundException.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
namespace FlightScanner.Domain.Exceptions;

public class NotFoundException : Exception
{
public NotFoundException(Type typeName, string objectId)
: base($"{typeName} with id {objectId} was not found!")
{
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@

using FlightScanner.Domain.Entities;

namespace FlightScanner.Domain.Repositories;

public interface IAirportRepository
{
Task<AirportEntity> GetAirportWithIataCode(string iataCode, CancellationToken cancellationToken);
}
Original file line number Diff line number Diff line change
@@ -1,12 +1,12 @@
using Microsoft.EntityFrameworkCore.Metadata.Builders;
using Microsoft.EntityFrameworkCore;
using FlightScanner.Domain.Models;
using FlightScanner.Domain.Entities;

namespace FlightScanner.Persistence.Configurations;

public class AirportEntityConfiguration : IEntityTypeConfiguration<Airport>
public class AirportEntityConfiguration : IEntityTypeConfiguration<AirportEntity>
{
public void Configure(EntityTypeBuilder<Airport> builder)
public void Configure(EntityTypeBuilder<AirportEntity> builder)
{
builder.HasKey(e => e.IataCode);
builder.Property(e => e.IataCode)
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
using FlightScanner.Domain.Models;
using FlightScanner.Domain.Entities;
using Microsoft.EntityFrameworkCore;

namespace FlightScanner.Persistence.Database;
Expand All @@ -12,7 +12,7 @@ public AirportsDbContext(DbContextOptions<AirportsDbContext> options)

#region Properties

public DbSet<Airport> Airports { get; set; }
public DbSet<AirportEntity> Airports { get; set; }

#endregion

Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
using FlightScanner.Persistence.Database;
using FlightScanner.Domain.Repositories;
using FlightScanner.Persistence.Database;
using FlightScanner.Persistence.Repositories;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection;
using System.Reflection;
Expand All @@ -15,6 +17,8 @@ public static class PersistenceServiceExtensions

public static IServiceCollection AddInfrastructureProjectServices(this IServiceCollection services)
{
services.AddScoped<IAirportRepository, AirportRepository>();

services.AddDbContext<AirportsDbContext>(optionsBuilder =>
{
optionsBuilder.UseSqlite($"Data Source={s_databaseFilePath}");
Expand Down
35 changes: 35 additions & 0 deletions source/FlightScanner.Persistence/Repositories/AirportRepository.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
using FlightScanner.Domain.Entities;
using FlightScanner.Domain.Exceptions;
using FlightScanner.Domain.Repositories;
using FlightScanner.Persistence.Database;
using Microsoft.EntityFrameworkCore;

namespace FlightScanner.Persistence.Repositories;

public class AirportRepository : IAirportRepository
{
private readonly AirportsDbContext _airportsDbContext;

public AirportRepository(AirportsDbContext airportsDbContext)
{
_airportsDbContext = airportsDbContext;
}

public async Task<AirportEntity> GetAirportWithIataCode(string iataCode, CancellationToken cancellationToken)
{
var airport = await _airportsDbContext
.Airports
.AsNoTracking()
.AsQueryable()
.FirstOrDefaultAsync(
predicate: airport => string.Equals(airport.IataCode, iataCode.ToUpper()),
cancellationToken: cancellationToken);

if (airport == null)
{
throw new NotFoundException(typeof(AirportEntity), iataCode);
}

return airport;
}
}
38 changes: 38 additions & 0 deletions source/FlightScanner.WebApi/Controllers/AirportCodesController.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
using FlightScanner.Domain.Entities;
using FlightScanner.WebApi.Filters;
using FlightScanner.WebApi.Validation;
using FlightsScanner.Application.Airports.Queries.GetAirport;
using MediatR;
using Microsoft.AspNetCore.Mvc;
using System.Net.Mime;

namespace FlightScanner.WebApi.Controllers;

[ApiController]
[Route("api/v2")]
[AirportCodesVersionDiscontinuationResourceFilter]
public class AirportCodesController : ControllerBase
{
private readonly ISender _sender;

public AirportCodesController(ISender sender)
{
_sender = sender;
}

[Produces(MediaTypeNames.Application.Json)]
[ProducesResponseType(StatusCodes.Status201Created, Type = typeof(AirportEntity))]
[HttpGet]
[Route("airport")]
[CountriesWithClosedAirTrafficFilter]
public async Task<IActionResult> GetAirport(
[FromQuery][IataCodeValidation] string iataCode,
CancellationToken cancellationToken)
{
var response = await _sender.Send(
request: new GetAirportQuery(iataCode),
cancellationToken: cancellationToken);

return Ok(response);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
using Microsoft.AspNetCore.Mvc.Filters;
using Microsoft.AspNetCore.Mvc;

namespace FlightScanner.WebApi.Filters;

public class AirportCodesVersionDiscontinuationResourceFilter : Attribute, IResourceFilter
{
private const string OUTDATED_API_VERSION = "v1";

public void OnResourceExecuted(ResourceExecutedContext context)
{

}

public void OnResourceExecuting(ResourceExecutingContext context)
{
var uriPath = context.HttpContext.Request.Path;

if (!string.IsNullOrEmpty(uriPath.Value) && uriPath.Value.Contains(OUTDATED_API_VERSION, StringComparison.CurrentCultureIgnoreCase))
{
var errorObject = new
{
Versioning = new[]
{
$"API version {OUTDATED_API_VERSION} is expired. Please use the latest version."
}
};

context.Result = new BadRequestObjectResult(errorObject);
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
using Microsoft.AspNetCore.Mvc.Filters;
using Microsoft.AspNetCore.Mvc;
using FlightScanner.Domain.Entities;

namespace FlightScanner.WebApi.Filters;

public class CountriesWithClosedAirTrafficFilter : ActionFilterAttribute
{
private readonly string[] _ukrainianAirportIataCodes = new string[]
{
"UCK",
"UDJ",
"UMY",
};

public override void OnActionExecuted(ActionExecutedContext context)
{
base.OnActionExecuted(context);

if (context.Result is not OkObjectResult result)
{
return;
}

if (result.Value is not AirportEntity airport)
{
return;
}

if (_ukrainianAirportIataCodes.Any(ukrainianAirportIataCode => string.Equals(ukrainianAirportIataCode, airport.IataCode, StringComparison.OrdinalIgnoreCase)))
{
context.ModelState.AddModelError(
key: "Forbidden IATA code",
errorMessage: $"Airport with IATA code {airport.IataCode} is currently closed due to war conditions!");

var problemDetails = new ValidationProblemDetails(context.ModelState)
{
Status = StatusCodes.Status406NotAcceptable
};

context.Result = new BadRequestObjectResult(problemDetails);
}
}
}
8 changes: 7 additions & 1 deletion source/FlightScanner.WebApi/FlightScanner.WebApi.csproj
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
<Project Sdk="Microsoft.NET.Sdk.Web">
<Project Sdk="Microsoft.NET.Sdk.Web">

<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
Expand All @@ -7,8 +7,14 @@
</PropertyGroup>

<ItemGroup>
<PackageReference Include="MediatR" Version="12.4.1" />
<PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="8.0.11" />
<PackageReference Include="Swashbuckle.AspNetCore" Version="6.6.2" />
</ItemGroup>

<ItemGroup>
<ProjectReference Include="..\FlightScanner.Persistence\FlightScanner.Persistence.csproj" />
<ProjectReference Include="..\FlightsScanner.Application\FlightsScanner.Application.csproj" />
</ItemGroup>

</Project>
25 changes: 24 additions & 1 deletion source/FlightScanner.WebApi/Program.cs
Original file line number Diff line number Diff line change
@@ -1,4 +1,10 @@
internal class Program
using FlightsScanner.Application.Constants;
using FlightsScanner.Application.Infrastructure;
using FlightsScanner.Application.Services.Contracts;
using FlightScanner.Persistence;
using FlightsScanner.Application.Airports.Queries.GetAirport;

internal class Program
{
private static void Main(string[] args)
{
Expand All @@ -17,6 +23,21 @@ private static void CreateWebBuilder(WebApplicationBuilder builder)
{
builder.Services.AddEndpointsApiExplorer();
builder.Services.AddSwaggerGen();

builder.Services.AddControllers();

builder.Services.AddInfrastructureProjectServices();

builder.Services.AddMemoryCache(options =>
{
options.SizeLimit = CacheConstants.CACHE_LIMIT;
});
builder.Services.AddScoped<IInMemoryCacheService, InMemoryCacheService>();

builder.Services.AddMediatR(configuration =>
{
configuration.RegisterServicesFromAssemblies(typeof(GetAirportQuery).Assembly);
});
}

private static void ConfigureMiddleware(WebApplication app)
Expand All @@ -29,5 +50,7 @@ private static void ConfigureMiddleware(WebApplication app)
}

app.UseHttpsRedirection();

app.MapControllers();
}
}
23 changes: 23 additions & 0 deletions source/FlightScanner.WebApi/Validation/IataCodeValidation.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
using System.ComponentModel.DataAnnotations;

namespace FlightScanner.WebApi.Validation;

public class IataCodeValidation : ValidationAttribute
{
private const int IATA_CODE_LENGTH = 3;

protected override ValidationResult? IsValid(object? value, ValidationContext validationContext)
{
if (value is not string iataCode)
{
return new ValidationResult($"Received input {value} is not text!");
}

if (iataCode.Length != IATA_CODE_LENGTH)
{
return new ValidationResult($"IATA code has invalid length of {iataCode.Length}. IATA code should have {IATA_CODE_LENGTH} text characters.");
}

return ValidationResult.Success;
}
}
6 changes: 6 additions & 0 deletions source/FlightScanner.sln
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,8 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "FlightScanner.Domain", "Fli
EndProject
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "FlightScanner.WebApi", "FlightScanner.WebApi\FlightScanner.WebApi.csproj", "{8A92404B-9967-4249-BE31-6C0760007B55}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "FlightsScanner.Application", "FlightsScanner.Application\FlightsScanner.Application.csproj", "{206F0DE9-7E35-4610-BC73-332324891C26}"
EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
Expand All @@ -41,6 +43,10 @@ Global
{8A92404B-9967-4249-BE31-6C0760007B55}.Debug|Any CPU.Build.0 = Debug|Any CPU
{8A92404B-9967-4249-BE31-6C0760007B55}.Release|Any CPU.ActiveCfg = Release|Any CPU
{8A92404B-9967-4249-BE31-6C0760007B55}.Release|Any CPU.Build.0 = Release|Any CPU
{206F0DE9-7E35-4610-BC73-332324891C26}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{206F0DE9-7E35-4610-BC73-332324891C26}.Debug|Any CPU.Build.0 = Debug|Any CPU
{206F0DE9-7E35-4610-BC73-332324891C26}.Release|Any CPU.ActiveCfg = Release|Any CPU
{206F0DE9-7E35-4610-BC73-332324891C26}.Release|Any CPU.Build.0 = Release|Any CPU
EndGlobalSection
GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE
Expand Down
Loading

0 comments on commit f77c02f

Please sign in to comment.