diff --git a/src/FlightRecorder.BusinessLogic/Api/AeroDataBox/AeroDataBoxFlightsApi.cs b/src/FlightRecorder.BusinessLogic/Api/AeroDataBox/AeroDataBoxFlightsApi.cs new file mode 100644 index 0000000..6ab39ad --- /dev/null +++ b/src/FlightRecorder.BusinessLogic/Api/AeroDataBox/AeroDataBoxFlightsApi.cs @@ -0,0 +1,111 @@ +using FlightRecorder.Entities.Api; +using FlightRecorder.Entities.Interfaces; +using FlightRecorder.Entities.Logging; +using System; +using System.Collections.Generic; +using System.Threading.Tasks; + +namespace FlightRecorder.BusinessLogic.Api.AeroDataBox +{ + public class AeroDataBoxFlightsApi : ExternalApiBase, IFlightsApi + { + private const string DateFormat = "yyyy-MM-dd"; + + private readonly string _baseAddress; + private readonly string _host; + private readonly string _key; + + public AeroDataBoxFlightsApi( + IFlightRecorderLogger logger, + IFlightRecorderHttpClient client, + string url, + string key) + : base(logger, client) + { + _baseAddress = url; + _key = key; + + // The URL contains the protocol, host and base route (if any), but we need to extract the host name only + // to pass in the headers as the RapidAPI host, so capture the host and the full URL + Uri uri = new Uri(url); + _host = uri.Host; + } + + /// + /// Look up flight details given a flight number + /// + /// + /// + /// + public async Task> LookupFlightByNumber(string number) + { + Logger.LogMessage(Severity.Info, $"Looking up flight {number}"); + var properties = await MakeApiRequest(number); + return properties; + } + + /// + /// Look up flight details given a flight number and date + /// + /// + /// + /// + public async Task> LookupFlightByNumberAndDate(string number, DateTime date) + { + Logger.LogMessage(Severity.Info, $"Looking up flight {number} on {date}"); + var parameters = $"{number}/{date.ToString(DateFormat)}"; + var properties = await MakeApiRequest(parameters); + return properties; + } + + /// + /// Make a request for flight details using the specified parameters + /// + /// + /// + private async Task> MakeApiRequest(string parameters) + { + Dictionary properties = null; + + // Definte the properties to be + List definitions = new List + { + new ApiPropertyDefinition{ PropertyType = ApiPropertyType.DepartureAirportIATA, JsonPath = "departure.airport.iata" }, + new ApiPropertyDefinition{ PropertyType = ApiPropertyType.DestinationAirportIATA, JsonPath = "arrival.airport.iata" }, + new ApiPropertyDefinition{ PropertyType = ApiPropertyType.AirlineName, JsonPath = "airline.name" } + }; + + // Set the headers + SetHeaders(new Dictionary + { + { "X-RapidAPI-Host", _host }, + { "X-RapidAPI-Key", _key } + }); + + // Make a request for the data from the API + var url = $"{_baseAddress}{parameters}"; + var node = await SendRequest(url); + + if (node != null) + { + try + { + // Extract the required properties from the response + properties = GetPropertyValuesFromResponse(node![0], definitions); + + // Log the properties dictionary + LogProperties(properties!); + } + catch (Exception ex) + { + var message = $"Error processing response: {ex.Message}"; + Logger.LogMessage(Severity.Error, message); + Logger.LogException(ex); + properties = null; + } + } + + return properties; + } + } +} diff --git a/src/FlightRecorder.BusinessLogic/Api/ExternalApiBase.cs b/src/FlightRecorder.BusinessLogic/Api/ExternalApiBase.cs new file mode 100644 index 0000000..171e7f3 --- /dev/null +++ b/src/FlightRecorder.BusinessLogic/Api/ExternalApiBase.cs @@ -0,0 +1,143 @@ +using FlightRecorder.Entities.Api; +using FlightRecorder.Entities.Interfaces; +using FlightRecorder.Entities.Logging; +using System; +using System.Collections.Generic; +using System.Diagnostics.CodeAnalysis; +using System.Text.Json.Nodes; +using System.Threading.Tasks; + +namespace FlightRecorder.BusinessLogic.Api +{ + public abstract class ExternalApiBase + { + private readonly IFlightRecorderHttpClient _client; + protected IFlightRecorderLogger Logger { get; private set; } + + protected ExternalApiBase(IFlightRecorderLogger logger, IFlightRecorderHttpClient client) + { + Logger = logger; + _client = client; + } + + /// + /// Set the request headers + /// + /// + protected virtual void SetHeaders(Dictionary headers) + => _client.SetHeaders(headers); + + /// + /// Make a request to the specified URL and return the response properties as a JSON DOM + /// + /// + /// + protected virtual async Task SendRequest(string endpoint) + { + JsonNode node = null; + + try + { + // Make a request for the data from the API + using (var response = await _client.GetAsync(endpoint)) + { + // Check the request was successful + if (response.IsSuccessStatusCode) + { + // Read the response, parse to a JSON DOM + var json = await response.Content.ReadAsStringAsync(); + node = JsonNode.Parse(json); + } + } + } + catch (Exception ex) + { + var message = $"Error calling {endpoint}: {ex.Message}"; + Logger.LogMessage(Severity.Error, message); + Logger.LogException(ex); + node = null; + } + + return node; + } + + /// + /// Given a JSON node and the path to an element, return the value at that element + /// + /// + /// + /// + private static string GetPropertyValueByPath(JsonNode node, ApiPropertyDefinition definition) + { + string value = null; + var current = node; + + // Walk the JSON document to the requested element + foreach (var element in definition.JsonPath.Split(".", StringSplitOptions.RemoveEmptyEntries)) + { + current = current?[element]; + } + + // Check the element is a type that can yield a value + if (current is JsonValue) + { + // Extract the value as a string and if "cleanup" has been specified perform it + value = current?.GetValue(); + } + + return value; + } + + /// + /// + /// + /// + /// + protected virtual Dictionary GetPropertyValuesFromResponse(JsonNode node, IEnumerable propertyDefinitions) + { + var properties = new Dictionary(); + + // Iterate over the property definitions + foreach (var definition in propertyDefinitions) + { + // Get the value from + var value = GetPropertyValueByPath(node, definition); + properties.Add(definition.PropertyType, value ?? ""); + } + + // Log the properties dictionary + LogProperties(properties!); + + return properties; + } + + /// + /// Log the content of a properties dictionary resulting from an external API call + /// + /// + [ExcludeFromCodeCoverage] + protected void LogProperties(Dictionary properties) + { + // Check the properties dictionary isn't NULL + if (properties != null) + { + // Not a NULL dictionary, so iterate over all the properties it contains + foreach (var property in properties) + { + // Construct a message containing the property name and the value, replacing + // null values with "NULL" + var value = property.Value != null ? property.Value.ToString() : "NULL"; + var message = $"API property {property.Key.ToString()} = {value}"; + + // Log the message for this property + Logger.LogMessage(Severity.Info, message); + } + } + else + { + // Log the fact that the properties dictionary is NULL + Logger.LogMessage(Severity.Warning, "API lookup generated a NULL properties dictionary"); + } + } + } +} diff --git a/src/FlightRecorder.BusinessLogic/Api/FlightRecorderHttpClient.cs b/src/FlightRecorder.BusinessLogic/Api/FlightRecorderHttpClient.cs new file mode 100644 index 0000000..d5a14a3 --- /dev/null +++ b/src/FlightRecorder.BusinessLogic/Api/FlightRecorderHttpClient.cs @@ -0,0 +1,61 @@ +using FlightRecorder.Entities.Interfaces; +using System.Collections.Generic; +using System.Diagnostics.CodeAnalysis; +using System.Net.Http; +using System.Threading.Tasks; + +namespace FlightRecorder.BusinessLogic.Api +{ + [ExcludeFromCodeCoverage] + public class FlightRecorderHttpClient : IFlightRecorderHttpClient + { + private readonly static HttpClient _client = new(); + private static FlightRecorderHttpClient? _instance = null; + private readonly static object _lock = new(); + + private FlightRecorderHttpClient() { } + + /// + /// Return the singleton instance of the client + /// + public static FlightRecorderHttpClient Instance + { + get + { + if (_instance == null) + { + lock (_lock) + { + if (_instance == null) + { + _instance = new FlightRecorderHttpClient(); + } + } + } + + return _instance; + } + } + + /// + /// Set the request headers + /// + /// + public void SetHeaders(Dictionary headers) + { + _client.DefaultRequestHeaders.Clear(); + foreach (var header in headers) + { + _client.DefaultRequestHeaders.Add(header.Key, header.Value); + } + } + + /// + /// Send a GET request to the specified URI and return the response + /// + /// + /// + public async Task GetAsync(string uri) + => await _client.GetAsync(uri); + } +} diff --git a/src/FlightRecorder.BusinessLogic/Database/AircraftManager.cs b/src/FlightRecorder.BusinessLogic/Database/AircraftManager.cs new file mode 100644 index 0000000..7c24834 --- /dev/null +++ b/src/FlightRecorder.BusinessLogic/Database/AircraftManager.cs @@ -0,0 +1,174 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Linq.Expressions; +using System.Threading.Tasks; +using FlightRecorder.BusinessLogic.Extensions; +using FlightRecorder.BusinessLogic.Factory; +using FlightRecorder.Entities.Db; +using FlightRecorder.Entities.Interfaces; +using Microsoft.EntityFrameworkCore; + +namespace FlightRecorder.BusinessLogic.Database +{ + internal class AircraftManager : IAircraftManager + { + private const int AllModelsPageSize = 1000000; + + private readonly FlightRecorderFactory _factory; + + internal AircraftManager(FlightRecorderFactory factory) + { + _factory = factory; + } + + /// + /// Get the first aircraft matching the specified criteria along with the associated model + /// + /// + /// + public async Task GetAsync(Expression> predicate) + { + List aircraft = await ListAsync(predicate, 1, 1).ToListAsync(); + return aircraft.FirstOrDefault(); + } + + /// + /// Get the aircraft matching the specified criteria along with the associated models + /// + /// + /// + /// + /// + public IAsyncEnumerable ListAsync(Expression> predicate, int pageNumber, int pageSize) + { + IAsyncEnumerable aircraft; + + if (predicate == null) + { + aircraft = _factory.Context.Aircraft + .Include(a => a.Model) + .ThenInclude(m => m.Manufacturer) + .Skip((pageNumber - 1) * pageSize) + .Take(pageSize) + .AsAsyncEnumerable(); + } + else + { + aircraft = _factory.Context.Aircraft + .Include(a => a.Model) + .ThenInclude(m => m.Manufacturer) + .Where(predicate) + .Skip((pageNumber - 1) * pageSize) + .Take(pageSize) + .AsAsyncEnumerable(); + } + + return aircraft; + } + + /// + /// Get the aircraft of a specified model + /// + /// + /// + /// + /// + public async Task> ListByModelAsync(string modelName, int pageNumber, int pageSize) + { + IAsyncEnumerable matches = null; + + modelName = modelName.CleanString(); + Model model = await _factory.Models.GetAsync(m => m.Name == modelName); + if (model != null) + { + matches = ListAsync(m => m.ModelId == model.Id, pageNumber, pageSize); + } + + return matches; + } + + /// + /// Get the aircraft manufactured by a given manufacturer + /// + /// + /// + /// + /// + public async Task> ListByManufacturerAsync(string manufacturerName, int pageNumber, int pageSize) + { + IAsyncEnumerable matches = null; + + manufacturerName = manufacturerName.CleanString(); + Manufacturer manufacturer = await _factory.Manufacturers + .GetAsync(m => m.Name == manufacturerName); + if (manufacturer != null) + { + // Model retrieval uses an arbitrarily large page size to retrieve all models + List modelIds = await _factory.Models + .ListAsync(m => m.ManufacturerId == manufacturer.Id, 1, AllModelsPageSize) + .Select(m => m.Id) + .ToListAsync(); + if (modelIds.Any()) + { + matches = ListAsync(a => modelIds.Contains(a.ModelId ?? -1), pageNumber, pageSize); + } + } + + return matches; + } + + /// + /// Add an aircraft + /// + /// + /// + /// + /// + /// + /// + public async Task AddAsync(string registration, string serialNumber, long? yearOfManufacture, string modelName, string manufacturerName) + { + registration = registration.CleanString().ToUpper(); + Aircraft aircraft = await GetAsync(a => a.Registration == registration); + + if (aircraft == null) + { + // If partial aircraft details have been supplied, the manufacturer and/or model may not + // be specified + long? modelId = null; + if (!string.IsNullOrEmpty(manufacturerName) && !string.IsNullOrEmpty(modelName)) + { + Model model = await _factory.Models.AddAsync(modelName, manufacturerName); + modelId = model.Id; + } + + // Similarly, the serial number should be cleaned and then treated as null when assigning + // to the new aircraft, below, if the result is empty + var cleanSerialNumber = serialNumber.CleanString().ToUpper(); + var haveSerialNumber = !string.IsNullOrEmpty(cleanSerialNumber); + + // Finally, year of manufacture should only be stored if we have a model or serial number + long? manufactured = haveSerialNumber && (modelId != null) ? yearOfManufacture : null; + + aircraft = new Aircraft + { + Registration = registration, + SerialNumber = haveSerialNumber ? cleanSerialNumber : null, + Manufactured = manufactured, + ModelId = modelId + }; + + await _factory.Context.AddAsync(aircraft); + await _factory.Context.SaveChangesAsync(); + await _factory.Context.Entry(aircraft).Reference(m => m.Model).LoadAsync(); + if (aircraft.Model != null) + { + await _factory.Context.Entry(aircraft.Model).Reference(m => m.Manufacturer).LoadAsync(); + } + } + + return aircraft; + } + } +} diff --git a/src/FlightRecorder.BusinessLogic/Database/AirlineManager.cs b/src/FlightRecorder.BusinessLogic/Database/AirlineManager.cs new file mode 100644 index 0000000..2d9c7a9 --- /dev/null +++ b/src/FlightRecorder.BusinessLogic/Database/AirlineManager.cs @@ -0,0 +1,79 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Linq.Expressions; +using System.Threading.Tasks; +using FlightRecorder.BusinessLogic.Extensions; +using FlightRecorder.Data; +using FlightRecorder.Entities.Db; +using FlightRecorder.Entities.Interfaces; +using Microsoft.EntityFrameworkCore; + +namespace FlightRecorder.BusinessLogic.Database +{ + internal class AirlineManager : IAirlineManager + { + private readonly FlightRecorderDbContext _context; + + internal AirlineManager(FlightRecorderDbContext context) + { + _context = context; + } + + /// + /// Get the first airline matching the specified criteria + /// + /// + /// + public async Task GetAsync(Expression> predicate) + { + List airlines = await ListAsync(predicate, 1, 1).ToListAsync(); + return airlines.FirstOrDefault(); + } + + /// + /// Return all entities matching the specified criteria + /// + /// + /// + /// + /// + public IAsyncEnumerable ListAsync(Expression> predicate, int pageNumber, int pageSize) + { + IAsyncEnumerable results; + if (predicate == null) + { + results = _context.Airlines + .Skip((pageNumber - 1) * pageSize) + .Take(pageSize) + .AsAsyncEnumerable(); + } + else + { + results = _context.Airlines.Where(predicate).AsAsyncEnumerable(); + } + + return results; + } + + /// + /// Add a named airline, if it doesn't already exist + /// + /// + /// + public async Task AddAsync(string name) + { + name = name.CleanString(); + Airline airline = await GetAsync(a => a.Name == name); + + if (airline == null) + { + airline = new Airline { Name = name }; + await _context.Airlines.AddAsync(airline); + await _context.SaveChangesAsync(); + } + + return airline; + } + } +} diff --git a/src/FlightRecorder.BusinessLogic/Database/AirportManager.cs b/src/FlightRecorder.BusinessLogic/Database/AirportManager.cs new file mode 100644 index 0000000..f0d33c0 --- /dev/null +++ b/src/FlightRecorder.BusinessLogic/Database/AirportManager.cs @@ -0,0 +1,99 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Linq.Expressions; +using System.Threading.Tasks; +using FlightRecorder.BusinessLogic.Extensions; +using FlightRecorder.BusinessLogic.Factory; +using FlightRecorder.Entities.Db; +using FlightRecorder.Entities.Interfaces; +using Microsoft.EntityFrameworkCore; + +namespace FlightRecorder.BusinessLogic.Database +{ + internal class AirportManager : IAirportManager + { + private readonly FlightRecorderFactory _factory; + + internal AirportManager(FlightRecorderFactory factory) + { + _factory = factory; + } + + /// + /// Get the first airport matching the specified criteria along with the associated airline + /// + /// + /// + public async Task GetAsync(Expression> predicate) + { + List airports = await ListAsync(predicate, 1, 1).ToListAsync(); + return airports.FirstOrDefault(); + } + + /// + /// Get the airports matching the specified criteria along with the associated airlines + /// + /// + /// + /// + /// + public IAsyncEnumerable ListAsync(Expression> predicate, int pageNumber, int pageSize) + { + IAsyncEnumerable airports; + + if (predicate == null) + { + airports = _factory.Context.Airports + .Include(m => m.Country) + .Skip((pageNumber - 1) * pageSize) + .Take(pageSize) + .AsAsyncEnumerable(); + } + else + { + airports = _factory.Context.Airports + .Include(m => m.Country) + .Where(predicate) + .Skip((pageNumber - 1) * pageSize) + .Take(pageSize) + .AsAsyncEnumerable(); + } + + return airports; + } + + /// + /// Add a new airport + /// + /// + /// + /// + /// + public async Task AddAsync(string code, string name, string countryName) + { + code = code.CleanString().ToUpper(); + name = name.CleanString(); + countryName = countryName.CleanString(); + Airport airport = await GetAsync(a => (a.Code == code)); + + if (airport == null) + { + Country country = await _factory.Countries.AddAsync(countryName); + + airport = new Airport + { + Code = code, + Name = name, + CountryId = country.Id + }; + + await _factory.Context.Airports.AddAsync(airport); + await _factory.Context.SaveChangesAsync(); + await _factory.Context.Entry(airport).Reference(m => m.Country).LoadAsync(); + } + + return airport; + } + } +} diff --git a/src/FlightRecorder.BusinessLogic/Database/CountryManager.cs b/src/FlightRecorder.BusinessLogic/Database/CountryManager.cs new file mode 100644 index 0000000..f6c50d0 --- /dev/null +++ b/src/FlightRecorder.BusinessLogic/Database/CountryManager.cs @@ -0,0 +1,80 @@ +using FlightRecorder.BusinessLogic.Extensions; +using FlightRecorder.Data; +using FlightRecorder.Entities.Db; +using FlightRecorder.Entities.Interfaces; +using Microsoft.EntityFrameworkCore; +using System; +using System.Collections.Generic; +using System.Linq; +using System.Linq.Expressions; +using System.Threading.Tasks; + + +namespace FlightRecorder.BusinessLogic.Database +{ + internal class CountryManager : ICountryManager + { + private readonly FlightRecorderDbContext _context; + + internal CountryManager(FlightRecorderDbContext context) + { + _context = context; + } + + /// + /// Get the first airline matching the specified criteria + /// + /// + /// + public async Task GetAsync(Expression> predicate) + { + List countries = await ListAsync(predicate, 1, 1).ToListAsync(); + return countries.FirstOrDefault(); + } + + /// + /// Return all entities matching the specified criteria + /// + /// + /// + /// + /// + public IAsyncEnumerable ListAsync(Expression> predicate, int pageNumber, int pageSize) + { + IAsyncEnumerable results; + if (predicate == null) + { + results = _context.Countries + .Skip((pageNumber - 1) * pageSize) + .Take(pageSize) + .AsAsyncEnumerable(); + } + else + { + results = _context.Countries.Where(predicate).AsAsyncEnumerable(); + } + + return results; + } + + /// + /// Add a named airline, if it doesn't already exist + /// + /// + /// + public async Task AddAsync(string name) + { + name = name.CleanString(); + Country country = await GetAsync(a => a.Name == name); + + if (country == null) + { + country = new Country { Name = name }; + await _context.Countries.AddAsync(country); + await _context.SaveChangesAsync(); + } + + return country; + } + } +} diff --git a/src/FlightRecorder.BusinessLogic/Database/DateBasedReport.cs b/src/FlightRecorder.BusinessLogic/Database/DateBasedReport.cs new file mode 100644 index 0000000..0d19e9d --- /dev/null +++ b/src/FlightRecorder.BusinessLogic/Database/DateBasedReport.cs @@ -0,0 +1,67 @@ +using FlightRecorder.Data; +using FlightRecorder.Entities.Interfaces; +using System; +using System.Collections.Generic; +using System.Diagnostics.CodeAnalysis; +using System.Threading.Tasks; + +namespace FlightRecorder.BusinessLogic.Database +{ + [ExcludeFromCodeCoverage] + internal class DateBasedReport : ReportManagerBase, IDateBasedReport where T : class + { + private const string DateFormat = "yyyy-MM-dd"; + private const string FromDatePlaceHolder = "$from"; + private const string ToDatePlaceHolder = "$to"; + + internal DateBasedReport(FlightRecorderDbContext context) : base(context) + { + } + + /// + /// Generate a datebased report for reporting entity type T + /// + /// + /// + /// + /// + /// + public async Task> GenerateReportAsync(DateTime? from, DateTime? to, int pageNumber, int pageSize) + { + // SQL report files are named after the keyless entity type they map to with a .sql extension + var sqlFile = $"{typeof(T).Name}.sql"; + + // Load the SQL file and perform date range place-holder replacements + var query = ReadDateBasedSqlReportResource(sqlFile, from, to); + + // Run the query and return the results + var results = await GenerateReportAsync(query, pageNumber, pageSize); + return results; + } + + /// + /// Read the SQL report file for a sightings-based report with a date range in it + /// + /// + /// + /// + /// + private static string ReadDateBasedSqlReportResource(string reportFile, DateTime? from, DateTime? to) + { + // Get non-NULL versions of the from and to dates + var nonNullFromDate = (from ?? DateTime.MinValue).ToString(DateFormat); + var nonNullToDate = (to ?? DateTime.MaxValue).ToString(DateFormat); + + // Read and return the query, replacing the date range parameters + var query = ReadSqlResource(reportFile, new Dictionary + { + { FromDatePlaceHolder, nonNullFromDate }, + { ToDatePlaceHolder, nonNullToDate } + + }); + + return query; + } + + } +} diff --git a/src/FlightRecorder.BusinessLogic/Database/FlightManager.cs b/src/FlightRecorder.BusinessLogic/Database/FlightManager.cs new file mode 100644 index 0000000..16fd66a --- /dev/null +++ b/src/FlightRecorder.BusinessLogic/Database/FlightManager.cs @@ -0,0 +1,125 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Linq.Expressions; +using System.Threading.Tasks; +using FlightRecorder.BusinessLogic.Extensions; +using FlightRecorder.BusinessLogic.Factory; +using FlightRecorder.Entities.Db; +using FlightRecorder.Entities.Interfaces; +using Microsoft.EntityFrameworkCore; + +namespace FlightRecorder.BusinessLogic.Database +{ + internal class FlightManager : IFlightManager + { + private readonly FlightRecorderFactory _factory; + + internal FlightManager(FlightRecorderFactory factory) + { + _factory = factory; + } + + /// + /// Get the first flight matching the specified criteria along with the associated airline + /// + /// + /// + public async Task GetAsync(Expression> predicate) + { + List flights = await ListAsync(predicate, 1, 1).ToListAsync(); + return flights.FirstOrDefault(); + } + + /// + /// Get the flights matching the specified criteria along with the associated airlines + /// + /// + /// + /// + /// + public IAsyncEnumerable ListAsync(Expression> predicate, int pageNumber, int pageSize) + { + IAsyncEnumerable flights; + + if (predicate == null) + { + flights = _factory.Context.Flights + .Include(m => m.Airline) + .Skip((pageNumber - 1) * pageSize) + .Take(pageSize) + .AsAsyncEnumerable(); + } + else + { + flights = _factory.Context.Flights + .Include(m => m.Airline) + .Where(predicate) + .Skip((pageNumber - 1) * pageSize) + .Take(pageSize) + .AsAsyncEnumerable(); + } + + return flights; + } + + /// + /// Get the flights for a named airline + /// + /// + /// + /// + /// + public async Task> ListByAirlineAsync(string airlineName, int pageNumber, int pageSize) + { + IAsyncEnumerable matches = null; + + airlineName = airlineName.CleanString(); + Airline airline = await _factory.Airlines + .GetAsync(m => m.Name == airlineName); + if (airline != null) + { + matches = ListAsync(m => m.AirlineId == airline.Id, pageNumber, pageSize); + } + + return matches; + } + + /// + /// Add a new flight + /// + /// + /// + /// + /// + /// + public async Task AddAsync(string number, string embarkation, string destination, string airlineName) + { + number = number.CleanString().ToUpper(); + embarkation = embarkation.CleanString().ToUpper(); + destination = destination.CleanString().ToUpper(); + Flight flight = await GetAsync(a => (a.Number == number) && + (a.Embarkation == embarkation) && + (a.Destination == destination)); + + if (flight == null) + { + Airline airline = await _factory.Airlines.AddAsync(airlineName); + + flight = new Flight + { + Number = number, + Embarkation = embarkation, + Destination = destination, + AirlineId = airline.Id + }; + + await _factory.Context.Flights.AddAsync(flight); + await _factory.Context.SaveChangesAsync(); + await _factory.Context.Entry(flight).Reference(m => m.Airline).LoadAsync(); + } + + return flight; + } + } +} diff --git a/src/FlightRecorder.BusinessLogic/Database/JobStatusManager.cs b/src/FlightRecorder.BusinessLogic/Database/JobStatusManager.cs new file mode 100644 index 0000000..fb09cbc --- /dev/null +++ b/src/FlightRecorder.BusinessLogic/Database/JobStatusManager.cs @@ -0,0 +1,98 @@ +using FlightRecorder.BusinessLogic.Extensions; +using FlightRecorder.Data; +using FlightRecorder.Entities.Db; +using FlightRecorder.Entities.Interfaces; +using Microsoft.EntityFrameworkCore; +using System; +using System.Collections.Generic; +using System.Linq; +using System.Linq.Expressions; +using System.Threading.Tasks; + +namespace FlightRecorder.BusinessLogic.Database +{ + internal class JobStatusManager : IJobStatusManager + { + private readonly FlightRecorderDbContext _context; + + internal JobStatusManager(FlightRecorderDbContext context) + { + _context = context; + } + + /// + /// Get the first job status matching the specified criteria + /// + /// + /// + public async Task GetAsync(Expression> predicate) + { + List statuses = await ListAsync(predicate, 1, 1).ToListAsync(); + return statuses.FirstOrDefault(); + } + + /// + /// Return all entities matching the specified criteria + /// + /// + /// + /// + /// + public IAsyncEnumerable ListAsync(Expression> predicate, int pageNumber, int pageSize) + { + IAsyncEnumerable results; + if (predicate == null) + { + results = _context.JobStatuses + .Skip((pageNumber - 1) * pageSize) + .Take(pageSize) + .AsAsyncEnumerable(); + } + else + { + results = _context.JobStatuses.Where(predicate).AsAsyncEnumerable(); + } + + return results; + } + + /// + /// Create a new job status + /// + /// + /// + public async Task AddAsync(string name, string parameters) + { + JobStatus status = new JobStatus + { + Name = name.CleanString(), + Parameters = parameters.CleanString(), + Start = DateTime.Now + }; + + await _context.JobStatuses.AddAsync(status); + await _context.SaveChangesAsync(); + + return status; + } + + /// + /// Update a job status, setting the end timestamp and error result + /// + /// + /// + /// + public async Task UpdateAsync(long id, string error) + { + JobStatus status = await GetAsync(x => x.Id == id); + if (status != null) + { + status.End = DateTime.Now; + status.Error = error.CleanString(); + await _context.SaveChangesAsync(); + } + + return status; + } + } +} diff --git a/src/FlightRecorder.BusinessLogic/Database/LocationManager.cs b/src/FlightRecorder.BusinessLogic/Database/LocationManager.cs new file mode 100644 index 0000000..22f5e62 --- /dev/null +++ b/src/FlightRecorder.BusinessLogic/Database/LocationManager.cs @@ -0,0 +1,83 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Linq.Expressions; +using System.Threading.Tasks; +using FlightRecorder.BusinessLogic.Extensions; +using FlightRecorder.Data; +using FlightRecorder.Entities.Db; +using FlightRecorder.Entities.Interfaces; +using Microsoft.EntityFrameworkCore; + +namespace FlightRecorder.BusinessLogic.Database +{ + internal class LocationManager : ILocationManager + { + private readonly FlightRecorderDbContext _context; + + internal LocationManager(FlightRecorderDbContext context) + { + _context = context; + } + + /// + /// Return the first entity matching the specified criteria + /// + /// + /// + public async Task GetAsync(Expression> predicate) + { + List locations = await ListAsync(predicate, 1, 1).ToListAsync(); + return locations.FirstOrDefault(); + } + + /// + /// Return all entities matching the specified criteria + /// + /// + /// + /// + /// + public virtual IAsyncEnumerable ListAsync(Expression> predicate, int pageNumber, int pageSize) + { + IAsyncEnumerable results; + if (predicate == null) + { + results = _context.Locations + .Skip((pageNumber - 1) * pageSize) + .Take(pageSize) + .AsAsyncEnumerable(); + } + else + { + results = _context.Locations + .Where(predicate) + .Skip((pageNumber - 1) * pageSize) + .Take(pageSize) + .AsAsyncEnumerable(); + } + + return results; + } + + /// + /// Add a named location, if it doesn't already exist + /// + /// + /// + public async Task AddAsync(string name) + { + name = name.CleanString(); + Location location = await GetAsync(a => a.Name == name); + + if (location == null) + { + location = new Location { Name = name }; + await _context.Locations.AddAsync(location); + await _context.SaveChangesAsync(); + } + + return location; + } + } +} diff --git a/src/FlightRecorder.BusinessLogic/Database/ManufacturerManager.cs b/src/FlightRecorder.BusinessLogic/Database/ManufacturerManager.cs new file mode 100644 index 0000000..a7a1e58 --- /dev/null +++ b/src/FlightRecorder.BusinessLogic/Database/ManufacturerManager.cs @@ -0,0 +1,83 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Linq.Expressions; +using System.Threading.Tasks; +using FlightRecorder.BusinessLogic.Extensions; +using FlightRecorder.Data; +using FlightRecorder.Entities.Db; +using FlightRecorder.Entities.Interfaces; +using Microsoft.EntityFrameworkCore; + +namespace FlightRecorder.BusinessLogic.Database +{ + internal class ManufacturerManager : IManufacturerManager + { + private readonly FlightRecorderDbContext _context; + + internal ManufacturerManager(FlightRecorderDbContext context) + { + _context = context; + } + + /// + /// Return the first entity matching the specified criteria + /// + /// + /// + public async Task GetAsync(Expression> predicate) + { + List manufacturers = await ListAsync(predicate, 1, 1).ToListAsync(); + return manufacturers.FirstOrDefault(); + } + + /// + /// Return all entities matching the specified criteria + /// + /// + /// + /// + /// + public IAsyncEnumerable ListAsync(Expression> predicate, int pageNumber, int pageSize) + { + IAsyncEnumerable results; + if (predicate == null) + { + results = _context.Manufacturers + .Skip((pageNumber - 1) * pageSize) + .Take(pageSize) + .AsAsyncEnumerable(); + } + else + { + results = _context.Manufacturers + .Where(predicate) + .Skip((pageNumber - 1) * pageSize) + .Take(pageSize) + .AsAsyncEnumerable(); + } + + return results; + } + + /// + /// Add a named manufacturer, if it doesn't already exist + /// + /// + /// + public async Task AddAsync(string name) + { + name = name.CleanString(); + Manufacturer manufacturer = await GetAsync(a => a.Name == name); + + if (manufacturer == null) + { + manufacturer = new Manufacturer { Name = name }; + await _context.Manufacturers.AddAsync(manufacturer); + await _context.SaveChangesAsync(); + } + + return manufacturer; + } + } +} diff --git a/src/FlightRecorder.BusinessLogic/Database/ModelManager.cs b/src/FlightRecorder.BusinessLogic/Database/ModelManager.cs new file mode 100644 index 0000000..662cd84 --- /dev/null +++ b/src/FlightRecorder.BusinessLogic/Database/ModelManager.cs @@ -0,0 +1,108 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Linq.Expressions; +using System.Threading.Tasks; +using FlightRecorder.BusinessLogic.Extensions; +using FlightRecorder.BusinessLogic.Factory; +using FlightRecorder.Entities.Db; +using FlightRecorder.Entities.Interfaces; +using Microsoft.EntityFrameworkCore; + +namespace FlightRecorder.BusinessLogic.Database +{ + internal class ModelManager : IModelManager + { + private readonly FlightRecorderFactory _factory; + + internal ModelManager(FlightRecorderFactory factory) + { + _factory = factory; + } + + /// + /// Get the first model matching the specified criteria along with the associated manufacturer + /// + /// + /// + public async Task GetAsync(Expression> predicate) + { + List models = await ListAsync(predicate, 1, 1).ToListAsync(); + return models.FirstOrDefault(); + } + + /// + /// Get the models matching the specified criteria along with the associated manufacturers + /// + /// + /// + /// + /// + public IAsyncEnumerable ListAsync(Expression> predicate, int pageNumber, int pageSize) + { + IAsyncEnumerable models; + + if (predicate == null) + { + models = _factory.Context.Models + .Include(m => m.Manufacturer) + .Skip((pageNumber - 1) * pageSize) + .Take(pageSize) + .AsAsyncEnumerable(); + } + else + { + models = _factory.Context.Models + .Include(m => m.Manufacturer) + .Where(predicate) + .Skip((pageNumber - 1) * pageSize) + .Take(pageSize) + .AsAsyncEnumerable(); + } + + return models; + } + + /// + /// Get the models for a named manufacturer + /// + /// + /// + /// + /// + public IAsyncEnumerable ListByManufacturerAsync(string manufacturerName, int pageNumber, int pageSize) + { + manufacturerName = manufacturerName.CleanString(); + IAsyncEnumerable models = _factory.Context.Models + .Include(m => m.Manufacturer) + .Where(m => m.Manufacturer.Name == manufacturerName) + .Skip((pageNumber - 1) * pageSize) + .Take(pageSize) + .AsAsyncEnumerable(); + return models; + } + + /// + /// Add a named model associated with the specified manufacturer, if it doesn't already exist + /// + /// + /// + /// + public async Task AddAsync(string name, string manufacturerName) + { + name = name.CleanString(); + Model model = await GetAsync(a => a.Name == name); + + if (model == null) + { + Manufacturer manufacturer = await _factory.Manufacturers.AddAsync(manufacturerName); + model = new Model { Name = name, ManufacturerId = manufacturer.Id }; + await _factory.Context.Models.AddAsync(model); + await _factory.Context.SaveChangesAsync(); + await _factory.Context.Entry(model).Reference(m => m.Manufacturer).LoadAsync(); + } + + return model; + } + } +} diff --git a/src/FlightRecorder.BusinessLogic/Database/ReportManagerBase.cs b/src/FlightRecorder.BusinessLogic/Database/ReportManagerBase.cs new file mode 100644 index 0000000..2b2a5b9 --- /dev/null +++ b/src/FlightRecorder.BusinessLogic/Database/ReportManagerBase.cs @@ -0,0 +1,120 @@ +using FlightRecorder.Data; +using Microsoft.EntityFrameworkCore; +using System.Collections.Generic; +using System.Diagnostics.CodeAnalysis; +using System.IO; +using System.Linq; +using System.Reflection; +using System.Threading.Tasks; + +namespace FlightRecorder.BusinessLogic.Database +{ + [ExcludeFromCodeCoverage] + internal abstract class ReportManagerBase + { + private readonly FlightRecorderDbContext _context; + + protected ReportManagerBase(FlightRecorderDbContext context) + { + _context = context; + } + + /// + /// Generate the report for entity type T given a query that returns rows mapping to that + /// type + /// + /// + /// + /// + protected async Task> GenerateReportAsync(string query, int pageNumber, int pageSize) where T : class + { + // Pagination using Skip and Take causes the database query to fail with FromSqlRaw, possible + // dependent on the DBS. To avoid this, the results are queried in two steps: + // + // 1) Query the database for all the report results and convert to a list + // 2) Extract the required page from the in-memory list + var all = await _context.Set().FromSqlRaw(query).ToListAsync(); + var results = all.Skip((pageNumber - 1) * pageSize).Take(pageSize); + return results; + } + + /// + /// Get the full path to a file held in the SQL sub-folder + /// + /// + /// + protected static string GetSqlFilePath(string fileName) + { + var assembly = Assembly.GetExecutingAssembly(); + var assemblyFolder = Path.GetDirectoryName(assembly.Location); + var sqlFilePath = Path.Combine(assemblyFolder, "sql", fileName); + return sqlFilePath; + } + + /// + /// Read a SQL file and perform placeholder value replacement + /// + /// + /// + protected static string ReadSqlFile(string file, Dictionary placeHolderValues) + { + string content = ""; + + // Open a stream reader to read the file content + using (var reader = new StreamReader(file)) + { + // Read the file content + content = reader.ReadToEnd(); + + // Perform place holder replacement + content = ReplacePlaceHolders(content, placeHolderValues); + } + + return content; + } + + /// + /// + /// + /// + /// + /// + protected static string ReadSqlResource(string file, Dictionary placeHolderValues) + { + string content = ""; + + // Get the name of the resource and a resource stream for reading it + var assembly = Assembly.GetExecutingAssembly(); + var sqlResourceName = $"FlightRecorder.BusinessLogic.Sql.{file}"; + var resourceStream = assembly.GetManifestResourceStream(sqlResourceName); + + // Open a stream reader to read the file content + using (var reader = new StreamReader(resourceStream)) + { + // Read the file content + content = reader.ReadToEnd(); + + // Perform place holder replacement + content = ReplacePlaceHolders(content, placeHolderValues); + } + + return content; + } + + /// + /// Perform place holder replacement on content read from an embedded resource or SQL file + /// + /// + /// + /// + private static string ReplacePlaceHolders(string content, Dictionary placeHolderValues) + { + foreach (var placeHolder in placeHolderValues.Keys) + { + content = content.Replace(placeHolder, placeHolderValues[placeHolder]); + } + + return content; + } + } +} diff --git a/src/FlightRecorder.BusinessLogic/Database/SightingManager.cs b/src/FlightRecorder.BusinessLogic/Database/SightingManager.cs new file mode 100644 index 0000000..56c64d5 --- /dev/null +++ b/src/FlightRecorder.BusinessLogic/Database/SightingManager.cs @@ -0,0 +1,222 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Linq.Expressions; +using System.Threading.Tasks; +using FlightRecorder.BusinessLogic.Extensions; +using FlightRecorder.BusinessLogic.Factory; +using FlightRecorder.Entities.DataExchange; +using FlightRecorder.Entities.Db; +using FlightRecorder.Entities.Interfaces; +using Microsoft.EntityFrameworkCore; + +namespace FlightRecorder.BusinessLogic.Database +{ + internal class SightingManager : ISightingManager + { + private const int AllFlightsPageSize = 1000000; + + private readonly FlightRecorderFactory _factory; + + internal SightingManager(FlightRecorderFactory factory) + { + _factory = factory; + } + + /// + /// Get the first sighting matching the specified criteria along with the associated entities + /// + /// + /// + public async Task GetAsync(Expression> predicate) + { + List sightings = await ListAsync(predicate, 1, 1).ToListAsync(); + return sightings.FirstOrDefault(); + } + + /// + /// Get the sightings matching the specified criteria along with the associated entities + /// + /// + /// + /// + /// + public IAsyncEnumerable ListAsync(Expression> predicate, int pageNumber, int pageSize) + { + IAsyncEnumerable sightings; + + if (predicate == null) + { + sightings = _factory.Context.Sightings + .Include(s => s.Location) + .Include(s => s.Flight) + .ThenInclude(f => f.Airline) + .Include(s => s.Aircraft) + .ThenInclude(a => a.Model) + .ThenInclude(m => m.Manufacturer) + .Skip((pageNumber - 1) * pageSize) + .Take(pageSize) + .AsAsyncEnumerable(); + } + else + { + sightings = _factory.Context.Sightings + .Include(s => s.Location) + .Include(s => s.Flight) + .ThenInclude(f => f.Airline) + .Include(s => s.Aircraft) + .ThenInclude(a => a.Model) + .ThenInclude(m => m.Manufacturer) + .Where(predicate) + .Skip((pageNumber - 1) * pageSize) + .Take(pageSize) + .AsAsyncEnumerable(); + } + + return sightings; + } + + /// + /// Add a new sighting + /// + /// + /// + /// + /// + /// + /// + public async Task AddAsync(long altitude, DateTime date, long locationId, long flightId, long aircraftId) + { + Sighting sighting = new Sighting + { + Altitude = altitude, + Date = date, + LocationId = locationId, + FlightId = flightId, + AircraftId = aircraftId + }; + + await _factory.Context.Sightings.AddAsync(sighting); + await _factory.Context.SaveChangesAsync(); + + // Loading the related entities using the following syntax is problematic if the model and/or + // manufacturer are NULL on the aircraft related to the sighting: + // + // await _factory.Context.Entry(sighting.Aircraft).Reference(a => a.Model).LoadAsync(); + // await _factory.Context.Entry(sighting.Aircraft.Model).Reference(m => m.Manufacturer).LoadAsync(); + // + // Instead, reload the sighting as this handles the above case + sighting = await GetAsync(x => x.Id == sighting.Id); + return sighting; + } + + /// + /// Add a sighting to the database based on a flattened representation of a sighting + /// + /// + /// + public async Task AddAsync(FlattenedSighting flattened) + { + long? yearOfManufacture = !string.IsNullOrEmpty(flattened.Age) ? DateTime.Now.Year - long.Parse(flattened.Age) : null; + long aircraftId = (await _factory.Aircraft.AddAsync(flattened.Registration, flattened.SerialNumber, yearOfManufacture, flattened.Model, flattened.Manufacturer)).Id; + long flightId = (await _factory.Flights.AddAsync(flattened.FlightNumber, flattened.Embarkation, flattened.Destination, flattened.Airline)).Id; + long locationId = (await _factory.Locations.AddAsync(flattened.Location)).Id; + return await AddAsync(flattened.Altitude, flattened.Date, locationId, flightId, aircraftId); + } + + /// + /// Return a list of sightings of a specified aircraft + /// + /// + /// + /// + /// + public async Task> ListByAircraftAsync(string registration, int pageNumber, int pageSize) + { + IAsyncEnumerable sightings = null; + + registration = registration.CleanString().ToUpper(); + Aircraft aircraft = await _factory.Aircraft.GetAsync(a => a.Registration == registration); + if (aircraft != null) + { + sightings = ListAsync(s => s.AircraftId == aircraft.Id, pageNumber, pageSize); + } + + return sightings; + } + + /// + /// Return a list of sightings for a specified route + /// + /// + /// + /// + /// + /// + public async Task> ListByRouteAsync(string embarkation, string destination, int pageNumber, int pageSize) + { + IAsyncEnumerable sightings = null; + + embarkation = embarkation.CleanString().ToUpper(); + destination = destination.CleanString().ToUpper(); + List flights = await _factory.Flights + .ListAsync(f => (f.Embarkation == embarkation) && + (f.Destination == destination), 1, AllFlightsPageSize) + .ToListAsync(); + if (flights.Any()) + { + IEnumerable flightIds = flights.Select(f => f.Id); + sightings = ListAsync(s => flightIds.Contains(s.FlightId), pageNumber, pageSize); + } + + return sightings; + } + + /// + /// Return a list of sightings for a specified airline + /// + /// + /// + /// + /// + public async Task> ListByAirlineAsync(string airlineName, int pageNumber, int pageSize) + { + IAsyncEnumerable sightings = null; + + airlineName = airlineName.CleanString(); + IAsyncEnumerable matches = await _factory.Flights.ListByAirlineAsync(airlineName, 1, AllFlightsPageSize); + if (matches != null) + { + List flights = await matches.ToListAsync(); + if (flights.Any()) + { + IEnumerable flightIds = flights.Select(f => f.Id); + sightings = ListAsync(s => flightIds.Contains(s.FlightId), pageNumber, pageSize); + } + } + + return sightings; + } + + /// + /// Return a list of sightings at a specified location + /// + /// + /// + /// + /// + public async Task> ListByLocationAsync(string locationName, int pageNumber, int pageSize) + { + IAsyncEnumerable sightings = null; + + locationName = locationName.CleanString(); + Location location = await _factory.Locations.GetAsync(a => a.Name == locationName); + if (location != null) + { + sightings = ListAsync(s => s.LocationId == location.Id, pageNumber, pageSize); + } + + return sightings; + } + } +} diff --git a/src/FlightRecorder.BusinessLogic/Database/UserManager.cs b/src/FlightRecorder.BusinessLogic/Database/UserManager.cs new file mode 100644 index 0000000..4485d53 --- /dev/null +++ b/src/FlightRecorder.BusinessLogic/Database/UserManager.cs @@ -0,0 +1,148 @@ +using System; +using System.Collections.Generic; +using System.Diagnostics.CodeAnalysis; +using System.Linq; +using System.Threading.Tasks; +using FlightRecorder.Data; +using FlightRecorder.Entities.Db; +using FlightRecorder.Entities.Exceptions; +using FlightRecorder.Entities.Interfaces; +using Microsoft.AspNetCore.Identity; +using Microsoft.EntityFrameworkCore; + +namespace FlightRecorder.BusinessLogic.Database +{ + public class UserManager : IUserManager + { + private readonly Lazy> _hasher; + private readonly FlightRecorderDbContext _context; + + public UserManager(FlightRecorderDbContext context) + { + _hasher = new Lazy>(() => new PasswordHasher()); + _context = context; + } + + /// + /// Return the user with the specified Id + /// + /// + /// + public async Task GetUserAsync(int userId) + { + User user = await _context.Users.FirstOrDefaultAsync(u => u.Id == userId); + ThrowIfUserNotFound(user, userId); + return user; + } + + /// + /// Return the user with the specified Id + /// + /// + /// + public async Task GetUserAsync(string userName) + { + User user = await _context.Users.FirstOrDefaultAsync(u => u.UserName == userName); + ThrowIfUserNotFound(user, userName); + return user; + } + + /// + /// Get all the current user details + /// + public IAsyncEnumerable GetUsersAsync() => + _context.Users.AsAsyncEnumerable(); + + /// + /// Add a new user, given their details + /// + /// + /// + /// + public async Task AddUserAsync(string userName, string password) + { + User user = await _context.Users.FirstOrDefaultAsync(u => u.UserName == userName); + ThrowIfUserFound(user, userName); + + user = new User + { + UserName = userName, + Password = _hasher.Value.HashPassword(userName, password) + }; + + await _context.Users.AddAsync(user); + await _context.SaveChangesAsync(); + return user; + } + + /// + /// Authenticate the specified user + /// + /// + /// + /// + public async Task AuthenticateAsync(string userName, string password) + { + User user = await GetUserAsync(userName); + PasswordVerificationResult result = _hasher.Value.VerifyHashedPassword(userName, user.Password, password); + if (result == PasswordVerificationResult.SuccessRehashNeeded) + { + user.Password = _hasher.Value.HashPassword(userName, password); + await _context.SaveChangesAsync(); + } + return result != PasswordVerificationResult.Failed; + } + + /// + /// Set the password for the specified user + /// + /// + /// + public async Task SetPasswordAsync(string userName, string password) + { + User user = await GetUserAsync(userName); + user.Password = _hasher.Value.HashPassword(userName, password); + await _context.SaveChangesAsync(); + } + + /// + /// Delete the specified user + /// + /// + public async Task DeleteUserAsync(string userName) + { + User user = await GetUserAsync(userName); + _context.Users.Remove(user); + await _context.SaveChangesAsync(); + } + + /// + /// Throw an exception if a user doesn't exist + /// + /// + /// + [ExcludeFromCodeCoverage] + private static void ThrowIfUserNotFound(User user, object userId) + { + if (user == null) + { + string message = $"User {userId} not found"; + throw new UserNotFoundException(message); + } + } + + /// + /// Throw an exception if a user already exists + /// + /// + /// + [ExcludeFromCodeCoverage] + private static void ThrowIfUserFound(User user, object userId) + { + if (user != null) + { + throw new UserExistsException($"User {userId} already exists"); + } + } + } +} diff --git a/src/FlightRecorder.BusinessLogic/Factory/FlightRecorderFactory.cs b/src/FlightRecorder.BusinessLogic/Factory/FlightRecorderFactory.cs index 502ae99..a71be3e 100644 --- a/src/FlightRecorder.BusinessLogic/Factory/FlightRecorderFactory.cs +++ b/src/FlightRecorder.BusinessLogic/Factory/FlightRecorderFactory.cs @@ -1,6 +1,6 @@ using System; using System.Diagnostics.CodeAnalysis; -using FlightRecorder.BusinessLogic.Logic; +using FlightRecorder.BusinessLogic.Database; using FlightRecorder.Data; using FlightRecorder.Entities.Interfaces; using FlightRecorder.Entities.Reporting; diff --git a/src/FlightRecorder.BusinessLogic/FlightRecorder.BusinessLogic.csproj b/src/FlightRecorder.BusinessLogic/FlightRecorder.BusinessLogic.csproj index cd6364e..97c9c8e 100644 --- a/src/FlightRecorder.BusinessLogic/FlightRecorder.BusinessLogic.csproj +++ b/src/FlightRecorder.BusinessLogic/FlightRecorder.BusinessLogic.csproj @@ -3,7 +3,7 @@ net7.0 FlightRecorder.BusinessLogic - 1.4.0.0 + 1.5.0.0 Dave Walker Copyright (c) Dave Walker 2020, 2021, 2022, 2023 Dave Walker @@ -16,7 +16,7 @@ https://github.com/davewalker5/FlightRecorderDb MIT false - 1.4.0.0 + 1.5.0.0 @@ -53,6 +53,8 @@ + + diff --git a/src/FlightRecorder.BusinessLogic/Logging/FileLogger.cs b/src/FlightRecorder.BusinessLogic/Logging/FileLogger.cs new file mode 100644 index 0000000..56480ff --- /dev/null +++ b/src/FlightRecorder.BusinessLogic/Logging/FileLogger.cs @@ -0,0 +1,112 @@ +using FlightRecorder.Entities.Interfaces; +using FlightRecorder.Entities.Logging; +using Serilog; +using Serilog.Core; +using Serilog.Events; +using System; +using System.Diagnostics.CodeAnalysis; + +namespace FlightRecorder.BusinessLogic.Logging +{ + [ExcludeFromCodeCoverage] + public class FileLogger : IFlightRecorderLogger + { + private bool _configured = false; + + /// + /// Configure logging using Serilog + /// + /// + /// + public void Initialise(string logFile, Severity minimumSeverityToLog) + { + // If the log file's empty, return now without configuring a logger + if (string.IsNullOrEmpty(logFile)) + { + return; + } + + // Set the minimum log level + var levelSwitch = new LoggingLevelSwitch(); + switch (minimumSeverityToLog) + { + case Severity.Debug: + levelSwitch.MinimumLevel = LogEventLevel.Debug; + break; + case Severity.Info: + levelSwitch.MinimumLevel = LogEventLevel.Information; + break; + case Severity.Warning: + levelSwitch.MinimumLevel = LogEventLevel.Warning; + break; + case Severity.Error: + levelSwitch.MinimumLevel = LogEventLevel.Error; + break; + default: + break; + } + + // Configure the logger +#pragma warning disable CS8602, S4792 + Log.Logger = new LoggerConfiguration() + .MinimumLevel.ControlledBy(levelSwitch) + .WriteTo + .File( + logFile, + rollingInterval: RollingInterval.Day, + rollOnFileSizeLimit: true) + .CreateLogger(); +#pragma warning restore CS8602, S4792 + + // Set the "configured" flag + _configured = true; + } + + /// + /// Log a message with the specified severity + /// + /// + /// + public void LogMessage(Severity severity, string message) + { + // Check the logger's been configured. If not, break out now + if (!_configured) + { + return; + } + + // Log the message + switch (severity) + { + case Severity.Debug: + Log.Debug(message); + break; + case Severity.Info: + Log.Information(message); + break; + case Severity.Warning: + Log.Warning(message); + break; + case Severity.Error: + Log.Error(message); + break; + default: + break; + } + } + + /// + /// Log exception details, including the stack trace + /// + /// + public void LogException(Exception ex) + { + // Check the logger's been configured and, if so, log the exception message and stack trace + if (!_configured) + { + Log.Error(ex.Message); + Log.Error(ex.ToString()); + } + } + } +} diff --git a/src/FlightRecorder.Data/FlightRecorder.Data.csproj b/src/FlightRecorder.Data/FlightRecorder.Data.csproj index 4cc1c1a..2899dad 100644 --- a/src/FlightRecorder.Data/FlightRecorder.Data.csproj +++ b/src/FlightRecorder.Data/FlightRecorder.Data.csproj @@ -3,7 +3,7 @@ net7.0 FlightRecorder.Data - 1.4.0.0 + 1.5.0.0 Dave Walker Copyright (c) Dave Walker 2020, 2021, 2022, 2023 Dave Walker @@ -16,7 +16,7 @@ https://github.com/davewalker5/FlightRecorderDb MIT false - 1.4.0.0 + 1.5.0.0 diff --git a/src/FlightRecorder.DataExchange/FlightRecorder.DataExchange.csproj b/src/FlightRecorder.DataExchange/FlightRecorder.DataExchange.csproj index bc57d77..7ddbeca 100644 --- a/src/FlightRecorder.DataExchange/FlightRecorder.DataExchange.csproj +++ b/src/FlightRecorder.DataExchange/FlightRecorder.DataExchange.csproj @@ -3,7 +3,7 @@ net7.0 FlightRecorder.DataExchange - 1.4.0.0 + 1.5.0.0 Dave Walker Copyright (c) Dave Walker 2020, 2021, 2022, 2023 Dave Walker @@ -16,7 +16,7 @@ https://github.com/davewalker5/FlightRecorderDb MIT false - 1.4.0.0 + 1.5.0.0 diff --git a/src/FlightRecorder.Entities/Api/ApiPropertyDefinition.cs b/src/FlightRecorder.Entities/Api/ApiPropertyDefinition.cs new file mode 100644 index 0000000..c011a83 --- /dev/null +++ b/src/FlightRecorder.Entities/Api/ApiPropertyDefinition.cs @@ -0,0 +1,11 @@ +using System.Diagnostics.CodeAnalysis; + +namespace FlightRecorder.Entities.Api +{ + [ExcludeFromCodeCoverage] + public class ApiPropertyDefinition + { + public ApiPropertyType PropertyType { get; set; } + public string JsonPath { get; set; } + } +} diff --git a/src/FlightRecorder.Entities/Api/ApiPropertyType.cs b/src/FlightRecorder.Entities/Api/ApiPropertyType.cs new file mode 100644 index 0000000..9a32d8a --- /dev/null +++ b/src/FlightRecorder.Entities/Api/ApiPropertyType.cs @@ -0,0 +1,9 @@ +namespace FlightRecorder.Entities.Api +{ + public enum ApiPropertyType + { + DepartureAirportIATA, + DestinationAirportIATA, + AirlineName + } +} diff --git a/src/FlightRecorder.Entities/FlightRecorder.Entities.csproj b/src/FlightRecorder.Entities/FlightRecorder.Entities.csproj index 372a7bd..ae9419d 100644 --- a/src/FlightRecorder.Entities/FlightRecorder.Entities.csproj +++ b/src/FlightRecorder.Entities/FlightRecorder.Entities.csproj @@ -3,7 +3,7 @@ net7.0 FlightRecorder.Entities - 1.4.0.0 + 1.5.0.0 Dave Walker Copyright (c) Dave Walker 2020, 2021, 2022, 2023 Dave Walker @@ -16,7 +16,7 @@ https://github.com/davewalker5/FlightRecorderDb MIT false - 1.4.0.0 + 1.5.0.0 diff --git a/src/FlightRecorder.Entities/Interfaces/IFlightRecorderHttpClient.cs b/src/FlightRecorder.Entities/Interfaces/IFlightRecorderHttpClient.cs new file mode 100644 index 0000000..db9a381 --- /dev/null +++ b/src/FlightRecorder.Entities/Interfaces/IFlightRecorderHttpClient.cs @@ -0,0 +1,12 @@ +using System.Collections.Generic; +using System.Net.Http; +using System.Threading.Tasks; + +namespace FlightRecorder.Entities.Interfaces +{ + public interface IFlightRecorderHttpClient + { + void SetHeaders(Dictionary headers); + Task GetAsync(string uri); + } +} \ No newline at end of file diff --git a/src/FlightRecorder.Entities/Interfaces/IFlightRecorderLogger.cs b/src/FlightRecorder.Entities/Interfaces/IFlightRecorderLogger.cs new file mode 100644 index 0000000..6d80165 --- /dev/null +++ b/src/FlightRecorder.Entities/Interfaces/IFlightRecorderLogger.cs @@ -0,0 +1,12 @@ +using FlightRecorder.Entities.Logging; +using System; + +namespace FlightRecorder.Entities.Interfaces +{ + public interface IFlightRecorderLogger + { + void Initialise(string logFile, Severity minimumSeverityToLog); + void LogMessage(Severity severity, string message); + void LogException(Exception ex); + } +} diff --git a/src/FlightRecorder.Entities/Interfaces/IFlightsApi.cs b/src/FlightRecorder.Entities/Interfaces/IFlightsApi.cs new file mode 100644 index 0000000..b3c84c4 --- /dev/null +++ b/src/FlightRecorder.Entities/Interfaces/IFlightsApi.cs @@ -0,0 +1,13 @@ +using FlightRecorder.Entities.Api; +using System; +using System.Collections.Generic; +using System.Threading.Tasks; + +namespace FlightRecorder.Entities.Interfaces +{ + public interface IFlightsApi + { + Task> LookupFlightByNumber(string number); + Task> LookupFlightByNumberAndDate(string number, DateTime date); + } +} \ No newline at end of file diff --git a/src/FlightRecorder.Entities/Logging/Severity.cs b/src/FlightRecorder.Entities/Logging/Severity.cs new file mode 100644 index 0000000..4740e0d --- /dev/null +++ b/src/FlightRecorder.Entities/Logging/Severity.cs @@ -0,0 +1,10 @@ +namespace FlightRecorder.Entities.Logging +{ + public enum Severity + { + Debug, + Info, + Warning, + Error + } +} diff --git a/src/FlightRecorder.Manager/FlightRecorder.Manager.csproj b/src/FlightRecorder.Manager/FlightRecorder.Manager.csproj index d0eb44b..3343b6b 100644 --- a/src/FlightRecorder.Manager/FlightRecorder.Manager.csproj +++ b/src/FlightRecorder.Manager/FlightRecorder.Manager.csproj @@ -3,9 +3,9 @@ Exe net7.0 - 1.4.0.0 - 1.4.0.0 - 1.4.0.0 + 1.5.0.0 + 1.5.0.0 + 1.5.0.0 Release;Debug false diff --git a/src/FlightRecorder.Tests/FlightLookupTest.cs b/src/FlightRecorder.Tests/FlightLookupTest.cs new file mode 100644 index 0000000..62a13c7 --- /dev/null +++ b/src/FlightRecorder.Tests/FlightLookupTest.cs @@ -0,0 +1,100 @@ +using FlightRecorder.BusinessLogic.Api.AeroDataBox; +using FlightRecorder.Entities.Api; +using FlightRecorder.Entities.Interfaces; +using FlightRecorder.Tests.Mocks; +using Microsoft.VisualStudio.TestTools.UnitTesting; +using System; +using System.Threading.Tasks; + +namespace FlightRecorder.Tests +{ + /// + /// These tests can't test authentication/authorisation at the service end, the lookup of data at the + /// service end or network transport. They're design to test the downstream logic once a response has + /// been received + /// + [TestClass] + public class FlightLookupTest + { + private const string EndpointBaseUrl = "https://aerodatabox.p.rapidapi.com/flights/number/"; + private const string FlightNumber = "LS803"; + private string MalformedResponse = "{\"departure\": {\"airport\": {\"iata\": \"MAN\"}}}"; + private string Response = "[{\"departure\": {\"airport\": {\"iata\": \"MAN\"}},\"arrival\": {\"airport\": {\"iata\": \"BCN\"}},\"airline\": {\"name\": \"Jet2\"}}]"; + + private MockHttpClient _client; + private IFlightsApi _api; + + [TestInitialize] + public void Initialise() + { + // Create the logger, mock client and API wrappers + var logger = new MockFileLogger(); + _client = new MockHttpClient(); + _api = new AeroDataBoxFlightsApi(logger, _client, EndpointBaseUrl, ""); + } + + [TestMethod] + public void FlightNotFoundByNumberTest() + { + _client!.AddResponse(""); + var properties = Task.Run(() => _api.LookupFlightByNumber("Flight Doesn't Exist")).Result; + Assert.IsNull(properties); + } + + [TestMethod] + public void FlightNotFoundByNumberAndDateTest() + { + _client!.AddResponse(""); + var properties = Task.Run(() => _api.LookupFlightByNumberAndDate("Flight Doesn't Exist", DateTime.Now)).Result; + Assert.IsNull(properties); + } + + [TestMethod] + public void NetworkErrorTest() + { + _client!.AddResponse(null); + var properties = Task.Run(() => _api.LookupFlightByNumber(FlightNumber)).Result; + Assert.IsNull(properties); + } + + [TestMethod] + public void MalformedResponseTest() + { + _client!.AddResponse(MalformedResponse); + var properties = Task.Run(() => _api.LookupFlightByNumber(FlightNumber)).Result; + Assert.IsNull(properties); + } + + [TestMethod] + public void LookupFlightByNumberTest() + { + _client!.AddResponse(Response); + var properties = Task.Run(() => _api.LookupFlightByNumber(FlightNumber)).Result; + Assert.IsNotNull(properties); + + var embarkation = properties[ApiPropertyType.DepartureAirportIATA]; + var destination = properties[ApiPropertyType.DestinationAirportIATA]; + var airline = properties[ApiPropertyType.AirlineName]; + + Assert.AreEqual("MAN", embarkation); + Assert.AreEqual("BCN", destination); + Assert.AreEqual("Jet2", airline); + } + + [TestMethod] + public void LookupFlightByNumberAndDateTest() + { + _client!.AddResponse(Response); + var properties = Task.Run(() => _api.LookupFlightByNumberAndDate(FlightNumber,DateTime.Now)).Result; + Assert.IsNotNull(properties); + + var embarkation = properties[ApiPropertyType.DepartureAirportIATA]; + var destination = properties[ApiPropertyType.DestinationAirportIATA]; + var airline = properties[ApiPropertyType.AirlineName]; + + Assert.AreEqual("MAN", embarkation); + Assert.AreEqual("BCN", destination); + Assert.AreEqual("Jet2", airline); + } + } +} diff --git a/src/FlightRecorder.Tests/FlightRecorder.Tests.csproj b/src/FlightRecorder.Tests/FlightRecorder.Tests.csproj index c6c0b46..7243975 100644 --- a/src/FlightRecorder.Tests/FlightRecorder.Tests.csproj +++ b/src/FlightRecorder.Tests/FlightRecorder.Tests.csproj @@ -4,7 +4,7 @@ net7.0 false - 1.4.0.0 + 1.5.0.0 @@ -26,4 +26,8 @@ + + + + diff --git a/src/FlightRecorder.Tests/Mocks/MockFileLogger.cs b/src/FlightRecorder.Tests/Mocks/MockFileLogger.cs new file mode 100644 index 0000000..43f915b --- /dev/null +++ b/src/FlightRecorder.Tests/Mocks/MockFileLogger.cs @@ -0,0 +1,27 @@ +using FlightRecorder.Entities.Interfaces; +using FlightRecorder.Entities.Logging; +using System; +using System.Diagnostics; +using System.Diagnostics.CodeAnalysis; + +namespace FlightRecorder.Tests.Mocks +{ + [ExcludeFromCodeCoverage] + public class MockFileLogger : IFlightRecorderLogger + { + public void Initialise(string logFile, Severity minimumSeverityToLog) + { + } + + public void LogMessage(Severity severity, string message) + { + Debug.Print($"{DateTime.Now.ToString("yyyy-MM-dd HH:mm:ss")} [{severity.ToString()}] {message}"); + } + + public void LogException(Exception ex) + { + LogMessage(Severity.Error, ex.Message); + LogMessage(Severity.Error, ex.ToString()); + } + } +} diff --git a/src/FlightRecorder.Tests/Mocks/MockHttpClient.cs b/src/FlightRecorder.Tests/Mocks/MockHttpClient.cs new file mode 100644 index 0000000..f4d126d --- /dev/null +++ b/src/FlightRecorder.Tests/Mocks/MockHttpClient.cs @@ -0,0 +1,71 @@ +using FlightRecorder.Entities.Interfaces; +using System; +using System.Collections.Generic; +using System.Net; +using System.Net.Http; +using System.Threading.Tasks; + +namespace FlightRecorder.Tests.Mocks +{ + internal class MockHttpClient : IFlightRecorderHttpClient + { + private readonly Queue _responses = new(); + + /// + /// Queue a response + /// + /// + public void AddResponse(string? response) + { + _responses.Enqueue(response); + } + + /// + /// Queue a set of responses + /// + /// + public void AddResponses(IEnumerable responses) + { + foreach (string response in responses) + { + _responses.Enqueue(response); + } + } + + /// + /// Set the HTTP request headers + /// + /// + public void SetHeaders(Dictionary headers) + { + } + + /// + /// Construct and return the next response + /// + /// + /// +#pragma warning disable CS1998 + public async Task GetAsync(string uri) + { + // De-queue the next message + var content = _responses.Dequeue(); + + // If the content is null, raise an exception to test the exception handling + if (content == null) + { + throw new Exception(); + } + + // Construct an HTTP response + var response = new HttpResponseMessage + { + Content = new StringContent(content ?? ""), + StatusCode = HttpStatusCode.OK + }; + + return response; + } +#pragma warning restore CS1998 + } +}