From 95093e3b02838fdce703c557c77e6918b8cf2dc7 Mon Sep 17 00:00:00 2001 From: bl0b3s Date: Mon, 16 Feb 2026 23:19:45 +0300 Subject: [PATCH 1/8] Created Domain layer models, Implemented LINQ xunit tests --- .github/workflows/test.yml | 28 +++ .../CarRental.Domain/CarRental.Domain.csproj | 9 + CarRental/CarRental.Domain/Enums/BodyType.cs | 18 ++ CarRental/CarRental.Domain/Enums/CarClass.cs | 18 ++ .../CarRental.Domain/Enums/DriverType.cs | 12 ++ .../Enums/TransmissionType.cs | 13 ++ .../CarRental.Domain/Models/Abstract/Model.cs | 12 ++ CarRental/CarRental.Domain/Models/Car.cs | 24 +++ CarRental/CarRental.Domain/Models/CarClass.cs | 35 ++++ CarRental/CarRental.Domain/Models/Customer.cs | 24 +++ .../Models/ModelGeneration.cs | 34 ++++ CarRental/CarRental.Domain/Models/Rental.cs | 34 ++++ .../CarRental.Tests/CarRental.Tests.csproj | 33 ++++ CarRental/CarRental.Tests/DataFixture.cs | 65 +++++++ CarRental/CarRental.Tests/LinqQueryTests.cs | 176 ++++++++++++++++++ CarRental/CarRental.sln | 35 ++++ 16 files changed, 570 insertions(+) create mode 100644 .github/workflows/test.yml create mode 100644 CarRental/CarRental.Domain/CarRental.Domain.csproj create mode 100644 CarRental/CarRental.Domain/Enums/BodyType.cs create mode 100644 CarRental/CarRental.Domain/Enums/CarClass.cs create mode 100644 CarRental/CarRental.Domain/Enums/DriverType.cs create mode 100644 CarRental/CarRental.Domain/Enums/TransmissionType.cs create mode 100644 CarRental/CarRental.Domain/Models/Abstract/Model.cs create mode 100644 CarRental/CarRental.Domain/Models/Car.cs create mode 100644 CarRental/CarRental.Domain/Models/CarClass.cs create mode 100644 CarRental/CarRental.Domain/Models/Customer.cs create mode 100644 CarRental/CarRental.Domain/Models/ModelGeneration.cs create mode 100644 CarRental/CarRental.Domain/Models/Rental.cs create mode 100644 CarRental/CarRental.Tests/CarRental.Tests.csproj create mode 100644 CarRental/CarRental.Tests/DataFixture.cs create mode 100644 CarRental/CarRental.Tests/LinqQueryTests.cs create mode 100644 CarRental/CarRental.sln diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml new file mode 100644 index 000000000..e12bb365b --- /dev/null +++ b/.github/workflows/test.yml @@ -0,0 +1,28 @@ +name: .NET Tests + +on: + push: + branches: [ "*" ] + pull_request: + branches: [ main ] + +jobs: + test: + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v4 + + - name: Setup .NET + uses: actions/setup-dotnet@v4 + with: + dotnet-version: 8.0.x + + - name: Restore dependencies + run: dotnet restore ./CarRental/CarRental.sln + + - name: Build + run: dotnet build ./CarRental/CarRental.sln --no-restore + + - name: Test + run: dotnet test ./CarRental/CarRental.Tests/CarRental.Tests.csproj --no-build --verbosity normal \ No newline at end of file diff --git a/CarRental/CarRental.Domain/CarRental.Domain.csproj b/CarRental/CarRental.Domain/CarRental.Domain.csproj new file mode 100644 index 000000000..fa71b7ae6 --- /dev/null +++ b/CarRental/CarRental.Domain/CarRental.Domain.csproj @@ -0,0 +1,9 @@ + + + + net8.0 + enable + enable + + + diff --git a/CarRental/CarRental.Domain/Enums/BodyType.cs b/CarRental/CarRental.Domain/Enums/BodyType.cs new file mode 100644 index 000000000..382a6a91c --- /dev/null +++ b/CarRental/CarRental.Domain/Enums/BodyType.cs @@ -0,0 +1,18 @@ +namespace CarRental.Domain.Enums; + +/// +/// Body style +/// +public enum BodyType +{ + Sedan, + Hatchback, + Liftback, + Coupe, + Convertible, + SUV, + Crossover, + Minivan, + Pickup, + StationWagon +} diff --git a/CarRental/CarRental.Domain/Enums/CarClass.cs b/CarRental/CarRental.Domain/Enums/CarClass.cs new file mode 100644 index 000000000..eb5ffda0d --- /dev/null +++ b/CarRental/CarRental.Domain/Enums/CarClass.cs @@ -0,0 +1,18 @@ +namespace CarRental.Domain.Enums; + +/// +/// Car class / rental category +/// +public enum CarClass +{ + Economy, + Compact, + Intermediate, + Standard, + FullSize, + Luxury, + Premium, + SUV, + Minivan, + Sport +} \ No newline at end of file diff --git a/CarRental/CarRental.Domain/Enums/DriverType.cs b/CarRental/CarRental.Domain/Enums/DriverType.cs new file mode 100644 index 000000000..acffdc4bd --- /dev/null +++ b/CarRental/CarRental.Domain/Enums/DriverType.cs @@ -0,0 +1,12 @@ +namespace CarRental.Domain.Enums; + +/// +/// Drivetrain / drive type +/// +public enum DriverType +{ + FrontWheelDrive, + RearWheelDrive, + AllWheelDrive, + FourWheelDrive +} diff --git a/CarRental/CarRental.Domain/Enums/TransmissionType.cs b/CarRental/CarRental.Domain/Enums/TransmissionType.cs new file mode 100644 index 000000000..a8241c82a --- /dev/null +++ b/CarRental/CarRental.Domain/Enums/TransmissionType.cs @@ -0,0 +1,13 @@ +namespace CarRental.Domain.Enums; + +/// +/// Transmission type +/// +public enum TransmissionType +{ + Manual, + Automatic, + Robotic, + CVT, + DualClutch +} \ No newline at end of file diff --git a/CarRental/CarRental.Domain/Models/Abstract/Model.cs b/CarRental/CarRental.Domain/Models/Abstract/Model.cs new file mode 100644 index 000000000..9ed10db9f --- /dev/null +++ b/CarRental/CarRental.Domain/Models/Abstract/Model.cs @@ -0,0 +1,12 @@ +namespace CarRental.Domain.Models.Abstract; + +/// +/// Base class for all entities with an identifier +/// +public abstract class Model +{ + /// + /// identifier of the entity + /// + public virtual int Id { get; set; } +} \ No newline at end of file diff --git a/CarRental/CarRental.Domain/Models/Car.cs b/CarRental/CarRental.Domain/Models/Car.cs new file mode 100644 index 000000000..b3ffe87bd --- /dev/null +++ b/CarRental/CarRental.Domain/Models/Car.cs @@ -0,0 +1,24 @@ +using CarRental.Domain.Models.Abstract; + +namespace CarRental.Domain.Models; + +/// +/// Represents a car in the car rental service. +/// +public class Car : Model +{ + /// + /// License plate number + /// + public required string LicensePlate { get; set; } + + /// + /// Color of the vehicle + /// + public required string Color { get; set; } + + /// + /// Foreign key to the model generation this car belongs to + /// + public required int ModelGenerationId { get; set; } +} diff --git a/CarRental/CarRental.Domain/Models/CarClass.cs b/CarRental/CarRental.Domain/Models/CarClass.cs new file mode 100644 index 000000000..16355c1d1 --- /dev/null +++ b/CarRental/CarRental.Domain/Models/CarClass.cs @@ -0,0 +1,35 @@ +using CarRental.Domain.Enums; +using CarRental.Domain.Models.Abstract; + +namespace CarRental.Domain.Models; + +/// +/// Car model (e.g. Toyota Camry, BMW X5) +/// +public class CarModel : Model +{ + /// + /// Name of the model + /// + public required string Name { get; set; } + + /// + /// Type of drivetrain + /// + public required DriverType DriveType { get; set; } + + /// + /// Number of seats (including driver) + /// + public required byte SeatingCapacity { get; set; } + + /// + /// Body style / type + /// + public required BodyType BodyType { get; set; } + + /// + /// Vehicle class / market segment + /// + public required CarClass CarClass { get; set; } +} diff --git a/CarRental/CarRental.Domain/Models/Customer.cs b/CarRental/CarRental.Domain/Models/Customer.cs new file mode 100644 index 000000000..1f0e60a18 --- /dev/null +++ b/CarRental/CarRental.Domain/Models/Customer.cs @@ -0,0 +1,24 @@ +using CarRental.Domain.Models.Abstract; + +namespace CarRental.Domain.Models; + +/// +/// Customer / renter of the vehicle +/// +public class Customer : Model +{ + /// + /// Driver's license number (used as unique business identifier) + /// + public required string DriverLicenseNumber { get; set; } + + /// + /// Full name of the customer + /// + public required string FullName { get; set; } + + /// + /// Date of birth + /// + public required DateOnly DateOfBirth { get; set; } +} diff --git a/CarRental/CarRental.Domain/Models/ModelGeneration.cs b/CarRental/CarRental.Domain/Models/ModelGeneration.cs new file mode 100644 index 000000000..90a9dcf4b --- /dev/null +++ b/CarRental/CarRental.Domain/Models/ModelGeneration.cs @@ -0,0 +1,34 @@ +using CarRental.Domain.Models.Abstract; +using CarRental.Domain.Enums; + +namespace CarRental.Domain.Models; +/// +/// Generation / specific version of a car model +/// +public class ModelGeneration : Model +{ + /// + /// Year of manufacture / start of production for this generation + /// + public required int ProductionYear { get; set; } + + /// + /// Engine displacement in liters + /// + public required decimal EngineVolumeLiters { get; set; } + + /// + /// Type of transmission + /// + public required TransmissionType TransmissionType { get; set; } + + /// + /// Hourly rental price for this generation + /// + public required decimal HourlyRate { get; set; } + + /// + /// Foreign key to the base car model + /// + public required int CarModelId { get; set; } +} diff --git a/CarRental/CarRental.Domain/Models/Rental.cs b/CarRental/CarRental.Domain/Models/Rental.cs new file mode 100644 index 000000000..75f95b4ef --- /dev/null +++ b/CarRental/CarRental.Domain/Models/Rental.cs @@ -0,0 +1,34 @@ +using CarRental.Domain.Models.Abstract; + +namespace CarRental.Domain.Models; + +/// +/// Rental agreement / contract +/// +public class Rental : Model +{ + /// + /// Customer who rents the car + /// + public required int CustomerId { get; set; } + + /// + /// Car being rented + /// + public required int CarId { get; set; } + + /// + /// Date and time when the vehicle was handed over + /// + public required DateTime PickupDateTime { get; set; } + + /// + /// Duration of the rental in hours + /// + public required int Hours { get; set; } + + /// + /// Calculated expected return time + /// + public DateTime ExpectedReturnDateTime => PickupDateTime.AddHours(Hours); +} diff --git a/CarRental/CarRental.Tests/CarRental.Tests.csproj b/CarRental/CarRental.Tests/CarRental.Tests.csproj new file mode 100644 index 000000000..cdd82d432 --- /dev/null +++ b/CarRental/CarRental.Tests/CarRental.Tests.csproj @@ -0,0 +1,33 @@ + + + + net8.0 + enable + enable + + false + true + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + + + + + + + + diff --git a/CarRental/CarRental.Tests/DataFixture.cs b/CarRental/CarRental.Tests/DataFixture.cs new file mode 100644 index 000000000..159596a3a --- /dev/null +++ b/CarRental/CarRental.Tests/DataFixture.cs @@ -0,0 +1,65 @@ +using CarRental.Domain.Models; + +namespace CarRental.Tests; + +/// +/// Provides sample data for car rental domain entities +/// +public class CarRentalDataFixture +{ + public List CarModels { get; } = new() + { + new() { Id = 1, Name = "Toyota Camry", DriveType = DriveType.FrontWheelDrive, SeatingCapacity = 5, BodyType = BodyType.Sedan, CarClass = CarClass.Intermediate }, + new() { Id = 2, Name = "Kia Rio", DriveType = DriveType.FrontWheelDrive, SeatingCapacity = 5, BodyType = BodyType.Hatchback, CarClass = CarClass.Economy }, + new() { Id = 3, Name = "BMW X5", DriveType = DriveType.AllWheelDrive, SeatingCapacity = 5, BodyType = BodyType.SUV, CarClass = CarClass.Premium }, + new() { Id = 4, Name = "Hyundai Solaris", DriveType = DriveType.FrontWheelDrive, SeatingCapacity = 5, BodyType = BodyType.Sedan, CarClass = CarClass.Economy }, + new() { Id = 5, Name = "Volkswagen Tiguan", DriveType = DriveType.AllWheelDrive, SeatingCapacity = 5, BodyType = BodyType.Crossover, CarClass = CarClass.Intermediate }, + }; + + public List ModelGenerations { get; } = new() + { + new() { Id = 1, CarModelId = 1, ProductionYear = 2023, EngineVolumeLiters = 2.5m, TransmissionType = TransmissionType.Automatic, HourlyRate = 1200 }, + new() { Id = 2, CarModelId = 1, ProductionYear = 2019, EngineVolumeLiters = 2.0m, TransmissionType = TransmissionType.CVT, HourlyRate = 950 }, + new() { Id = 3, CarModelId = 2, ProductionYear = 2024, EngineVolumeLiters = 1.6m, TransmissionType = TransmissionType.Automatic, HourlyRate = 650 }, + new() { Id = 4, CarModelId = 3, ProductionYear = 2022, EngineVolumeLiters = 3.0m, TransmissionType = TransmissionType.Automatic, HourlyRate = 3500 }, + new() { Id = 5, CarModelId = 4, ProductionYear = 2023, EngineVolumeLiters = 1.6m, TransmissionType = TransmissionType.Automatic, HourlyRate = 700 }, + new() { Id = 6, CarModelId = 5, ProductionYear = 2021, EngineVolumeLiters = 2.0m, TransmissionType = TransmissionType.DualClutch, HourlyRate = 1400 }, + }; + + public List Cars { get; } = new() + { + new() { Id = 1, LicensePlate = "А123ВС 777", Color = "Черный", ModelGenerationId = 1 }, + new() { Id = 2, LicensePlate = "В456ОР 777", Color = "Белый", ModelGenerationId = 2 }, + new() { Id = 3, LicensePlate = "Е789КХ 777", Color = "Синий", ModelGenerationId = 3 }, + new() { Id = 4, LicensePlate = "К001МР 777", Color = "Серебро", ModelGenerationId = 4 }, + new() { Id = 5, LicensePlate = "М234ТН 777", Color = "Красный", ModelGenerationId = 5 }, + new() { Id = 6, LicensePlate = "Н567УХ 777", Color = "Серый", ModelGenerationId = 1 }, + new() { Id = 7, LicensePlate = "О890ЦВ 777", Color = "Черный", ModelGenerationId = 3 }, + }; + + public List Customers { get; } = new() + { + new() { Id = 1, DriverLicenseNumber = "1234 567890", FullName = "Иванов Иван Иванович", DateOfBirth = new DateOnly(1985, 3, 12) }, + new() { Id = 2, DriverLicenseNumber = "2345 678901", FullName = "Петрова Анна Сергеевна", DateOfBirth = new DateOnly(1992, 7, 19) }, + new() { Id = 3, DriverLicenseNumber = "3456 789012", FullName = "Сидоров Алексей Петрович", DateOfBirth = new DateOnly(1978, 11, 5) }, + new() { Id = 4, DriverLicenseNumber = "4567 890123", FullName = "Кузнецова Мария Дмитриевна", DateOfBirth = new DateOnly(1990, 4, 28) }, + new() { Id = 5, DriverLicenseNumber = "5678 901234", FullName = "Смирнов Дмитрий Александрович", DateOfBirth = new DateOnly(1982, 9, 15) }, + new() { Id = 6, DriverLicenseNumber = "6789 012345", FullName = "Волкова Ольга Николаевна", DateOfBirth = new DateOnly(1995, 1, 8) }, + }; + + public List Rentals { get; } = new() + { + new() { Id = 1, CustomerId = 1, CarId = 1, PickupDateTime = new DateTime(2025, 10, 1, 9, 0, 0), Hours = 24 }, + new() { Id = 2, CustomerId = 2, CarId = 3, PickupDateTime = new DateTime(2025, 10, 3, 14, 0, 0), Hours = 8 }, + new() { Id = 3, CustomerId = 1, CarId = 1, PickupDateTime = new DateTime(2025, 9, 15, 10, 0, 0), Hours = 48 }, + new() { Id = 4, CustomerId = 3, CarId = 4, PickupDateTime = new DateTime(2025, 10, 5, 11, 30, 0), Hours = 5 }, + new() { Id = 5, CustomerId = 4, CarId = 1, PickupDateTime = new DateTime(2025, 10, 7, 8, 0, 0), Hours = 72 }, + new() { Id = 6, CustomerId = 2, CarId = 3, PickupDateTime = new DateTime(2025, 9, 20, 16, 0, 0), Hours = 24 }, + new() { Id = 7, CustomerId = 5, CarId = 7, PickupDateTime = new DateTime(2025, 10, 10, 12, 0, 0), Hours = 12 }, + new() { Id = 8, CustomerId = 1, CarId = 6, PickupDateTime = new DateTime(2025, 10, 12, 9, 0, 0), Hours = 36 }, + new() { Id = 9, CustomerId = 6, CarId = 2, PickupDateTime = new DateTime(2025, 10, 14, 13, 0, 0), Hours = 4 }, + new() { Id = 10, CustomerId = 3, CarId = 1, PickupDateTime = new DateTime(2025, 9, 25, 10, 0, 0), Hours = 24 }, + new() { Id = 11, CustomerId = 2, CarId = 3, PickupDateTime = new DateTime(2025, 10, 10, 8, 0, 0), Hours = 240 }, + new() { Id = 12, CustomerId = 5, CarId = 1, PickupDateTime = new DateTime(2025, 10, 15, 14, 0, 0), Hours = 36 } + }; +} \ No newline at end of file diff --git a/CarRental/CarRental.Tests/LinqQueryTests.cs b/CarRental/CarRental.Tests/LinqQueryTests.cs new file mode 100644 index 000000000..d0d55200b --- /dev/null +++ b/CarRental/CarRental.Tests/LinqQueryTests.cs @@ -0,0 +1,176 @@ +namespace CarRental.Tests; + +/// +/// Tests for car rental functionalities. +/// +public class LinqQueryTests(CarRentalDataFixture testData) : IClassFixture +{ + /// + /// Displays information about all customers who rented cars of the specified model, sorted by full name. + /// + [Fact] + public void GetCustomersByModel() + { + const int targetModelId = 1; + + var expectedFullNames = new List + { + " ", + " ", + " " + }; + + var actualFullNames = testData.Rentals + .Join(testData.Cars, + r => r.CarId, + c => c.Id, + (r, c) => new { Rental = r, Car = c }) + .Where(x => testData.ModelGenerations + .Any(mg => mg.Id == x.Car.ModelGenerationId && mg.CarModelId == targetModelId)) + .Select(x => x.Rental.CustomerId) + .Distinct() + .Join(testData.Customers, + cid => cid, + c => c.Id, + (_, c) => c.FullName) + .OrderBy(name => name) + .ToList(); + + Assert.Equal(expectedFullNames, actualFullNames); + } + + /// + /// Displays information about cars that are currently rented. + /// + [Fact] + public void GetCurrentlyRentedCars() + { + var now = new DateTime(2025, 10, 16, 12, 0, 0); + + var expectedPlates = new List + { + "123 777", + "789 777" + }; + + var actualPlates = testData.Rentals + .Where(r => r.PickupDateTime <= now && r.PickupDateTime.AddHours(r.Hours) > now) + .Join(testData.Cars, + r => r.CarId, + c => c.Id, + (r, c) => c.LicensePlate) + .OrderBy(plate => plate) + .ToList(); + + Assert.Equal(expectedPlates, actualPlates); + } + + /// + /// Displays the top 5 most frequently rented cars. + /// + [Fact] + public void GetTop5MostRentedCars() + { + var expectedPlates = new List + { + "123 777", + "789 777", + "456 777", + "001 777", + "234 777" + }; + + var actualPlates = testData.Rentals + .GroupBy(r => r.CarId) + .Select(g => new + { + CarId = g.Key, + Count = g.Count() + }) + .OrderByDescending(x => x.Count) + .ThenBy(x => x.CarId) + .Take(5) + .Join(testData.Cars, + x => x.CarId, + c => c.Id, + (x, c) => c.LicensePlate) + .ToList(); + + Assert.Equal(expectedPlates, actualPlates); + } + + /// + /// Displays the number of rentals for each car. + /// + [Fact] + public void GetRentalCountForEachCar() + { + var expected = new Dictionary + { + { "123 777", 3 }, + { "456 777", 1 }, + { "789 777", 2 }, + { "001 777", 1 }, + { "234 777", 1 }, + { "567 777", 1 }, + { "890 777", 1 } + }; + + var actual = testData.Rentals + .GroupBy(r => r.CarId) + .Select(g => new + { + Plate = testData.Cars.First(c => c.Id == g.Key).LicensePlate, + Count = g.Count() + }) + .OrderBy(x => x.Plate) + .ToDictionary(x => x.Plate, x => x.Count); + + Assert.Equal(expected, actual); + } + + /// + /// Displays the top 5 customers by total rental amount. + /// + [Fact] + public void GetTop5CustomersByTotalCost() + { + var expectedFullNames = new List + { + " ", + " ", + " ", + " ", + " " + }; + + var actualFullNames = testData.Rentals + .Join(testData.Cars, + r => r.CarId, + c => c.Id, + (r, c) => new { Rental = r, Car = c }) + .Join(testData.ModelGenerations, + x => x.Car.ModelGenerationId, + mg => mg.Id, + (x, mg) => new + { + x.Rental.CustomerId, + Cost = mg.HourlyRate * x.Rental.Hours + }) + .GroupBy(x => x.CustomerId) + .Select(g => new + { + CustomerId = g.Key, + Total = g.Sum(x => x.Cost) + }) + .OrderByDescending(x => x.Total) + .Take(5) + .Join(testData.Customers, + x => x.CustomerId, + c => c.Id, + (x, c) => c.FullName) + .ToList(); + + Assert.Equal(expectedFullNames, actualFullNames); + } +} \ No newline at end of file diff --git a/CarRental/CarRental.sln b/CarRental/CarRental.sln new file mode 100644 index 000000000..666e49be5 --- /dev/null +++ b/CarRental/CarRental.sln @@ -0,0 +1,35 @@ + +Microsoft Visual Studio Solution File, Format Version 12.00 +# Visual Studio Version 17 +VisualStudioVersion = 17.10.35122.118 +MinimumVisualStudioVersion = 10.0.40219.1 +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "CarRental.Domain", "CarRental.Domain\CarRental.Domain.csproj", "{2EF714F9-3283-45EB-8C57-D6109531F46B}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "CarRental.Tests", "CarRental.Tests\CarRental.Tests.csproj", "{134839E5-014D-4CF4-825C-387251D4216C}" +EndProject +Global + GlobalSection(SolutionConfigurationPlatforms) = preSolution + Debug|Any CPU = Debug|Any CPU + Release|Any CPU = Release|Any CPU + EndGlobalSection + GlobalSection(ProjectConfigurationPlatforms) = postSolution + {CD66114E-38DD-4497-A835-343983BA4262}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {CD66114E-38DD-4497-A835-343983BA4262}.Debug|Any CPU.Build.0 = Debug|Any CPU + {CD66114E-38DD-4497-A835-343983BA4262}.Release|Any CPU.ActiveCfg = Release|Any CPU + {CD66114E-38DD-4497-A835-343983BA4262}.Release|Any CPU.Build.0 = Release|Any CPU + {2EF714F9-3283-45EB-8C57-D6109531F46B}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {2EF714F9-3283-45EB-8C57-D6109531F46B}.Debug|Any CPU.Build.0 = Debug|Any CPU + {2EF714F9-3283-45EB-8C57-D6109531F46B}.Release|Any CPU.ActiveCfg = Release|Any CPU + {2EF714F9-3283-45EB-8C57-D6109531F46B}.Release|Any CPU.Build.0 = Release|Any CPU + {134839E5-014D-4CF4-825C-387251D4216C}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {134839E5-014D-4CF4-825C-387251D4216C}.Debug|Any CPU.Build.0 = Debug|Any CPU + {134839E5-014D-4CF4-825C-387251D4216C}.Release|Any CPU.ActiveCfg = Release|Any CPU + {134839E5-014D-4CF4-825C-387251D4216C}.Release|Any CPU.Build.0 = Release|Any CPU + EndGlobalSection + GlobalSection(SolutionProperties) = preSolution + HideSolutionNode = FALSE + EndGlobalSection + GlobalSection(ExtensibilityGlobals) = postSolution + SolutionGuid = {311FD1FC-B518-4A98-ADF2-DCD3E2A70164} + EndGlobalSection +EndGlobal From 15875267b5a019bd1045a5ac9faf5ef1543f3174 Mon Sep 17 00:00:00 2001 From: bl0b3s Date: Mon, 16 Feb 2026 23:39:00 +0300 Subject: [PATCH 2/8] fixed imports --- CarRental/CarRental.Tests/DataFixture.cs | 1 + 1 file changed, 1 insertion(+) diff --git a/CarRental/CarRental.Tests/DataFixture.cs b/CarRental/CarRental.Tests/DataFixture.cs index 159596a3a..e06d97a99 100644 --- a/CarRental/CarRental.Tests/DataFixture.cs +++ b/CarRental/CarRental.Tests/DataFixture.cs @@ -1,4 +1,5 @@ using CarRental.Domain.Models; +using CarRental.Domain.Enums; namespace CarRental.Tests; From ecbee08eb667acb9b6b18f25ee6b2ef0de44dd5f Mon Sep 17 00:00:00 2001 From: bl0b3s Date: Tue, 17 Feb 2026 14:38:05 +0300 Subject: [PATCH 3/8] fix imports --- CarRental/CarRental.Tests/DataFixture.cs | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/CarRental/CarRental.Tests/DataFixture.cs b/CarRental/CarRental.Tests/DataFixture.cs index e06d97a99..ec4694faf 100644 --- a/CarRental/CarRental.Tests/DataFixture.cs +++ b/CarRental/CarRental.Tests/DataFixture.cs @@ -10,11 +10,11 @@ public class CarRentalDataFixture { public List CarModels { get; } = new() { - new() { Id = 1, Name = "Toyota Camry", DriveType = DriveType.FrontWheelDrive, SeatingCapacity = 5, BodyType = BodyType.Sedan, CarClass = CarClass.Intermediate }, - new() { Id = 2, Name = "Kia Rio", DriveType = DriveType.FrontWheelDrive, SeatingCapacity = 5, BodyType = BodyType.Hatchback, CarClass = CarClass.Economy }, - new() { Id = 3, Name = "BMW X5", DriveType = DriveType.AllWheelDrive, SeatingCapacity = 5, BodyType = BodyType.SUV, CarClass = CarClass.Premium }, - new() { Id = 4, Name = "Hyundai Solaris", DriveType = DriveType.FrontWheelDrive, SeatingCapacity = 5, BodyType = BodyType.Sedan, CarClass = CarClass.Economy }, - new() { Id = 5, Name = "Volkswagen Tiguan", DriveType = DriveType.AllWheelDrive, SeatingCapacity = 5, BodyType = BodyType.Crossover, CarClass = CarClass.Intermediate }, + new() { Id = 1, Name = "Toyota Camry", DriverType = DriverType.FrontWheelDrive, SeatingCapacity = 5, BodyType = BodyType.Sedan, CarClass = CarClass.Intermediate }, + new() { Id = 2, Name = "Kia Rio", DriverType = DriverType.FrontWheelDrive, SeatingCapacity = 5, BodyType = BodyType.Hatchback, CarClass = CarClass.Economy }, + new() { Id = 3, Name = "BMW X5", DriverType = DriverType.AllWheelDrive, SeatingCapacity = 5, BodyType = BodyType.SUV, CarClass = CarClass.Premium }, + new() { Id = 4, Name = "Hyundai Solaris", DriverType = DriverType.FrontWheelDrive, SeatingCapacity = 5, BodyType = BodyType.Sedan, CarClass = CarClass.Economy }, + new() { Id = 5, Name = "Volkswagen Tiguan", DriverType = DriverType.AllWheelDrive, SeatingCapacity = 5, BodyType = BodyType.Crossover, CarClass = CarClass.Intermediate }, }; public List ModelGenerations { get; } = new() From 422377e2c3265f3187434c76e5d00bf314cd6781 Mon Sep 17 00:00:00 2001 From: bl0b3s Date: Tue, 17 Feb 2026 14:44:05 +0300 Subject: [PATCH 4/8] fix imports --- .../Models/{CarClass.cs => CarModel.cs} | 2 +- CarRental/CarRental.Tests/LinqQueryTests.cs | 46 +++++++++---------- 2 files changed, 24 insertions(+), 24 deletions(-) rename CarRental/CarRental.Domain/Models/{CarClass.cs => CarModel.cs} (93%) diff --git a/CarRental/CarRental.Domain/Models/CarClass.cs b/CarRental/CarRental.Domain/Models/CarModel.cs similarity index 93% rename from CarRental/CarRental.Domain/Models/CarClass.cs rename to CarRental/CarRental.Domain/Models/CarModel.cs index 16355c1d1..dfa895622 100644 --- a/CarRental/CarRental.Domain/Models/CarClass.cs +++ b/CarRental/CarRental.Domain/Models/CarModel.cs @@ -16,7 +16,7 @@ public class CarModel : Model /// /// Type of drivetrain /// - public required DriverType DriveType { get; set; } + public required DriverType DriverType { get; set; } /// /// Number of seats (including driver) diff --git a/CarRental/CarRental.Tests/LinqQueryTests.cs b/CarRental/CarRental.Tests/LinqQueryTests.cs index d0d55200b..c13e8d38f 100644 --- a/CarRental/CarRental.Tests/LinqQueryTests.cs +++ b/CarRental/CarRental.Tests/LinqQueryTests.cs @@ -1,4 +1,4 @@ -namespace CarRental.Tests; +namespace CarRental.Tests; /// /// Tests for car rental functionalities. @@ -15,9 +15,9 @@ public void GetCustomersByModel() var expectedFullNames = new List { - " ", - " ", - " " + "Иванов Иван Иванович", + "Кузнецова Мария Дмитриевна", + "Сидоров Алексей Петрович" }; var actualFullNames = testData.Rentals @@ -49,8 +49,8 @@ public void GetCurrentlyRentedCars() var expectedPlates = new List { - "123 777", - "789 777" + "А123ВС 777", + "Е789КХ 777" }; var actualPlates = testData.Rentals @@ -73,11 +73,11 @@ public void GetTop5MostRentedCars() { var expectedPlates = new List { - "123 777", - "789 777", - "456 777", - "001 777", - "234 777" + "А123ВС 777", + "Е789КХ 777", + "В456ОР 777", + "К001МР 777", + "М234ТН 777" }; var actualPlates = testData.Rentals @@ -107,13 +107,13 @@ public void GetRentalCountForEachCar() { var expected = new Dictionary { - { "123 777", 3 }, - { "456 777", 1 }, - { "789 777", 2 }, - { "001 777", 1 }, - { "234 777", 1 }, - { "567 777", 1 }, - { "890 777", 1 } + { "А123ВС 777", 3 }, + { "В456ОР 777", 1 }, + { "Е789КХ 777", 2 }, + { "К001МР 777", 1 }, + { "М234ТН 777", 1 }, + { "Н567УХ 777", 1 }, + { "О890ЦВ 777", 1 } }; var actual = testData.Rentals @@ -137,11 +137,11 @@ public void GetTop5CustomersByTotalCost() { var expectedFullNames = new List { - " ", - " ", - " ", - " ", - " " + "Иванов Иван Иванович", + "Петрова Анна Сергеевна", + "Кузнецова Мария Дмитриевна", + "Сидоров Алексей Петрович", + "Смирнов Дмитрий Александрович" }; var actualFullNames = testData.Rentals From a5784bf9d946bf8ecdeb5269e0cf0593826ece67 Mon Sep 17 00:00:00 2001 From: bl0b3s Date: Tue, 17 Feb 2026 14:53:58 +0300 Subject: [PATCH 5/8] =?UTF-8?q?fix=D1=83=D0=B2=20tests?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CarRental/CarRental.Tests/LinqQueryTests.cs | 52 ++++++++------------- 1 file changed, 19 insertions(+), 33 deletions(-) diff --git a/CarRental/CarRental.Tests/LinqQueryTests.cs b/CarRental/CarRental.Tests/LinqQueryTests.cs index c13e8d38f..29d2c4204 100644 --- a/CarRental/CarRental.Tests/LinqQueryTests.cs +++ b/CarRental/CarRental.Tests/LinqQueryTests.cs @@ -5,29 +5,27 @@ /// public class LinqQueryTests(CarRentalDataFixture testData) : IClassFixture { - /// - /// Displays information about all customers who rented cars of the specified model, sorted by full name. - /// [Fact] public void GetCustomersByModel() { const int targetModelId = 1; - var expectedFullNames = new List { + "Волкова Ольга Николаевна", "Иванов Иван Иванович", "Кузнецова Мария Дмитриевна", - "Сидоров Алексей Петрович" + "Сидоров Алексей Петрович", + "Смирнов Дмитрий Александрович" }; var actualFullNames = testData.Rentals .Join(testData.Cars, r => r.CarId, c => c.Id, - (r, c) => new { Rental = r, Car = c }) + (r, c) => new { r.CustomerId, c.ModelGenerationId }) .Where(x => testData.ModelGenerations - .Any(mg => mg.Id == x.Car.ModelGenerationId && mg.CarModelId == targetModelId)) - .Select(x => x.Rental.CustomerId) + .Any(mg => mg.Id == x.ModelGenerationId && mg.CarModelId == targetModelId)) + .Select(x => x.CustomerId) .Distinct() .Join(testData.Customers, cid => cid, @@ -39,14 +37,10 @@ public void GetCustomersByModel() Assert.Equal(expectedFullNames, actualFullNames); } - /// - /// Displays information about cars that are currently rented. - /// [Fact] public void GetCurrentlyRentedCars() { var now = new DateTime(2025, 10, 16, 12, 0, 0); - var expectedPlates = new List { "А123ВС 777", @@ -59,15 +53,13 @@ public void GetCurrentlyRentedCars() r => r.CarId, c => c.Id, (r, c) => c.LicensePlate) + .Distinct() .OrderBy(plate => plate) .ToList(); Assert.Equal(expectedPlates, actualPlates); } - /// - /// Displays the top 5 most frequently rented cars. - /// [Fact] public void GetTop5MostRentedCars() { @@ -77,7 +69,7 @@ public void GetTop5MostRentedCars() "Е789КХ 777", "В456ОР 777", "К001МР 777", - "М234ТН 777" + "Н567УХ 777" }; var actualPlates = testData.Rentals @@ -88,7 +80,7 @@ public void GetTop5MostRentedCars() Count = g.Count() }) .OrderByDescending(x => x.Count) - .ThenBy(x => x.CarId) + .ThenBy(x => testData.Cars.First(c => c.Id == x.CarId).LicensePlate) .Take(5) .Join(testData.Cars, x => x.CarId, @@ -99,19 +91,15 @@ public void GetTop5MostRentedCars() Assert.Equal(expectedPlates, actualPlates); } - /// - /// Displays the number of rentals for each car. - /// [Fact] public void GetRentalCountForEachCar() { var expected = new Dictionary { - { "А123ВС 777", 3 }, + { "А123ВС 777", 5 }, { "В456ОР 777", 1 }, - { "Е789КХ 777", 2 }, + { "Е789КХ 777", 3 }, { "К001МР 777", 1 }, - { "М234ТН 777", 1 }, { "Н567УХ 777", 1 }, { "О890ЦВ 777", 1 } }; @@ -129,33 +117,30 @@ public void GetRentalCountForEachCar() Assert.Equal(expected, actual); } - /// - /// Displays the top 5 customers by total rental amount. - /// [Fact] public void GetTop5CustomersByTotalCost() { var expectedFullNames = new List { - "Иванов Иван Иванович", "Петрова Анна Сергеевна", + "Иванов Иван Иванович", "Кузнецова Мария Дмитриевна", - "Сидоров Алексей Петрович", - "Смирнов Дмитрий Александрович" + "Смирнов Дмитрий Александрович", + "Сидоров Алексей Петрович" }; var actualFullNames = testData.Rentals .Join(testData.Cars, r => r.CarId, c => c.Id, - (r, c) => new { Rental = r, Car = c }) + (r, c) => new { r.CustomerId, c.ModelGenerationId, r.Hours }) .Join(testData.ModelGenerations, - x => x.Car.ModelGenerationId, + x => x.ModelGenerationId, mg => mg.Id, (x, mg) => new { - x.Rental.CustomerId, - Cost = mg.HourlyRate * x.Rental.Hours + x.CustomerId, + Cost = mg.HourlyRate * x.Hours }) .GroupBy(x => x.CustomerId) .Select(g => new @@ -164,6 +149,7 @@ public void GetTop5CustomersByTotalCost() Total = g.Sum(x => x.Cost) }) .OrderByDescending(x => x.Total) + .ThenBy(x => testData.Customers.First(c => c.Id == x.CustomerId).FullName) .Take(5) .Join(testData.Customers, x => x.CustomerId, From 474be01e84d3d00a4fed7de1829598983f82efdf Mon Sep 17 00:00:00 2001 From: bl0b3s Date: Sun, 22 Feb 2026 17:40:48 +0300 Subject: [PATCH 6/8] create infrastructire layer: Added Repositories for each domain models, Added DBcontext for ORM queries tghrough MS SQL Server. Added Application layer: Implemented CRUD services for each Repository, created DTO views for each domain models. Created API layer: added CRUD, analytics controllers for each services. Created Producer for generation contracts between consumers and cars. Producer sends data via the grpc protocol. Created proto files in Application layer. Consumer has been created that receives generated data via rpc --- CarRental/CarRental.Api/CarRental.Api.csproj | 26 ++ .../Controllers/AnalyticController.cs | 136 ++++++++ .../Controllers/CarController.cs | 41 +++ .../Controllers/CarModelController.cs | 11 + .../Controllers/CrudControllerBase.cs | 123 +++++++ .../Controllers/CustomerController.cs | 11 + .../Controllers/ModelGenerationController.cs | 41 +++ .../Controllers/RentalController.cs | 99 ++++++ .../Middleware/LoggingMiddleware.cs | 40 +++ CarRental/CarRental.Api/Program.cs | 119 +++++++ .../Properties/launchSettings.json | 41 +++ .../appsettings.Development.json | 8 + CarRental/CarRental.Api/appsettings.json | 11 + CarRental/CarRental.AppHost/AppHost.cs | 21 ++ .../CarRental.AppHost.csproj | 25 ++ .../Properties/launchSettings.json | 33 ++ .../appsettings.Development.json | 8 + CarRental/CarRental.AppHost/appsettings.json | 6 + .../CarRental.Application.csproj | 31 ++ .../Dtos/CarModels/CarModelCreateDto.cs | 45 +++ .../Dtos/CarModels/CarModelResponseDto.cs | 8 + .../Dtos/CarModels/CarModelUpdate.cs | 6 + .../Dtos/Cars/CarCreateDto.cs | 31 ++ .../Dtos/Cars/CarResponseDto.cs | 6 + .../Dtos/Cars/CarUpdateDto.cs | 6 + .../Dtos/Customers/CustomerCreateDto.cs | 29 ++ .../Dtos/Customers/CustomerResponseDto.cs | 8 + .../Dtos/Customers/CustomerUpdateDto.cs | 5 + .../ModelGenerationCreateDto.cs | 46 +++ .../ModelGenerationResponseDto.cs | 8 + .../ModelGenerationUpdateDto.cs | 6 + .../Dtos/Rentals/RentalCreateDto.cs | 35 ++ .../Dtos/Rentals/RentalResponseDto.cs | 7 + .../Dtos/Rentals/RentalUpdateDto.cs | 5 + .../Profiles/MappingProfile.cs | 54 ++++ .../Protos/rental_streaming.proto | 25 ++ .../Services/AnalyticQueryService.cs | 182 +++++++++++ .../Services/BaseCrudSevice.cs | 123 +++++++ .../Services/CarModelService.cs | 15 + .../Services/CarService.cs | 15 + .../Services/CustomerService.cs | 15 + .../Services/IAnalyticQueryService.cs | 38 +++ .../Services/ICrudeService.cs | 46 +++ .../Services/ModelGenerationService.cs | 15 + .../Services/RentalService.cs | 15 + .../Validation/EnumRangeAttribute.cs | 44 +++ .../CarRental.Consumer.csproj | 22 ++ CarRental/CarRental.Consumer/Program.cs | 45 +++ .../Properties/launchSettings.json | 23 ++ .../Services/RequestStreamingService.cs | 121 +++++++ .../appsettings.Development.json | 11 + CarRental/CarRental.Consumer/appsettings.json | 24 ++ .../CarRental.Domain/CarRental.Domain.csproj | 4 +- .../Data/DataSeed.cs} | 28 +- CarRental/CarRental.Domain/Enums/BodyType.cs | 43 ++- CarRental/CarRental.Domain/Enums/CarClass.cs | 41 ++- .../CarRental.Domain/Enums/DriverType.cs | 28 +- .../Enums/TransmissionType.cs | 26 +- .../CarRental.Infrastructure.csproj | 25 ++ .../Data/EfDataSeeder.cs | 303 ++++++++++++++++++ .../Data/Interfaces/IDataSeeder.cs | 22 ++ .../20260222093112_InitialCreate.Designer.cs | 223 +++++++++++++ .../20260222093112_InitialCreate.cs | 173 ++++++++++ .../Migrations/AppDbContextModelSnapshot.cs | 220 +++++++++++++ .../Persistence/AppDbContext.cs | 130 ++++++++ .../Repositories/Interfaces/IRepository.cs | 43 +++ .../Repositories/Repository.cs | 72 +++++ .../CarRental.Producer.csproj | 25 ++ .../Configurations/GeneratorOptions.cs | 21 ++ .../Controllers/GeneratorController.cs | 39 +++ CarRental/CarRental.Producer/Program.cs | 45 +++ .../Properties/launchSettings.json | 41 +++ .../Services/RequestStreamingService.cs | 132 ++++++++ .../appsettings.Development.json | 33 ++ CarRental/CarRental.Producer/appsettings.json | 14 + .../CarRental.ServiceDefaults.csproj | 22 ++ .../CarRental.ServiceDefaults/Extensions.cs | 96 ++++++ CarRental/CarRental.Tests/LinqQueryTests.cs | 6 +- CarRental/CarRental.sln | 48 ++- 79 files changed, 3798 insertions(+), 20 deletions(-) create mode 100644 CarRental/CarRental.Api/CarRental.Api.csproj create mode 100644 CarRental/CarRental.Api/Controllers/AnalyticController.cs create mode 100644 CarRental/CarRental.Api/Controllers/CarController.cs create mode 100644 CarRental/CarRental.Api/Controllers/CarModelController.cs create mode 100644 CarRental/CarRental.Api/Controllers/CrudControllerBase.cs create mode 100644 CarRental/CarRental.Api/Controllers/CustomerController.cs create mode 100644 CarRental/CarRental.Api/Controllers/ModelGenerationController.cs create mode 100644 CarRental/CarRental.Api/Controllers/RentalController.cs create mode 100644 CarRental/CarRental.Api/Middleware/LoggingMiddleware.cs create mode 100644 CarRental/CarRental.Api/Program.cs create mode 100644 CarRental/CarRental.Api/Properties/launchSettings.json create mode 100644 CarRental/CarRental.Api/appsettings.Development.json create mode 100644 CarRental/CarRental.Api/appsettings.json create mode 100644 CarRental/CarRental.AppHost/AppHost.cs create mode 100644 CarRental/CarRental.AppHost/CarRental.AppHost.csproj create mode 100644 CarRental/CarRental.AppHost/Properties/launchSettings.json create mode 100644 CarRental/CarRental.AppHost/appsettings.Development.json create mode 100644 CarRental/CarRental.AppHost/appsettings.json create mode 100644 CarRental/CarRental.Application/CarRental.Application.csproj create mode 100644 CarRental/CarRental.Application/Dtos/CarModels/CarModelCreateDto.cs create mode 100644 CarRental/CarRental.Application/Dtos/CarModels/CarModelResponseDto.cs create mode 100644 CarRental/CarRental.Application/Dtos/CarModels/CarModelUpdate.cs create mode 100644 CarRental/CarRental.Application/Dtos/Cars/CarCreateDto.cs create mode 100644 CarRental/CarRental.Application/Dtos/Cars/CarResponseDto.cs create mode 100644 CarRental/CarRental.Application/Dtos/Cars/CarUpdateDto.cs create mode 100644 CarRental/CarRental.Application/Dtos/Customers/CustomerCreateDto.cs create mode 100644 CarRental/CarRental.Application/Dtos/Customers/CustomerResponseDto.cs create mode 100644 CarRental/CarRental.Application/Dtos/Customers/CustomerUpdateDto.cs create mode 100644 CarRental/CarRental.Application/Dtos/ModelGenerations/ModelGenerationCreateDto.cs create mode 100644 CarRental/CarRental.Application/Dtos/ModelGenerations/ModelGenerationResponseDto.cs create mode 100644 CarRental/CarRental.Application/Dtos/ModelGenerations/ModelGenerationUpdateDto.cs create mode 100644 CarRental/CarRental.Application/Dtos/Rentals/RentalCreateDto.cs create mode 100644 CarRental/CarRental.Application/Dtos/Rentals/RentalResponseDto.cs create mode 100644 CarRental/CarRental.Application/Dtos/Rentals/RentalUpdateDto.cs create mode 100644 CarRental/CarRental.Application/Profiles/MappingProfile.cs create mode 100644 CarRental/CarRental.Application/Protos/rental_streaming.proto create mode 100644 CarRental/CarRental.Application/Services/AnalyticQueryService.cs create mode 100644 CarRental/CarRental.Application/Services/BaseCrudSevice.cs create mode 100644 CarRental/CarRental.Application/Services/CarModelService.cs create mode 100644 CarRental/CarRental.Application/Services/CarService.cs create mode 100644 CarRental/CarRental.Application/Services/CustomerService.cs create mode 100644 CarRental/CarRental.Application/Services/IAnalyticQueryService.cs create mode 100644 CarRental/CarRental.Application/Services/ICrudeService.cs create mode 100644 CarRental/CarRental.Application/Services/ModelGenerationService.cs create mode 100644 CarRental/CarRental.Application/Services/RentalService.cs create mode 100644 CarRental/CarRental.Application/Validation/EnumRangeAttribute.cs create mode 100644 CarRental/CarRental.Consumer/CarRental.Consumer.csproj create mode 100644 CarRental/CarRental.Consumer/Program.cs create mode 100644 CarRental/CarRental.Consumer/Properties/launchSettings.json create mode 100644 CarRental/CarRental.Consumer/Services/RequestStreamingService.cs create mode 100644 CarRental/CarRental.Consumer/appsettings.Development.json create mode 100644 CarRental/CarRental.Consumer/appsettings.json rename CarRental/{CarRental.Tests/DataFixture.cs => CarRental.Domain/Data/DataSeed.cs} (87%) create mode 100644 CarRental/CarRental.Infrastructure/CarRental.Infrastructure.csproj create mode 100644 CarRental/CarRental.Infrastructure/Data/EfDataSeeder.cs create mode 100644 CarRental/CarRental.Infrastructure/Data/Interfaces/IDataSeeder.cs create mode 100644 CarRental/CarRental.Infrastructure/Migrations/20260222093112_InitialCreate.Designer.cs create mode 100644 CarRental/CarRental.Infrastructure/Migrations/20260222093112_InitialCreate.cs create mode 100644 CarRental/CarRental.Infrastructure/Migrations/AppDbContextModelSnapshot.cs create mode 100644 CarRental/CarRental.Infrastructure/Persistence/AppDbContext.cs create mode 100644 CarRental/CarRental.Infrastructure/Repositories/Interfaces/IRepository.cs create mode 100644 CarRental/CarRental.Infrastructure/Repositories/Repository.cs create mode 100644 CarRental/CarRental.Producer/CarRental.Producer.csproj create mode 100644 CarRental/CarRental.Producer/Configurations/GeneratorOptions.cs create mode 100644 CarRental/CarRental.Producer/Controllers/GeneratorController.cs create mode 100644 CarRental/CarRental.Producer/Program.cs create mode 100644 CarRental/CarRental.Producer/Properties/launchSettings.json create mode 100644 CarRental/CarRental.Producer/Services/RequestStreamingService.cs create mode 100644 CarRental/CarRental.Producer/appsettings.Development.json create mode 100644 CarRental/CarRental.Producer/appsettings.json create mode 100644 CarRental/CarRental.ServiceDefaults/CarRental.ServiceDefaults.csproj create mode 100644 CarRental/CarRental.ServiceDefaults/Extensions.cs diff --git a/CarRental/CarRental.Api/CarRental.Api.csproj b/CarRental/CarRental.Api/CarRental.Api.csproj new file mode 100644 index 000000000..7a2377247 --- /dev/null +++ b/CarRental/CarRental.Api/CarRental.Api.csproj @@ -0,0 +1,26 @@ + + + + net8.0 + enable + True + $(NoWarn);1591 + $(NoWarn);9107 + enable + + + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + + + + + \ No newline at end of file diff --git a/CarRental/CarRental.Api/Controllers/AnalyticController.cs b/CarRental/CarRental.Api/Controllers/AnalyticController.cs new file mode 100644 index 000000000..952364f62 --- /dev/null +++ b/CarRental/CarRental.Api/Controllers/AnalyticController.cs @@ -0,0 +1,136 @@ +using CarRental.Application.Dtos.Cars; +using CarRental.Application.Dtos.Customers; +using CarRental.Application.Services; +using Microsoft.AspNetCore.Mvc; + +namespace CarRental.Api.Controllers; + +/// +/// Controller for analytic queries and reports in the car rental system. +/// Provides endpoints for generating various business intelligence reports and data analytics. +/// +[ApiController] +[Produces("application/json")] +[Route("api/[controller]")] +public class AnalyticController(IAnalyticQueryService analyticQueryService) : ControllerBase +{ + /// + /// Retrieves the top 5 most rented cars. + /// + /// Collection of top 5 cars by rental count + /// Returns list of cars + /// If there was an internal server error + [HttpGet("cars/top5-most-rented")] + [ProducesResponseType(typeof(IEnumerable), 200)] + [ProducesResponseType(500)] + public async Task>> GetTop5MostRentedCars() + { + try + { + var result = await analyticQueryService.GetTop5MostRentedCarsAsync(); + return Ok(result); + } + catch (InvalidOperationException) + { + return StatusCode(500); + } + } + + /// + /// Retrieves the number of rentals for each car. + /// + /// Dictionary with license plate as key and rental count as value + /// Returns rental counts per car + /// If there was an internal server error + [HttpGet("cars/rental-counts")] + [ProducesResponseType(typeof(Dictionary), 200)] + [ProducesResponseType(500)] + public async Task>> GetRentalCountForEachCar() + { + try + { + var result = await analyticQueryService.GetRentalCountForEachCarAsync(); + return Ok(result); + } + catch (InvalidOperationException) + { + return StatusCode(500); + } + } + + /// + /// Retrieves the top 5 customers by total rental cost. + /// + /// Collection of top 5 customers by total spent + /// Returns list of customers + /// If there was an internal server error + [HttpGet("customers/top5-by-total-cost")] + [ProducesResponseType(typeof(IEnumerable), 200)] + [ProducesResponseType(500)] + public async Task>> GetTop5CustomersByTotalCost() + { + try + { + var result = await analyticQueryService.GetTop5CustomersByTotalCostAsync(); + return Ok(result); + } + catch (InvalidOperationException) + { + return StatusCode(500); + } + } + + /// + /// Retrieves all customers who rented cars of a specific model. + /// + /// ID of the car model + /// Collection of customers who rented the specified model + /// Returns list of customers + /// If model ID is invalid + /// If there was an internal server error + [HttpGet("models/{modelId}/customers")] + [ProducesResponseType(typeof(IEnumerable), 200)] + [ProducesResponseType(400)] + [ProducesResponseType(500)] + public async Task>> GetCustomersByModel([FromRoute] int modelId) + { + try + { + if (modelId <= 0) + return BadRequest(new { error = "Model ID must be positive" }); + + var result = await analyticQueryService.GetCustomersByModelAsync(modelId); + return Ok(result); + } + catch (InvalidOperationException) + { + return StatusCode(500); + } + } + + /// + /// Retrieves cars that are currently rented at the specified moment. + /// + /// Current date and time (optional, defaults to server time) + /// Collection of currently rented cars + /// Returns list of cars + /// If the provided time is invalid + /// If there was an internal server error + [HttpGet("cars/currently-rented")] + [ProducesResponseType(typeof(IEnumerable), 200)] + [ProducesResponseType(400)] + [ProducesResponseType(500)] + public async Task>> GetCurrentlyRentedCars([FromQuery] DateTime? now = null) + { + try + { + var currentTime = now ?? DateTime.UtcNow; + var result = await analyticQueryService.GetCurrentlyRentedCarsAsync(currentTime); + return Ok(result); + } + catch (InvalidOperationException) + { + return StatusCode(500); + } + } +} \ No newline at end of file diff --git a/CarRental/CarRental.Api/Controllers/CarController.cs b/CarRental/CarRental.Api/Controllers/CarController.cs new file mode 100644 index 000000000..293d67e91 --- /dev/null +++ b/CarRental/CarRental.Api/Controllers/CarController.cs @@ -0,0 +1,41 @@ +using CarRental.Application.Dtos.Cars; +using CarRental.Application.Dtos.ModelGenerations; +using CarRental.Application.Services; +using Microsoft.AspNetCore.Mvc; + +namespace CarRental.Api.Controllers; + +/// +/// Controller for managing cars in the car rental system. +/// +public class CarsController( + ICrudService service, + ICrudService modelGenerationService +) : CrudControllerBase(service) +{ + public override async Task> Create([FromBody] CarCreateDto createDto) + { + if (!ModelState.IsValid) return BadRequest(ModelState); + + var generation = await modelGenerationService.GetAsync(createDto.ModelGenerationId); + if (generation == null) + return BadRequest(new { error = $"Model generation with ID {createDto.ModelGenerationId} does not exist" }); + + var entity = await service.CreateAsync(createDto); + return CreatedAtAction(nameof(GetById), new { id = entity.Id }, entity); + } + + public override async Task Update(int id, [FromBody] CarUpdateDto updateDto) + { + if (!ModelState.IsValid) return BadRequest(ModelState); + + var generation = await modelGenerationService.GetAsync(updateDto.ModelGenerationId); + if (generation == null) + return BadRequest(new { error = $"Model generation with ID {updateDto.ModelGenerationId} does not exist" }); + + var updated = await service.UpdateAsync(id, updateDto); + if (updated == null) return NotFound(); + + return NoContent(); + } +} \ No newline at end of file diff --git a/CarRental/CarRental.Api/Controllers/CarModelController.cs b/CarRental/CarRental.Api/Controllers/CarModelController.cs new file mode 100644 index 000000000..057dba93d --- /dev/null +++ b/CarRental/CarRental.Api/Controllers/CarModelController.cs @@ -0,0 +1,11 @@ +using CarRental.Application.Dtos.CarModels; +using CarRental.Application.Services; + +namespace CarRental.Api.Controllers; + +/// +/// Controller for managing car models in the car rental system. +/// +public class CarModelController( + ICrudService service +) : CrudControllerBase(service); \ No newline at end of file diff --git a/CarRental/CarRental.Api/Controllers/CrudControllerBase.cs b/CarRental/CarRental.Api/Controllers/CrudControllerBase.cs new file mode 100644 index 000000000..820f648f5 --- /dev/null +++ b/CarRental/CarRental.Api/Controllers/CrudControllerBase.cs @@ -0,0 +1,123 @@ +using CarRental.Application.Services; +using Microsoft.AspNetCore.Mvc; +namespace CarRental.Api.Controllers; + +/// +/// Base controller providing CRUD operations for entities. +/// +/// The response DTO type +/// The create DTO type +/// The update DTO type +[ApiController] +[Produces("application/json")] +[Route("api/[controller]")] +public abstract class CrudControllerBase + (ICrudService service) : ControllerBase +{ + + /// + /// Retrieves all entities. + /// + /// A list of all entities + [HttpGet] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status500InternalServerError)] + public virtual async Task>> GetAll() + { + var entities = await service.GetAsync(); + return Ok(entities); + } + + /// + /// Retrieves a specific entity by its unique identifier. + /// + /// The ID of the entity to retrieve + /// The entity record if found + [HttpGet("{id:int}")] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + [ProducesResponseType(StatusCodes.Status500InternalServerError)] + public virtual async Task> GetById(int id) + { + var entity = await service.GetAsync(id); + if (entity == null) + return NotFound(); + + return Ok(entity); + } + + /// + /// Creates a new entity record. + /// + /// The entity data to create + /// The newly created entity record + [HttpPost] + [ProducesResponseType(StatusCodes.Status201Created)] + [ProducesResponseType(StatusCodes.Status400BadRequest)] + [ProducesResponseType(StatusCodes.Status500InternalServerError)] + public virtual async Task> Create([FromBody] TCreateDto createDto) + { + if (!ModelState.IsValid) + return BadRequest(ModelState); + + var entity = await service.CreateAsync(createDto); + var id = GetIdFromDto(entity); + + return CreatedAtAction(nameof(GetById), new { id }, entity); + } + + /// + /// Updates an existing entity record. + /// + /// The ID of the entity to update + /// The updated entity data + /// No content if successful + [HttpPut("{id:int}")] + [ProducesResponseType(StatusCodes.Status204NoContent)] + [ProducesResponseType(StatusCodes.Status400BadRequest)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + [ProducesResponseType(StatusCodes.Status500InternalServerError)] + public virtual async Task Update(int id, [FromBody] TUpdateDto updateDto) + { + if (!ModelState.IsValid) + return BadRequest(ModelState); + + var updatedEntity = await service.UpdateAsync(id, updateDto); + if (updatedEntity == null) + return NotFound(); + + return NoContent(); + } + + /// + /// Deletes an entity record. + /// + /// The ID of the entity to delete + /// No content if successful + [HttpDelete("{id:int}")] + [ProducesResponseType(StatusCodes.Status204NoContent)] + [ProducesResponseType(StatusCodes.Status500InternalServerError)] + public virtual async Task Delete(int id) + { + var deleted = await service.DeleteAsync(id); + return NoContent(); + } + + /// + /// Gets the ID from the DTO using reflection. + /// + private static int GetIdFromDto(TDto dto) + { + var property = dto?.GetType().GetProperty("Id") + ?? throw new InvalidOperationException($"DTO type {typeof(TDto).Name} does not have an 'Id' property."); + + if (property.PropertyType != typeof(int)) + throw new InvalidOperationException($"Id property in {typeof(TDto).Name} must be of type int."); + + var value = property.GetValue(dto); + if (value is not int id) + throw new InvalidOperationException($"Failed to retrieve Id from {typeof(TDto).Name}."); + + return id; + } +} \ No newline at end of file diff --git a/CarRental/CarRental.Api/Controllers/CustomerController.cs b/CarRental/CarRental.Api/Controllers/CustomerController.cs new file mode 100644 index 000000000..63e6e13f0 --- /dev/null +++ b/CarRental/CarRental.Api/Controllers/CustomerController.cs @@ -0,0 +1,11 @@ +using CarRental.Application.Dtos.Customers; +using CarRental.Application.Services; + +namespace CarRental.Api.Controllers; + +/// +/// Controller for managing customers in the car rental system. +/// +public class CustomerController( + ICrudService service +) : CrudControllerBase(service); \ No newline at end of file diff --git a/CarRental/CarRental.Api/Controllers/ModelGenerationController.cs b/CarRental/CarRental.Api/Controllers/ModelGenerationController.cs new file mode 100644 index 000000000..3efe861d1 --- /dev/null +++ b/CarRental/CarRental.Api/Controllers/ModelGenerationController.cs @@ -0,0 +1,41 @@ +using CarRental.Application.Dtos.CarModels; +using CarRental.Application.Dtos.ModelGenerations; +using CarRental.Application.Services; +using Microsoft.AspNetCore.Mvc; + +namespace CarRental.Api.Controllers; + +/// +/// Controller for managing model generations in the car rental system. +/// +public class ModelGenerationController( + ICrudService service, + ICrudService carModelService +) : CrudControllerBase(service) +{ + public override async Task> Create([FromBody] ModelGenerationCreateDto createDto) + { + if (!ModelState.IsValid) return BadRequest(ModelState); + + var carModel = await carModelService.GetAsync(createDto.CarModelId); + if (carModel == null) + return BadRequest(new { error = $"Car model with ID {createDto.CarModelId} does not exist" }); + + var entity = await service.CreateAsync(createDto); + return CreatedAtAction(nameof(GetById), new { id = entity.Id }, entity); + } + + public override async Task Update(int id, [FromBody] ModelGenerationUpdateDto updateDto) + { + if (!ModelState.IsValid) return BadRequest(ModelState); + + var carModel = await carModelService.GetAsync(updateDto.CarModelId); + if (carModel == null) + return BadRequest(new { error = $"Car model with ID {updateDto.CarModelId} does not exist" }); + + var updated = await service.UpdateAsync(id, updateDto); + if (updated == null) return NotFound(); + + return NoContent(); + } +} \ No newline at end of file diff --git a/CarRental/CarRental.Api/Controllers/RentalController.cs b/CarRental/CarRental.Api/Controllers/RentalController.cs new file mode 100644 index 000000000..eb0fb1356 --- /dev/null +++ b/CarRental/CarRental.Api/Controllers/RentalController.cs @@ -0,0 +1,99 @@ +using CarRental.Application.Dtos.Cars; +using CarRental.Application.Dtos.Customers; +using CarRental.Application.Dtos.Rentals; +using CarRental.Application.Services; +using Microsoft.AspNetCore.Mvc; + +namespace CarRental.Api.Controllers; + +/// +/// Controller for managing rentals in the car rental system. +/// +public class RentalsController + ( + ICrudService service, + ICrudService customerService, + ICrudService carService + ) + : CrudControllerBase(service) +{ + /// + /// Creates a new rental agreement. + /// + /// The rental data to create + /// The newly created rental record + /// Returns the newly created rental + /// If the request data is invalid or customer/car does not exist + /// If there was an internal server error + public override async Task> Create([FromBody] RentalCreateDto createDto) + { + try + { + if (!ModelState.IsValid) return BadRequest(ModelState); + + var customer = await customerService.GetAsync(createDto.CustomerId); + if (customer == null) + { + return BadRequest(new { error = $"Customer with ID {createDto.CustomerId} does not exist" }); + } + + var car = await carService.GetAsync(createDto.CarId); + if (car == null) + { + return BadRequest(new { error = $"Car with ID {createDto.CarId} does not exist" }); + } + + var entity = await service.CreateAsync(createDto); + return CreatedAtAction(nameof(GetById), new { id = entity.Id }, entity); + } + catch (InvalidOperationException) + { + return StatusCode(500); + } + } + + /// + /// Updates an existing rental agreement. + /// + /// The ID of the rental to update + /// The updated rental data + /// No content if successful + /// If the update was successful + /// If the request data is invalid or customer/car does not exist + /// If the rental with the specified ID was not found + /// If there was an internal server error + public override async Task Update(int id, [FromBody] RentalUpdateDto updateDto) + { + try + { + if (!ModelState.IsValid) return BadRequest(ModelState); + + if (updateDto.CustomerId != default) + { + var customer = await customerService.GetAsync(updateDto.CustomerId); + if (customer == null) + { + return BadRequest(new { error = $"Customer with ID {updateDto.CustomerId} does not exist" }); + } + } + + if (updateDto.CarId != default) + { + var car = await carService.GetAsync(updateDto.CarId); + if (car == null) + { + return BadRequest(new { error = $"Car with ID {updateDto.CarId} does not exist" }); + } + } + + var updatedEntity = await service.UpdateAsync(id, updateDto); + if (updatedEntity == null) return NotFound(); + + return NoContent(); + } + catch (InvalidOperationException) + { + return StatusCode(500); + } + } +} \ No newline at end of file diff --git a/CarRental/CarRental.Api/Middleware/LoggingMiddleware.cs b/CarRental/CarRental.Api/Middleware/LoggingMiddleware.cs new file mode 100644 index 000000000..2a95513e7 --- /dev/null +++ b/CarRental/CarRental.Api/Middleware/LoggingMiddleware.cs @@ -0,0 +1,40 @@ +using System.Diagnostics; +namespace CarRental.Api.Middleware; + +/// +/// Middleware for logging HTTP requests and responses. +/// Logs request details, execution time, and any exceptions that occur during request processing. +/// +public class LoggingMiddleware(RequestDelegate next, ILogger logger) +{ + /// + /// Processes an HTTP request and logs details about the request, response, and execution time. + /// + /// The HTTP context for the current request. + /// A task that represents the completion of request processing. + public async Task InvokeAsync(HttpContext context) + { + var requestId = Guid.NewGuid().ToString("N")[..8]; + var stopwatch = Stopwatch.StartNew(); + + try + { + logger.LogInformation("REQUEST_START | ID:{RequestId} | Method:{Method} | Path:{Path} | RemoteIP:{RemoteIp}", + requestId, context.Request.Method, context.Request.Path, context.Connection.RemoteIpAddress); + + await next(context); + + stopwatch.Stop(); + + logger.LogInformation("REQUEST_END | ID:{RequestId} | Method:{Method} | Path:{Path} | Status:{StatusCode} | Time:{ElapsedMs}ms", + requestId, context.Request.Method, context.Request.Path, context.Response.StatusCode, stopwatch.ElapsedMilliseconds); + } + catch (Exception ex) + { + stopwatch.Stop(); + logger.LogError(ex, "REQUEST_ERROR | ID:{RequestId} | Method:{Method} | Path:{Path} | Status:500 | Time:{ElapsedMs}ms | Error:{ErrorMessage}", + requestId, context.Request.Method, context.Request.Path, stopwatch.ElapsedMilliseconds, ex.Message); + throw; + } + } +} \ No newline at end of file diff --git a/CarRental/CarRental.Api/Program.cs b/CarRental/CarRental.Api/Program.cs new file mode 100644 index 000000000..f5ed8abc8 --- /dev/null +++ b/CarRental/CarRental.Api/Program.cs @@ -0,0 +1,119 @@ +using CarRental.Application.Dtos.CarModels; +using CarRental.Application.Dtos.Cars; +using CarRental.Application.Dtos.Customers; +using CarRental.Application.Dtos.ModelGenerations; +using CarRental.Application.Dtos.Rentals; +using CarRental.Application.Profiles; +using CarRental.Application.Services; +using CarRental.Infrastructure.Data; +using CarRental.Infrastructure.Data.Interfaces; +using CarRental.Infrastructure.Persistence; +using CarRental.Infrastructure.Repositories; +using CarRental.Infrastructure.Repositories.Interfaces; +using CarRental.ServiceDefaults; +using Microsoft.EntityFrameworkCore; +using Microsoft.OpenApi.Models; +using System.Text.Json.Serialization; + +var builder = WebApplication.CreateBuilder(args); + +builder.AddServiceDefaults(); + +builder.AddSqlServerDbContext("carrentaldb", configureDbContextOptions: opt => +{ + opt.UseLazyLoadingProxies(); +}); + +builder.Services.Configure(options => +{ + options.LowercaseUrls = true; + options.LowercaseQueryStrings = true; +}); + +builder.Services.AddControllers().AddJsonOptions(opts => +{ + opts.JsonSerializerOptions.Converters.Add(new JsonStringEnumConverter()); +}); + +builder.Services.AddAuthorization(); +builder.Services.AddEndpointsApiExplorer(); + +builder.Services.AddAutoMapper(config => +{ + config.AddProfile(new MappingProfile()); +}); + +builder.Services.AddSingleton(); +builder.Services.AddScoped(); + +builder.Services.AddScoped(typeof(IRepository<>), typeof(Repository<>)); + +builder.Services.AddScoped, CarService>(); +builder.Services.AddScoped, CustomerService>(); +builder.Services.AddScoped, CarModelService>(); +builder.Services.AddScoped, ModelGenerationService>(); +builder.Services.AddScoped, RentalService>(); + +builder.Services.AddScoped(); + +builder.Services.AddSwaggerGen(c => +{ + c.SwaggerDoc("v1", new OpenApiInfo + { + Title = "Car Rental API", + Version = "v1", + Description = "Car Rental Management System API" + }); + + var basePath = AppContext.BaseDirectory; + c.IncludeXmlComments(Path.Combine(basePath, "CarRental.Api.xml")); + c.IncludeXmlComments(Path.Combine(basePath, "CarRental.Application.xml")); + c.IncludeXmlComments(Path.Combine(basePath, "CarRental.Domain.xml")); +}); + +var app = builder.Build(); + +app.MapDefaultEndpoints(); + +if (app.Environment.IsDevelopment()) +{ + app.UseSwagger(); + app.UseSwaggerUI(); +} + +using var migrationScope = app.Services.CreateScope(); +var context = migrationScope.ServiceProvider.GetRequiredService(); +var logger = migrationScope.ServiceProvider.GetRequiredService>(); + +try +{ + logger.LogInformation("Applying database migrations..."); + await context.Database.MigrateAsync(); + logger.LogInformation("Migrations applied successfully"); +} +catch (Exception ex) +{ + logger.LogError(ex, "An error occurred during database migrations"); + throw; +} + +using var seedingScope = app.Services.CreateScope(); +var seeder = seedingScope.ServiceProvider.GetRequiredService(); +var seedingLogger = seedingScope.ServiceProvider.GetRequiredService>(); + +try +{ + seedingLogger.LogInformation("Seeding database..."); + await seeder.SeedAsync(); + seedingLogger.LogInformation("Database seeded successfully"); +} +catch (Exception ex) +{ + seedingLogger.LogError(ex, "An error occurred during database seeding"); +} + +app.UseHttpsRedirection(); +app.UseAuthorization(); +app.MapControllers(); + +app.Run(); \ No newline at end of file diff --git a/CarRental/CarRental.Api/Properties/launchSettings.json b/CarRental/CarRental.Api/Properties/launchSettings.json new file mode 100644 index 000000000..860e60bdf --- /dev/null +++ b/CarRental/CarRental.Api/Properties/launchSettings.json @@ -0,0 +1,41 @@ +{ + "$schema": "http://json.schemastore.org/launchsettings.json", + "iisSettings": { + "windowsAuthentication": false, + "anonymousAuthentication": true, + "iisExpress": { + "applicationUrl": "http://localhost:8879", + "sslPort": 44371 + } + }, + "profiles": { + "http": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": true, + "launchUrl": "swagger", + "applicationUrl": "http://localhost:5085", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + }, + "https": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": true, + "launchUrl": "swagger", + "applicationUrl": "https://localhost:7074;http://localhost:5085", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + }, + "IIS Express": { + "commandName": "IISExpress", + "launchBrowser": true, + "launchUrl": "swagger", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + } + } +} diff --git a/CarRental/CarRental.Api/appsettings.Development.json b/CarRental/CarRental.Api/appsettings.Development.json new file mode 100644 index 000000000..0c208ae91 --- /dev/null +++ b/CarRental/CarRental.Api/appsettings.Development.json @@ -0,0 +1,8 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + } +} diff --git a/CarRental/CarRental.Api/appsettings.json b/CarRental/CarRental.Api/appsettings.json new file mode 100644 index 000000000..ce27fa65a --- /dev/null +++ b/CarRental/CarRental.Api/appsettings.json @@ -0,0 +1,11 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Information", + "Microsoft.AspNetCore.Hosting": "Information", + "Microsoft.AspNetCore.Routing": "Information", + "Clinic.Application.Middleware.LoggingMiddleware": "Information" + } + } +} \ No newline at end of file diff --git a/CarRental/CarRental.AppHost/AppHost.cs b/CarRental/CarRental.AppHost/AppHost.cs new file mode 100644 index 000000000..7c37c3db6 --- /dev/null +++ b/CarRental/CarRental.AppHost/AppHost.cs @@ -0,0 +1,21 @@ +var builder = DistributedApplication.CreateBuilder(args); + +var password = builder.AddParameter("DatabasePassword", secret: true); + +var carrentalDb = builder.AddSqlServer("sqlserver", password: password) + .AddDatabase("carrentaldb"); + +builder.AddProject("carrental-api") + .WithReference(carrentalDb) + .WithExternalHttpEndpoints() + .WaitFor(carrentalDb); + +var consumer = builder.AddProject("grpc-consumer") + .WithReference(carrentalDb) + .WaitFor(carrentalDb); + +builder.AddProject("grpc-producer") + .WaitFor(carrentalDb) + .WaitFor(consumer); + +builder.Build().Run(); \ No newline at end of file diff --git a/CarRental/CarRental.AppHost/CarRental.AppHost.csproj b/CarRental/CarRental.AppHost/CarRental.AppHost.csproj new file mode 100644 index 000000000..54cfb5ef5 --- /dev/null +++ b/CarRental/CarRental.AppHost/CarRental.AppHost.csproj @@ -0,0 +1,25 @@ + + + + + + Exe + net8.0 + enable + enable + true + 70dd5e9d-2669-455c-87c8-a11d69e37eff + + + + + + + + + + + + + + \ No newline at end of file diff --git a/CarRental/CarRental.AppHost/Properties/launchSettings.json b/CarRental/CarRental.AppHost/Properties/launchSettings.json new file mode 100644 index 000000000..9cbe8250e --- /dev/null +++ b/CarRental/CarRental.AppHost/Properties/launchSettings.json @@ -0,0 +1,33 @@ +{ + "$schema": "https://json.schemastore.org/launchsettings.json", + "profiles": { + "https": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": true, + "applicationUrl": "https://localhost:17241;http://localhost:15153", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development", + "DOTNET_ENVIRONMENT": "Development", + "ASPIRE_DASHBOARD_OTLP_ENDPOINT_URL": "https://localhost:21040", + "ASPIRE_RESOURCE_SERVICE_ENDPOINT_URL": "https://localhost:22095", + "DOTNET_DASHBOARD_OTLP_ENDPOINT_URL": "https://localhost:21040", + "ASPIRE_ALLOW_UNSECURED_TRANSPORT": "true" + } + }, + "http": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": true, + "applicationUrl": "http://localhost:15153", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development", + "DOTNET_ENVIRONMENT": "Development", + "ASPIRE_DASHBOARD_OTLP_ENDPOINT_URL": "http://localhost:19161", + "ASPIRE_RESOURCE_SERVICE_ENDPOINT_URL": "http://localhost:20064", + "DOTNET_DASHBOARD_OTLP_ENDPOINT_URL": "https://localhost:21040", + "ASPIRE_ALLOW_UNSECURED_TRANSPORT": "true" + } + } + } +} \ No newline at end of file diff --git a/CarRental/CarRental.AppHost/appsettings.Development.json b/CarRental/CarRental.AppHost/appsettings.Development.json new file mode 100644 index 000000000..0c208ae91 --- /dev/null +++ b/CarRental/CarRental.AppHost/appsettings.Development.json @@ -0,0 +1,8 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + } +} diff --git a/CarRental/CarRental.AppHost/appsettings.json b/CarRental/CarRental.AppHost/appsettings.json new file mode 100644 index 000000000..840be2fe3 --- /dev/null +++ b/CarRental/CarRental.AppHost/appsettings.json @@ -0,0 +1,6 @@ +{ + + "Parameters": { + "DatabasePassword": "MyStr0ngP@ssw0rd2026" + } +} \ No newline at end of file diff --git a/CarRental/CarRental.Application/CarRental.Application.csproj b/CarRental/CarRental.Application/CarRental.Application.csproj new file mode 100644 index 000000000..7ea792cb4 --- /dev/null +++ b/CarRental/CarRental.Application/CarRental.Application.csproj @@ -0,0 +1,31 @@ + + + + net8.0 + enable + enable + True + $(NoWarn);1591 + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/CarRental/CarRental.Application/Dtos/CarModels/CarModelCreateDto.cs b/CarRental/CarRental.Application/Dtos/CarModels/CarModelCreateDto.cs new file mode 100644 index 000000000..89fdebcb4 --- /dev/null +++ b/CarRental/CarRental.Application/Dtos/CarModels/CarModelCreateDto.cs @@ -0,0 +1,45 @@ +using CarRental.Application.Validation; +using CarRental.Domain.Enums; +using System.ComponentModel.DataAnnotations; + +namespace CarRental.Application.Dtos.CarModels; +/// +/// DTO for creating a new car model. +/// +public class CarModelCreateDto +{ + /// + /// Name of the car model (e.g. Toyota Camry, BMW X5). + /// + [Required(ErrorMessage = "Model name is required")] + [StringLength(100, MinimumLength = 2, ErrorMessage = "Model name must be between 2 and 100 characters")] + public required string Name { get; set; } + + /// + /// Drivetrain type. + /// + [Required(ErrorMessage = "Drivetrain type is required")] + [EnumRange(typeof(DriverType))] + public required DriverType DriverType { get; set; } + + /// + /// Number of seats (including driver). + /// + [Required(ErrorMessage = "Seating capacity is required")] + [Range(2, 20, ErrorMessage = "Seating capacity must be between 2 and 20")] + public required byte SeatingCapacity { get; set; } + + /// + /// Body type / style. + /// + [Required(ErrorMessage = "Body type is required")] + [EnumRange(typeof(BodyType))] + public required BodyType BodyType { get; set; } + + /// + /// Vehicle class / segment. + /// + [Required(ErrorMessage = "Car class is required")] + [EnumRange(typeof(CarClass))] + public required CarClass CarClass { get; set; } +} \ No newline at end of file diff --git a/CarRental/CarRental.Application/Dtos/CarModels/CarModelResponseDto.cs b/CarRental/CarRental.Application/Dtos/CarModels/CarModelResponseDto.cs new file mode 100644 index 000000000..b5c83f85d --- /dev/null +++ b/CarRental/CarRental.Application/Dtos/CarModels/CarModelResponseDto.cs @@ -0,0 +1,8 @@ +using CarRental.Domain.Models; + +namespace CarRental.Application.Dtos.CarModels; + +/// +/// DTO for returning car model information. +/// +public class CarModelResponseDto: CarModel; diff --git a/CarRental/CarRental.Application/Dtos/CarModels/CarModelUpdate.cs b/CarRental/CarRental.Application/Dtos/CarModels/CarModelUpdate.cs new file mode 100644 index 000000000..637a91bf9 --- /dev/null +++ b/CarRental/CarRental.Application/Dtos/CarModels/CarModelUpdate.cs @@ -0,0 +1,6 @@ +namespace CarRental.Application.Dtos.CarModels; + +/// +/// DTO for updating an existing car model. +/// +public class CarModelUpdateDto : CarModelCreateDto; \ No newline at end of file diff --git a/CarRental/CarRental.Application/Dtos/Cars/CarCreateDto.cs b/CarRental/CarRental.Application/Dtos/Cars/CarCreateDto.cs new file mode 100644 index 000000000..604c474b5 --- /dev/null +++ b/CarRental/CarRental.Application/Dtos/Cars/CarCreateDto.cs @@ -0,0 +1,31 @@ +using System.ComponentModel.DataAnnotations; + +namespace CarRental.Application.Dtos.Cars; +/// +/// DTO for creating a new car. +/// +public class CarCreateDto +{ + /// + /// License plate number (e.g. A123BC 777). + /// + [Required(ErrorMessage = "License plate is required")] + [StringLength(12, MinimumLength = 6, ErrorMessage = "License plate must be between 6 and 12 characters")] + [RegularExpression(@"^[АВЕКМНОРСТУХ]\d{3}[АВЕКМНОРСТУХ]{2}\s\d{3}$", + ErrorMessage = "License plate format example: A123BC 777")] + public required string LicensePlate { get; set; } + + /// + /// Color of the vehicle. + /// + [Required(ErrorMessage = "Color is required")] + [StringLength(50, MinimumLength = 3, ErrorMessage = "Color must be between 3 and 50 characters")] + public required string Color { get; set; } + + /// + /// ID of the model generation this car belongs to. + /// + [Required(ErrorMessage = "Model generation ID is required")] + [Range(1, int.MaxValue, ErrorMessage = "Invalid model generation ID")] + public required int ModelGenerationId { get; set; } +} \ No newline at end of file diff --git a/CarRental/CarRental.Application/Dtos/Cars/CarResponseDto.cs b/CarRental/CarRental.Application/Dtos/Cars/CarResponseDto.cs new file mode 100644 index 000000000..d84d3053d --- /dev/null +++ b/CarRental/CarRental.Application/Dtos/Cars/CarResponseDto.cs @@ -0,0 +1,6 @@ +using CarRental.Domain.Models; +namespace CarRental.Application.Dtos.Cars; +/// +/// DTO for returning car information. +/// +public class CarResponseDto: Car; diff --git a/CarRental/CarRental.Application/Dtos/Cars/CarUpdateDto.cs b/CarRental/CarRental.Application/Dtos/Cars/CarUpdateDto.cs new file mode 100644 index 000000000..4c7535656 --- /dev/null +++ b/CarRental/CarRental.Application/Dtos/Cars/CarUpdateDto.cs @@ -0,0 +1,6 @@ +namespace CarRental.Application.Dtos.Cars; + +/// +/// DTO for updating an existing car. +/// +public class CarUpdateDto : CarCreateDto; \ No newline at end of file diff --git a/CarRental/CarRental.Application/Dtos/Customers/CustomerCreateDto.cs b/CarRental/CarRental.Application/Dtos/Customers/CustomerCreateDto.cs new file mode 100644 index 000000000..99bcf686b --- /dev/null +++ b/CarRental/CarRental.Application/Dtos/Customers/CustomerCreateDto.cs @@ -0,0 +1,29 @@ +using System.ComponentModel.DataAnnotations; + +namespace CarRental.Application.Dtos.Customers; + +/// +/// DTO for creating a new customer. +/// +public class CustomerCreateDto +{ + /// + /// Driver's license number (unique identifier). + /// + [Required(ErrorMessage = "Driver's license number is required")] + [StringLength(20, MinimumLength = 8, ErrorMessage = "Driver's license must be between 8 and 20 characters")] + public required string DriverLicenseNumber { get; set; } + + /// + /// Full name of the customer. + /// + [Required(ErrorMessage = "Full name is required")] + [StringLength(150, MinimumLength = 3, ErrorMessage = "Full name must be between 3 and 150 characters")] + public required string FullName { get; set; } + + /// + /// Date of birth. + /// + [Required(ErrorMessage = "Date of birth is required")] + public required DateOnly DateOfBirth { get; set; } +} \ No newline at end of file diff --git a/CarRental/CarRental.Application/Dtos/Customers/CustomerResponseDto.cs b/CarRental/CarRental.Application/Dtos/Customers/CustomerResponseDto.cs new file mode 100644 index 000000000..97be2493b --- /dev/null +++ b/CarRental/CarRental.Application/Dtos/Customers/CustomerResponseDto.cs @@ -0,0 +1,8 @@ +using CarRental.Domain.Models; + +namespace CarRental.Application.Dtos.Customers; + +/// +/// DTO for returning customer information. +/// +public class CustomerResponseDto : Customer; \ No newline at end of file diff --git a/CarRental/CarRental.Application/Dtos/Customers/CustomerUpdateDto.cs b/CarRental/CarRental.Application/Dtos/Customers/CustomerUpdateDto.cs new file mode 100644 index 000000000..fd4271f18 --- /dev/null +++ b/CarRental/CarRental.Application/Dtos/Customers/CustomerUpdateDto.cs @@ -0,0 +1,5 @@ +namespace CarRental.Application.Dtos.Customers; +/// +/// DTO for updating an existing customer. +/// +public class CustomerUpdateDto : CustomerCreateDto; diff --git a/CarRental/CarRental.Application/Dtos/ModelGenerations/ModelGenerationCreateDto.cs b/CarRental/CarRental.Application/Dtos/ModelGenerations/ModelGenerationCreateDto.cs new file mode 100644 index 000000000..e1cada0e1 --- /dev/null +++ b/CarRental/CarRental.Application/Dtos/ModelGenerations/ModelGenerationCreateDto.cs @@ -0,0 +1,46 @@ +using CarRental.Application.Validation; +using CarRental.Domain.Enums; +using System.ComponentModel.DataAnnotations; + +namespace CarRental.Application.Dtos.ModelGenerations; + +/// +/// DTO for creating a new model generation. +/// +public class ModelGenerationCreateDto +{ + /// + /// Year when this generation started production. + /// + [Required(ErrorMessage = "Production year is required")] + [Range(1900, 2100, ErrorMessage = "Production year must be between 1900 and 2100")] + public required int ProductionYear { get; set; } + + /// + /// Engine displacement in liters. + /// + [Required(ErrorMessage = "Engine volume is required")] + [Range(0.1, 10.0, ErrorMessage = "Engine volume must be between 0.1 and 10.0 liters")] + public required decimal EngineVolumeLiters { get; set; } + + /// + /// Type of transmission. + /// + [Required(ErrorMessage = "Transmission type is required")] + [EnumRange(typeof(TransmissionType))] + public required TransmissionType TransmissionType { get; set; } + + /// + /// Hourly rental rate for this generation. + /// + [Required(ErrorMessage = "Hourly rate is required")] + [Range(100, 100000, ErrorMessage = "Hourly rate must be between 100 and 100000")] + public required decimal HourlyRate { get; set; } + + /// + /// ID of the base car model. + /// + [Required(ErrorMessage = "Car model ID is required")] + [Range(1, int.MaxValue)] + public required int CarModelId { get; set; } +} \ No newline at end of file diff --git a/CarRental/CarRental.Application/Dtos/ModelGenerations/ModelGenerationResponseDto.cs b/CarRental/CarRental.Application/Dtos/ModelGenerations/ModelGenerationResponseDto.cs new file mode 100644 index 000000000..87f7339e7 --- /dev/null +++ b/CarRental/CarRental.Application/Dtos/ModelGenerations/ModelGenerationResponseDto.cs @@ -0,0 +1,8 @@ +using CarRental.Domain.Models; + +namespace CarRental.Application.Dtos.ModelGenerations; + +/// +/// DTO for returning model generation information. +/// +public class ModelGenerationResponseDto: ModelGeneration; \ No newline at end of file diff --git a/CarRental/CarRental.Application/Dtos/ModelGenerations/ModelGenerationUpdateDto.cs b/CarRental/CarRental.Application/Dtos/ModelGenerations/ModelGenerationUpdateDto.cs new file mode 100644 index 000000000..6f7edbbb2 --- /dev/null +++ b/CarRental/CarRental.Application/Dtos/ModelGenerations/ModelGenerationUpdateDto.cs @@ -0,0 +1,6 @@ +namespace CarRental.Application.Dtos.ModelGenerations; + +/// +/// DTO for updating an existing model generation. +/// +public class ModelGenerationUpdateDto : ModelGenerationCreateDto; diff --git a/CarRental/CarRental.Application/Dtos/Rentals/RentalCreateDto.cs b/CarRental/CarRental.Application/Dtos/Rentals/RentalCreateDto.cs new file mode 100644 index 000000000..419895bb5 --- /dev/null +++ b/CarRental/CarRental.Application/Dtos/Rentals/RentalCreateDto.cs @@ -0,0 +1,35 @@ +using System.ComponentModel.DataAnnotations; + +namespace CarRental.Application.Dtos.Rentals; +/// +/// DTO for creating a new rental agreement. +/// +public class RentalCreateDto +{ + /// + /// ID of the customer renting the car. + /// + [Required(ErrorMessage = "Customer ID is required")] + [Range(1, int.MaxValue)] + public required int CustomerId { get; set; } + + /// + /// ID of the car being rented. + /// + [Required(ErrorMessage = "Car ID is required")] + [Range(1, int.MaxValue)] + public required int CarId { get; set; } + + /// + /// Date and time when the car is picked up. + /// + [Required(ErrorMessage = "Pickup date and time is required")] + public required DateTime PickupDateTime { get; set; } + + /// + /// Rental duration in hours. + /// + [Required(ErrorMessage = "Rental duration is required")] + [Range(1, 8760, ErrorMessage = "Duration must be between 1 hour and 1 year (8760 hours)")] + public required int Hours { get; set; } +} \ No newline at end of file diff --git a/CarRental/CarRental.Application/Dtos/Rentals/RentalResponseDto.cs b/CarRental/CarRental.Application/Dtos/Rentals/RentalResponseDto.cs new file mode 100644 index 000000000..40fdd2672 --- /dev/null +++ b/CarRental/CarRental.Application/Dtos/Rentals/RentalResponseDto.cs @@ -0,0 +1,7 @@ +using CarRental.Domain.Models; + +namespace CarRental.Application.Dtos.Rentals; +/// +/// DTO for returning rental information. +/// +public class RentalResponseDto : Rental; diff --git a/CarRental/CarRental.Application/Dtos/Rentals/RentalUpdateDto.cs b/CarRental/CarRental.Application/Dtos/Rentals/RentalUpdateDto.cs new file mode 100644 index 000000000..cce81bae6 --- /dev/null +++ b/CarRental/CarRental.Application/Dtos/Rentals/RentalUpdateDto.cs @@ -0,0 +1,5 @@ +namespace CarRental.Application.Dtos.Rentals; +/// +/// DTO for updating a rental (e.g. extend duration). +/// +public class RentalUpdateDto: RentalCreateDto; diff --git a/CarRental/CarRental.Application/Profiles/MappingProfile.cs b/CarRental/CarRental.Application/Profiles/MappingProfile.cs new file mode 100644 index 000000000..bed7f3a79 --- /dev/null +++ b/CarRental/CarRental.Application/Profiles/MappingProfile.cs @@ -0,0 +1,54 @@ +using AutoMapper; +using CarRental.Domain.Models; +using CarRental.Application.Dtos.Cars; +using CarRental.Application.Dtos.Customers; +using CarRental.Application.Dtos.CarModels; +using CarRental.Application.Dtos.ModelGenerations; +using CarRental.Application.Dtos.Rentals; + +namespace CarRental.Application.Profiles; + +/// +/// AutoMapper profile configuration for mapping between domain models and DTOs. +/// Defines all object-to-object mappings used in the application. +/// +public class MappingProfile : Profile +{ + /// + /// Initializes a new instance of the MappingProfile class and configures all mappings. + /// + public MappingProfile() + { + CreateMap(); + CreateMap(); + CreateMap(); + CreateMap(); + CreateMap(); + + CreateMap(); + CreateMap() + .ForMember(dest => dest.DriverLicenseNumber, + opt => opt.MapFrom(src => NormalizeDriverLicense(src.DriverLicenseNumber))); + CreateMap(); + CreateMap(); + CreateMap(); + + CreateMap(); + CreateMap() + .ForMember(dest => dest.DriverLicenseNumber, + opt => opt.MapFrom(src => NormalizeDriverLicense(src.DriverLicenseNumber))); + CreateMap(); + CreateMap(); + CreateMap() + .ForAllMembers(opt => opt.Condition((src, dest, srcMember) => srcMember != null)); + } + + private static string? NormalizeDriverLicense(string? license) + { + if (string.IsNullOrWhiteSpace(license)) return null; + + var cleaned = new string(license.Where(c => char.IsLetterOrDigit(c)).ToArray()); + + return cleaned.ToUpperInvariant(); + } +} \ No newline at end of file diff --git a/CarRental/CarRental.Application/Protos/rental_streaming.proto b/CarRental/CarRental.Application/Protos/rental_streaming.proto new file mode 100644 index 000000000..0d96f3c08 --- /dev/null +++ b/CarRental/CarRental.Application/Protos/rental_streaming.proto @@ -0,0 +1,25 @@ +syntax = "proto3"; + +option csharp_namespace = "CarRental.Application.Dtos.Grpc"; + +import "google/protobuf/timestamp.proto"; + +package carrental; + +service RentalStreaming { + rpc StreamRentals (stream RentalRequestMessage) returns (stream RentalResponseMessage); +} + +message RentalRequestMessage { + int32 customer_id = 1; + int32 car_id = 2; + google.protobuf.Timestamp pickup_date_time = 3; + int32 hours = 4; +} + +message RentalResponseMessage { + bool success = 1; + string message = 2; + int64 rental_id = 3; + string error_code = 4; +} \ No newline at end of file diff --git a/CarRental/CarRental.Application/Services/AnalyticQueryService.cs b/CarRental/CarRental.Application/Services/AnalyticQueryService.cs new file mode 100644 index 000000000..a073f1a0a --- /dev/null +++ b/CarRental/CarRental.Application/Services/AnalyticQueryService.cs @@ -0,0 +1,182 @@ +using AutoMapper; +using CarRental.Application.Dtos.Cars; +using CarRental.Application.Dtos.Customers; +using CarRental.Domain.Models; +using CarRental.Infrastructure.Repositories.Interfaces; + +namespace CarRental.Application.Services; + +/// +/// Implementation of car rental analytic query service. +/// +public class AnalyticQueryService( + IRepository carRepository, + IRepository customerRepository, + IRepository rentalRepository, + IRepository modelGenerationRepository, + IMapper mapper +) : IAnalyticQueryService +{ + /// + public async Task> GetTop5MostRentedCarsAsync() + { + try + { + var rentals = await rentalRepository.GetAsync(); + var cars = await carRepository.GetAsync(); + + var topCars = rentals + .GroupBy(r => r.CarId) + .Select(g => new + { + CarId = g.Key, + Count = g.Count() + }) + .OrderByDescending(x => x.Count) + .ThenBy(x => cars.First(c => c.Id == x.CarId).LicensePlate) + .Take(5) + .Join(cars, + x => x.CarId, + c => c.Id, + (x, c) => c) + .ToList(); + + return mapper.Map>(topCars); + } + catch (Exception ex) + { + throw new InvalidOperationException("Failed to retrieve top 5 most rented cars", ex); + } + } + + /// + public async Task> GetRentalCountForEachCarAsync() + { + try + { + var rentals = await rentalRepository.GetAsync(); + var cars = await carRepository.GetAsync(); + + var counts = rentals + .GroupBy(r => r.CarId) + .Select(g => new + { + Plate = cars.First(c => c.Id == g.Key).LicensePlate, + Count = g.Count() + }) + .OrderBy(x => x.Plate) + .ToDictionary(x => x.Plate, x => x.Count); + + return counts; + } + catch (Exception ex) + { + throw new InvalidOperationException("Failed to retrieve rental counts for each car", ex); + } + } + + /// + public async Task> GetTop5CustomersByTotalCostAsync() + { + try + { + var rentals = await rentalRepository.GetAsync(); + var cars = await carRepository.GetAsync(); + var modelGenerations = await modelGenerationRepository.GetAsync(); + var customers = await customerRepository.GetAsync(); + + var topCustomers = rentals + .Join(cars, + r => r.CarId, + c => c.Id, + (r, c) => new { r.CustomerId, c.ModelGenerationId, r.Hours }) + .Join(modelGenerations, + x => x.ModelGenerationId, + mg => mg.Id, + (x, mg) => new + { + x.CustomerId, + Cost = mg.HourlyRate * x.Hours + }) + .GroupBy(x => x.CustomerId) + .Select(g => new + { + CustomerId = g.Key, + Total = g.Sum(x => x.Cost) + }) + .OrderByDescending(x => x.Total) + .ThenBy(x => customers.First(c => c.Id == x.CustomerId).FullName) + .Take(5) + .Join(customers, + x => x.CustomerId, + c => c.Id, + (x, c) => c) + .ToList(); + + return mapper.Map>(topCustomers); + } + catch (Exception ex) + { + throw new InvalidOperationException("Failed to retrieve top 5 customers by total cost", ex); + } + } + + /// + public async Task> GetCustomersByModelAsync(int modelId) + { + try + { + var rentals = await rentalRepository.GetAsync(); + var cars = await carRepository.GetAsync(); + var modelGenerations = await modelGenerationRepository.GetAsync(); + var customers = await customerRepository.GetAsync(); + + var customerIds = rentals + .Join(cars, + r => r.CarId, + c => c.Id, + (r, c) => new { r.CustomerId, c.ModelGenerationId }) + .Where(x => modelGenerations.Any(mg => mg.Id == x.ModelGenerationId && mg.CarModelId == modelId)) + .Select(x => x.CustomerId) + .Distinct() + .ToList(); + + var customersList = customers + .Where(c => customerIds.Contains(c.Id)) + .OrderBy(c => c.FullName) + .ToList(); + + return mapper.Map>(customersList); + } + catch (Exception ex) + { + throw new InvalidOperationException($"Failed to retrieve customers for model ID {modelId}", ex); + } + } + + /// + public async Task> GetCurrentlyRentedCarsAsync(DateTime now) + { + try + { + var rentals = await rentalRepository.GetAsync(); + var cars = await carRepository.GetAsync(); + + var currentRentals = rentals + .Where(r => r.PickupDateTime <= now && r.PickupDateTime.AddHours(r.Hours) > now) + .Join(cars, + r => r.CarId, + c => c.Id, + (r, c) => c) + .DistinctBy(c => c.Id) + .OrderBy(c => c.LicensePlate) + .ToList(); + + return mapper.Map>(currentRentals); + } + catch (Exception ex) + { + throw new InvalidOperationException("Failed to retrieve currently rented cars", ex); + } + } +} diff --git a/CarRental/CarRental.Application/Services/BaseCrudSevice.cs b/CarRental/CarRental.Application/Services/BaseCrudSevice.cs new file mode 100644 index 000000000..687aa5522 --- /dev/null +++ b/CarRental/CarRental.Application/Services/BaseCrudSevice.cs @@ -0,0 +1,123 @@ +using AutoMapper; +using CarRental.Infrastructure.Repositories.Interfaces; + +namespace CarRental.Application.Services; +/// +/// Base implementation of CRUD service providing common operations for all entities. +/// Handles mapping between domain models and DTOs, and delegates data access to the repository. +/// +/// The type of the domain model, must inherit from Model. +/// The type of the response DTO used for data retrieval. +/// The type of the DTO used for creating new entities. +/// The type of the DTO used for updating existing entities. +public class BaseCrudService + ( + IRepository repository, + IMapper mapper + ) + + : ICrudService + where TDto : class? +{ + /// + /// Retrieves all entities as DTOs. + /// + /// A collection of all entity DTOs. + /// Thrown when retrieval fails. + public virtual async Task> GetAsync() + { + try + { + var entities = await repository.GetAsync(); + return mapper.Map>(entities); + } + catch (Exception ex) + { + throw new InvalidOperationException($"Failed to retrieve {typeof(TModel).Name} entities", ex); + } + } + + /// + /// Retrieves a specific entity by its unique identifier. + /// + /// The ID of the entity to retrieve. + /// The entity DTO if found; otherwise, null. + /// Thrown when retrieval fails. + public virtual async Task GetAsync(int id) + { + try + { + var entity = await repository.GetAsync(id); + return entity == null ? null : mapper.Map(entity); + } + catch (Exception ex) + { + throw new InvalidOperationException($"Failed to retrieve {typeof(TModel).Name} with ID {id}", ex); + } + } + + /// + /// Creates a new entity from the provided DTO. + /// + /// The DTO containing data for the new entity. + /// The created entity as a DTO. + /// Thrown when creation fails. + public virtual async Task CreateAsync(TCreateDto createDto) + { + try + { + var entity = mapper.Map(createDto); + await repository.CreateAsync(entity); + return mapper.Map(entity); + } + catch (Exception ex) + { + throw new InvalidOperationException($"Failed to create {typeof(TModel).Name}", ex); + } + } + + /// + /// Updates an existing entity with the provided DTO data. + /// + /// The ID of the entity to update. + /// The DTO containing updated data. + /// The updated entity as a DTO if successful; otherwise, null. + /// Thrown when update fails. + public virtual async Task UpdateAsync(int id, TUpdateDto updateDto) + { + try + { + var entity = await repository.GetAsync(id); + if (entity == null) return null; + mapper.Map(updateDto, entity); + await repository.UpdateAsync(entity); + return mapper.Map(entity); + } + catch (Exception ex) + { + throw new InvalidOperationException($"Failed to update {typeof(TModel).Name} with ID {id}", ex); + } + } + + /// + /// Deletes an entity by its unique identifier. + /// + /// The ID of the entity to delete. + /// True if the entity was successfully deleted; otherwise, false. + /// Thrown when deletion fails. + public virtual async Task DeleteAsync(int id) + { + try + { + var exists = await repository.GetAsync(id); + if (exists == null) return false; + + await repository.DeleteAsync(id); + return true; + } + catch (Exception ex) + { + throw new InvalidOperationException($"Failed to delete {typeof(TModel).Name} with ID {id}", ex); + } + } +} \ No newline at end of file diff --git a/CarRental/CarRental.Application/Services/CarModelService.cs b/CarRental/CarRental.Application/Services/CarModelService.cs new file mode 100644 index 000000000..9ceb8e81f --- /dev/null +++ b/CarRental/CarRental.Application/Services/CarModelService.cs @@ -0,0 +1,15 @@ +using AutoMapper; +using CarRental.Application.Dtos.CarModels; +using CarRental.Domain.Models; +using CarRental.Infrastructure.Repositories.Interfaces; + +namespace CarRental.Application.Services; + +/// +/// Service for managing CarModel entities. +/// Provides CRUD operations for CarModels using the underlying repository. +/// +/// The repository for CarModel data access. +/// The AutoMapper instance for object mapping. +public class CarModelService(IRepository repository, IMapper mapper) + : BaseCrudService(repository, mapper); \ No newline at end of file diff --git a/CarRental/CarRental.Application/Services/CarService.cs b/CarRental/CarRental.Application/Services/CarService.cs new file mode 100644 index 000000000..bf8467ddd --- /dev/null +++ b/CarRental/CarRental.Application/Services/CarService.cs @@ -0,0 +1,15 @@ +using AutoMapper; +using CarRental.Application.Dtos.Cars; +using CarRental.Domain.Models; +using CarRental.Infrastructure.Repositories.Interfaces; + +namespace CarRental.Application.Services; + +/// +/// Service for managing Car entities. +/// Provides CRUD operations for Cars using the underlying repository. +/// +/// The repository for Car data access. +/// The AutoMapper instance for object mapping. +public class CarService(IRepository repository, IMapper mapper) + : BaseCrudService(repository, mapper); diff --git a/CarRental/CarRental.Application/Services/CustomerService.cs b/CarRental/CarRental.Application/Services/CustomerService.cs new file mode 100644 index 000000000..f3ee84a90 --- /dev/null +++ b/CarRental/CarRental.Application/Services/CustomerService.cs @@ -0,0 +1,15 @@ +using AutoMapper; +using CarRental.Application.Dtos.Customers; +using CarRental.Domain.Models; +using CarRental.Infrastructure.Repositories.Interfaces; + +namespace CarRental.Application.Services; + +/// +/// Service for managing Customer entities. +/// Provides CRUD operations for Customers using the underlying repository. +/// +/// The repository for Customer data access. +/// The AutoMapper instance for object mapping. +public class CustomerService(IRepository repository, IMapper mapper) + : BaseCrudService(repository, mapper); \ No newline at end of file diff --git a/CarRental/CarRental.Application/Services/IAnalyticQueryService.cs b/CarRental/CarRental.Application/Services/IAnalyticQueryService.cs new file mode 100644 index 000000000..4a9518c28 --- /dev/null +++ b/CarRental/CarRental.Application/Services/IAnalyticQueryService.cs @@ -0,0 +1,38 @@ +using CarRental.Application.Dtos.Cars; +using CarRental.Application.Dtos.Customers; + +namespace CarRental.Application.Services; +/// +/// Service for complex car rental analytic queries and reports. +/// +public interface IAnalyticQueryService +{ + /// + /// Returns the top 5 most rented cars (by number of rentals). + /// Sorted by count descending, then by license plate ascending. + /// + Task> GetTop5MostRentedCarsAsync(); + + /// + /// Returns number of rentals for each car. + /// Result is sorted by license plate. + /// + Task> GetRentalCountForEachCarAsync(); + + /// + /// Returns top 5 customers by total rental cost. + /// Sorted by total cost descending, then by full name ascending. + /// + Task> GetTop5CustomersByTotalCostAsync(); + + /// + /// Returns all customers who ever rented cars of the specified model. + /// Sorted by full name. + /// + Task> GetCustomersByModelAsync(int modelId); + + /// + /// Returns cars that are currently rented at the given moment. + /// + Task> GetCurrentlyRentedCarsAsync(DateTime now); +} diff --git a/CarRental/CarRental.Application/Services/ICrudeService.cs b/CarRental/CarRental.Application/Services/ICrudeService.cs new file mode 100644 index 000000000..e8ca7e7bb --- /dev/null +++ b/CarRental/CarRental.Application/Services/ICrudeService.cs @@ -0,0 +1,46 @@ +namespace CarRental.Application.Services; + +/// +/// Generic service interface for performing CRUD operations on entities. +/// Defines standard create, read, update, and delete operations using DTOs. +/// +/// The type of the response DTO used for data retrieval. +/// The type of the DTO used for creating new entities. +/// The type of the DTO used for updating existing entities. +public interface ICrudService +{ + /// + /// Retrieves all entities as DTOs. + /// + /// A collection of all entity DTOs. + public Task> GetAsync(); + + /// + /// Retrieves a specific entity by its unique identifier. + /// + /// The ID of the entity to retrieve. + /// The entity DTO if found; otherwise, null. + public Task GetAsync(int id); + + /// + /// Creates a new entity from the provided DTO. + /// + /// The DTO containing data for the new entity. + /// The created entity as a DTO. + public Task CreateAsync(TCreateDto createDto); + + /// + /// Updates an existing entity with the provided DTO data. + /// + /// The ID of the entity to update. + /// The DTO containing updated data. + /// The updated entity as a DTO if successful; otherwise, null. + public Task UpdateAsync(int id, TUpdateDto updateDto); + + /// + /// Deletes an entity by its unique identifier. + /// + /// The ID of the entity to delete. + /// True if the entity was successfully deleted; otherwise, false. + public Task DeleteAsync(int id); +} \ No newline at end of file diff --git a/CarRental/CarRental.Application/Services/ModelGenerationService.cs b/CarRental/CarRental.Application/Services/ModelGenerationService.cs new file mode 100644 index 000000000..f1b2ae7f7 --- /dev/null +++ b/CarRental/CarRental.Application/Services/ModelGenerationService.cs @@ -0,0 +1,15 @@ +using AutoMapper; +using CarRental.Application.Dtos.ModelGenerations; +using CarRental.Domain.Models; +using CarRental.Infrastructure.Repositories.Interfaces; + +namespace CarRental.Application.Services; + +/// +/// Service for managing ModelGeneration entities. +/// Provides CRUD operations for ModelGenerations using the underlying repository. +/// +/// The repository for ModelGeneration data access. +/// The AutoMapper instance for object mapping. +public class ModelGenerationService(IRepository repository, IMapper mapper) + : BaseCrudService(repository, mapper); diff --git a/CarRental/CarRental.Application/Services/RentalService.cs b/CarRental/CarRental.Application/Services/RentalService.cs new file mode 100644 index 000000000..311baf70b --- /dev/null +++ b/CarRental/CarRental.Application/Services/RentalService.cs @@ -0,0 +1,15 @@ +using AutoMapper; +using CarRental.Application.Dtos.Rentals; +using CarRental.Domain.Models; +using CarRental.Infrastructure.Repositories.Interfaces; + +namespace CarRental.Application.Services; + +/// +/// Service for managing Rental entities. +/// Provides CRUD operations for Rentals using the underlying repository. +/// +/// The repository for Rental data access. +/// The AutoMapper instance for object mapping. +public class RentalService(IRepository repository, IMapper mapper) + : BaseCrudService(repository, mapper); diff --git a/CarRental/CarRental.Application/Validation/EnumRangeAttribute.cs b/CarRental/CarRental.Application/Validation/EnumRangeAttribute.cs new file mode 100644 index 000000000..652da56f9 --- /dev/null +++ b/CarRental/CarRental.Application/Validation/EnumRangeAttribute.cs @@ -0,0 +1,44 @@ +using System.ComponentModel.DataAnnotations; + +namespace CarRental.Application.Validation; + +/// +/// Validation attribute that ensures a value is a valid member of the specified enumeration type. +/// Works with both string and integer enum values, compatible with JsonStringEnumConverter. +/// +/// The type of the enumeration to validate against +public class EnumRangeAttribute(Type enumType) : ValidationAttribute +{ + /// + /// Validates that the specified value is a defined member of the enumeration. + /// Supports both string names and integer values of the enum. + /// + /// The value to validate + /// The context information about the validation operation + /// ValidationResult.Success if valid; otherwise, an error message + protected override ValidationResult? IsValid(object? value, ValidationContext validationContext) + { + if (value == null) + return ValidationResult.Success; + + if (value is string stringValue) + { + if (Enum.TryParse(enumType, stringValue, true, out var parsedEnum) && + Enum.IsDefined(enumType, parsedEnum)) + { + return ValidationResult.Success; + } + + var validValues = string.Join(", ", Enum.GetNames(enumType)); + return new ValidationResult($"The field {validationContext.DisplayName} must be one of: {validValues}. Received: '{stringValue}'"); + } + + if (!Enum.IsDefined(enumType, value)) + { + var validValues = string.Join(", ", Enum.GetNames(enumType)); + return new ValidationResult($"The field {validationContext.DisplayName} must be one of: {validValues}. Received: {value}"); + } + + return ValidationResult.Success; + } +} diff --git a/CarRental/CarRental.Consumer/CarRental.Consumer.csproj b/CarRental/CarRental.Consumer/CarRental.Consumer.csproj new file mode 100644 index 000000000..d8a755ddb --- /dev/null +++ b/CarRental/CarRental.Consumer/CarRental.Consumer.csproj @@ -0,0 +1,22 @@ + + + + net8.0 + enable + enable + + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + + + + + \ No newline at end of file diff --git a/CarRental/CarRental.Consumer/Program.cs b/CarRental/CarRental.Consumer/Program.cs new file mode 100644 index 000000000..669d52c8f --- /dev/null +++ b/CarRental/CarRental.Consumer/Program.cs @@ -0,0 +1,45 @@ +using CarRental.Application.Dtos.CarModels; +using CarRental.Application.Dtos.Cars; +using CarRental.Application.Dtos.Customers; +using CarRental.Application.Dtos.ModelGenerations; +using CarRental.Application.Dtos.Rentals; +using CarRental.Application.Profiles; +using CarRental.Application.Services; +using CarRental.Customer.Services; +using CarRental.Infrastructure.Persistence; +using CarRental.Infrastructure.Repositories; +using CarRental.Infrastructure.Repositories.Interfaces; +using CarRental.ServiceDefaults; +using Microsoft.EntityFrameworkCore; + +var builder = WebApplication.CreateBuilder(args); + +builder.AddServiceDefaults(); + +builder.Services.AddDbContext(options => +{ + var connectionString = builder.Configuration.GetConnectionString("carrentaldb") + ?? throw new InvalidOperationException("Connection string 'carrentaldb' not found."); + + options.UseSqlServer(connectionString); + options.UseLazyLoadingProxies(); +}); + +builder.Services.AddScoped(typeof(IRepository<>), typeof(Repository<>)); +builder.Services.AddScoped, CarModelService>(); +builder.Services.AddScoped, ModelGenerationService>(); +builder.Services.AddScoped, CarService>(); +builder.Services.AddScoped, CustomerService>(); +builder.Services.AddScoped, RentalService>(); + +builder.Services.AddAutoMapper(config => +{ + config.AddProfile(new MappingProfile()); +}); + +builder.Services.AddGrpc(); + +var app = builder.Build(); +app.MapDefaultEndpoints(); +app.MapGrpcService(); +app.Run(); \ No newline at end of file diff --git a/CarRental/CarRental.Consumer/Properties/launchSettings.json b/CarRental/CarRental.Consumer/Properties/launchSettings.json new file mode 100644 index 000000000..06a3bcd32 --- /dev/null +++ b/CarRental/CarRental.Consumer/Properties/launchSettings.json @@ -0,0 +1,23 @@ +{ + "$schema": "http://json.schemastore.org/launchsettings.json", + "profiles": { + "http": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": false, + "applicationUrl": "http://localhost:5026", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + }, + "https": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": false, + "applicationUrl": "https://localhost:7177;http://localhost:5026", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + } + } +} diff --git a/CarRental/CarRental.Consumer/Services/RequestStreamingService.cs b/CarRental/CarRental.Consumer/Services/RequestStreamingService.cs new file mode 100644 index 000000000..a3965bfad --- /dev/null +++ b/CarRental/CarRental.Consumer/Services/RequestStreamingService.cs @@ -0,0 +1,121 @@ +using CarRental.Application.Dtos.Cars; +using CarRental.Application.Dtos.Customers; +using CarRental.Application.Dtos.Grpc; +using CarRental.Application.Dtos.Rentals; +using CarRental.Application.Services; +using Grpc.Core; + +namespace CarRental.Customer.Services; + +/// +/// gRPC service for streaming car rental requests. +/// +public class RequestStreamingService( + ILogger logger, + IServiceScopeFactory scopeFactory) : RentalStreaming.RentalStreamingBase +{ + public override async Task StreamRentals( + IAsyncStreamReader requestStream, + IServerStreamWriter responseStream, + ServerCallContext context) + { + logger.LogInformation("Started bidirectional streaming from {Peer}", context.Peer); + + try + { + await foreach (var request in requestStream.ReadAllAsync(context.CancellationToken)) + { + logger.LogInformation( + "Received rental request: customer {CustomerId}, car {CarId}, pickup {Pickup}, hours {Hours}", + request.CustomerId, request.CarId, request.PickupDateTime.ToDateTime(), request.Hours); + + var (success, rentalId, errorMessage) = await ProcessRentalRequest(request); + + await responseStream.WriteAsync(new RentalResponseMessage + { + Success = success, + Message = success + ? $"Rental created successfully (ID: {rentalId}) for customer {request.CustomerId}" + : $"Failed to create rental: {errorMessage}", + RentalId = success ? rentalId : 0, + ErrorCode = success ? "" : "RENTAL_CREATION_FAILED" + }); + + logger.LogInformation("Sent response for customer {CustomerId} → success: {Success}", + request.CustomerId, success); + } + } + catch (OperationCanceledException) + { + logger.LogWarning("Streaming cancelled by client {Peer}", context.Peer); + } + catch (Exception ex) + { + logger.LogError(ex, "Unexpected error in streaming"); + throw; + } + + logger.LogInformation("Finished streaming rentals"); + } + + private async Task<(bool Success, long RentalId, string ErrorMessage)> ProcessRentalRequest(RentalRequestMessage request) + { + using var scope = scopeFactory.CreateScope(); + + try + { + var rentalService = scope.ServiceProvider.GetRequiredService>(); + var customerService = scope.ServiceProvider.GetRequiredService>(); + var carService = scope.ServiceProvider.GetRequiredService>(); + + var customer = await customerService.GetAsync(request.CustomerId); + if (customer == null) + { + logger.LogWarning("Customer not found: {CustomerId}", request.CustomerId); + return (false, 0, $"Customer {request.CustomerId} not found"); + } + + var car = await carService.GetAsync(request.CarId); + if (car == null) + { + logger.LogWarning("Car not found: {CarId}", request.CarId); + return (false, 0, $"Car {request.CarId} not found"); + } + + var pickupDateTime = request.PickupDateTime.ToDateTime(); + + if (pickupDateTime < DateTime.UtcNow) + { + logger.LogWarning("Pickup date is in the past: {PickupDateTime}", pickupDateTime); + return (false, 0, "Pickup date cannot be in the past"); + } + + if (request.Hours <= 0) + { + logger.LogWarning("Invalid rental duration: {Hours}", request.Hours); + return (false, 0, "Rental duration must be positive"); + } + + var createDto = new RentalCreateDto + { + CustomerId = request.CustomerId, + CarId = request.CarId, + PickupDateTime = pickupDateTime, + Hours = request.Hours + }; + + var createdRental = await rentalService.CreateAsync(createDto); + + logger.LogInformation( + "Rental created successfully: ID {RentalId}, customer {CustomerId}, car {CarId}", + createdRental?.Id, request.CustomerId, request.CarId); + + return (true, createdRental?.Id ?? 0, ""); + } + catch (Exception ex) + { + logger.LogError(ex, "Failed to process rental request for customer {CustomerId}", request.CustomerId); + return (false, 0, ex.Message); + } + } +} \ No newline at end of file diff --git a/CarRental/CarRental.Consumer/appsettings.Development.json b/CarRental/CarRental.Consumer/appsettings.Development.json new file mode 100644 index 000000000..b99b28356 --- /dev/null +++ b/CarRental/CarRental.Consumer/appsettings.Development.json @@ -0,0 +1,11 @@ +{ + "ConnectionStrings": { + "mysqldb": "Server=localhost;Port=3306;Database=realestate;User=root;Password=P@ssw0rd;" + }, + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + } +} \ No newline at end of file diff --git a/CarRental/CarRental.Consumer/appsettings.json b/CarRental/CarRental.Consumer/appsettings.json new file mode 100644 index 000000000..961ea5fe6 --- /dev/null +++ b/CarRental/CarRental.Consumer/appsettings.json @@ -0,0 +1,24 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + }, + "AllowedHosts": "*", + "Kestrel": { + "Endpoints": { + "Https": { + "Url": "https://localhost:7002", + "Protocols": "Http2" + }, + "Http": { + "Url": "http://localhost:5002", + "Protocols": "Http2" + } + } + }, + "ConnectionStrings": { + "mysqldb": "Server=localhost;Port=3306;Database=realestate;User=root;Password=P@ssw0rd;" + } +} \ No newline at end of file diff --git a/CarRental/CarRental.Domain/CarRental.Domain.csproj b/CarRental/CarRental.Domain/CarRental.Domain.csproj index fa71b7ae6..e1f6a46db 100644 --- a/CarRental/CarRental.Domain/CarRental.Domain.csproj +++ b/CarRental/CarRental.Domain/CarRental.Domain.csproj @@ -1,9 +1,11 @@  + Library net8.0 enable + True enable - + \ No newline at end of file diff --git a/CarRental/CarRental.Tests/DataFixture.cs b/CarRental/CarRental.Domain/Data/DataSeed.cs similarity index 87% rename from CarRental/CarRental.Tests/DataFixture.cs rename to CarRental/CarRental.Domain/Data/DataSeed.cs index ec4694faf..0947e52f6 100644 --- a/CarRental/CarRental.Tests/DataFixture.cs +++ b/CarRental/CarRental.Domain/Data/DataSeed.cs @@ -1,13 +1,17 @@ -using CarRental.Domain.Models; -using CarRental.Domain.Enums; +using CarRental.Domain.Enums; +using CarRental.Domain.Models; -namespace CarRental.Tests; +namespace CarRental.Domain.Data; /// -/// Provides sample data for car rental domain entities +/// Provides seed data for the Car Rental domain entities. +/// This class contains initial data for database seeding and testing purposes. /// -public class CarRentalDataFixture +public class DataSeed { + /// + /// Gets the list of car models for seeding. + /// public List CarModels { get; } = new() { new() { Id = 1, Name = "Toyota Camry", DriverType = DriverType.FrontWheelDrive, SeatingCapacity = 5, BodyType = BodyType.Sedan, CarClass = CarClass.Intermediate }, @@ -17,6 +21,9 @@ public class CarRentalDataFixture new() { Id = 5, Name = "Volkswagen Tiguan", DriverType = DriverType.AllWheelDrive, SeatingCapacity = 5, BodyType = BodyType.Crossover, CarClass = CarClass.Intermediate }, }; + /// + /// Gets the list of model generations for seeding. + /// public List ModelGenerations { get; } = new() { new() { Id = 1, CarModelId = 1, ProductionYear = 2023, EngineVolumeLiters = 2.5m, TransmissionType = TransmissionType.Automatic, HourlyRate = 1200 }, @@ -27,6 +34,9 @@ public class CarRentalDataFixture new() { Id = 6, CarModelId = 5, ProductionYear = 2021, EngineVolumeLiters = 2.0m, TransmissionType = TransmissionType.DualClutch, HourlyRate = 1400 }, }; + /// + /// Gets the list of cars for seeding. + /// public List Cars { get; } = new() { new() { Id = 1, LicensePlate = "А123ВС 777", Color = "Черный", ModelGenerationId = 1 }, @@ -38,6 +48,9 @@ public class CarRentalDataFixture new() { Id = 7, LicensePlate = "О890ЦВ 777", Color = "Черный", ModelGenerationId = 3 }, }; + /// + /// Gets the list of customers for seeding. + /// public List Customers { get; } = new() { new() { Id = 1, DriverLicenseNumber = "1234 567890", FullName = "Иванов Иван Иванович", DateOfBirth = new DateOnly(1985, 3, 12) }, @@ -48,6 +61,9 @@ public class CarRentalDataFixture new() { Id = 6, DriverLicenseNumber = "6789 012345", FullName = "Волкова Ольга Николаевна", DateOfBirth = new DateOnly(1995, 1, 8) }, }; + /// + /// Gets the list of rentals for seeding. + /// public List Rentals { get; } = new() { new() { Id = 1, CustomerId = 1, CarId = 1, PickupDateTime = new DateTime(2025, 10, 1, 9, 0, 0), Hours = 24 }, @@ -61,6 +77,6 @@ public class CarRentalDataFixture new() { Id = 9, CustomerId = 6, CarId = 2, PickupDateTime = new DateTime(2025, 10, 14, 13, 0, 0), Hours = 4 }, new() { Id = 10, CustomerId = 3, CarId = 1, PickupDateTime = new DateTime(2025, 9, 25, 10, 0, 0), Hours = 24 }, new() { Id = 11, CustomerId = 2, CarId = 3, PickupDateTime = new DateTime(2025, 10, 10, 8, 0, 0), Hours = 240 }, - new() { Id = 12, CustomerId = 5, CarId = 1, PickupDateTime = new DateTime(2025, 10, 15, 14, 0, 0), Hours = 36 } + new() { Id = 12, CustomerId = 5, CarId = 1, PickupDateTime = new DateTime(2025, 10, 15, 14, 0, 0), Hours = 36 }, }; } \ No newline at end of file diff --git a/CarRental/CarRental.Domain/Enums/BodyType.cs b/CarRental/CarRental.Domain/Enums/BodyType.cs index 382a6a91c..ffd283770 100644 --- a/CarRental/CarRental.Domain/Enums/BodyType.cs +++ b/CarRental/CarRental.Domain/Enums/BodyType.cs @@ -1,18 +1,57 @@ namespace CarRental.Domain.Enums; /// -/// Body style +/// Represents the body style or body type of a vehicle. /// public enum BodyType { + /// + /// Four-door passenger car with a separate trunk (saloon). + /// Sedan, + + /// + /// Compact car with a rear door that opens upwards, giving access to the cargo area. + /// Hatchback, + + /// + /// Similar to a sedan but with a fastback rear end and a hatch-like tailgate. + /// Liftback, + + /// + /// Two-door car with a fixed roof and a sporty design, usually with limited rear seating. + /// Coupe, + + /// + /// Car with a retractable or removable roof (soft-top or hard-top convertible). + /// Convertible, + + /// + /// Sport Utility Vehicle — taller vehicle with off-road capability and higher ground clearance. + /// SUV, + + /// + /// Crossover Utility Vehicle — combines features of a passenger car and an SUV (usually unibody construction). + /// Crossover, + + /// + /// Minivan — family-oriented vehicle with sliding doors and flexible seating arrangements. + /// Minivan, + + /// + /// Pickup truck — vehicle with an open cargo bed at the rear, often used for hauling. + /// Pickup, + + /// + /// Station wagon — passenger car with an extended roofline and a rear door that opens upwards (estate car). + /// StationWagon -} +} \ No newline at end of file diff --git a/CarRental/CarRental.Domain/Enums/CarClass.cs b/CarRental/CarRental.Domain/Enums/CarClass.cs index eb5ffda0d..9dac81d42 100644 --- a/CarRental/CarRental.Domain/Enums/CarClass.cs +++ b/CarRental/CarRental.Domain/Enums/CarClass.cs @@ -1,18 +1,57 @@ namespace CarRental.Domain.Enums; /// -/// Car class / rental category +/// Represents the rental category or car class, which typically affects pricing, features and insurance requirements. /// public enum CarClass { + /// + /// Economy class — the most affordable option, usually small compact cars with basic features and low fuel consumption. + /// Economy, + + /// + /// Compact class — slightly larger than economy, offering more interior space and better comfort while remaining economical. + /// Compact, + + /// + /// Intermediate class — mid-size vehicles providing a good balance between space, comfort and cost (often called "midsize"). + /// Intermediate, + + /// + /// Standard class — full-size sedans or similar vehicles with more legroom and trunk space than intermediate. + /// Standard, + + /// + /// Full-size class — large sedans or crossovers designed for maximum passenger and luggage capacity. + /// FullSize, + + /// + /// Luxury class — premium vehicles with high-end interior materials, advanced technology and superior comfort. + /// Luxury, + + /// + /// Premium class — top-tier luxury vehicles, often including executive sedans, high-performance models or ultra-luxury brands. + /// Premium, + + /// + /// SUV class — sport utility vehicles, usually offering higher seating position, more space and sometimes all-wheel drive. + /// SUV, + + /// + /// Minivan class — family-oriented vehicles with sliding doors, flexible seating configurations and large cargo capacity. + /// Minivan, + + /// + /// Sport class — performance-oriented vehicles with sporty handling, powerful engines and dynamic design. + /// Sport } \ No newline at end of file diff --git a/CarRental/CarRental.Domain/Enums/DriverType.cs b/CarRental/CarRental.Domain/Enums/DriverType.cs index acffdc4bd..ee2911577 100644 --- a/CarRental/CarRental.Domain/Enums/DriverType.cs +++ b/CarRental/CarRental.Domain/Enums/DriverType.cs @@ -1,12 +1,36 @@ namespace CarRental.Domain.Enums; /// -/// Drivetrain / drive type +/// Represents the drivetrain type (which wheels receive power from the engine). +/// This affects vehicle handling, traction, fuel efficiency and suitability for different conditions. /// public enum DriverType { + /// + /// Front-Wheel Drive (FWD) — power is delivered to the front wheels. + /// Common in most modern compact and mid-size cars due to good traction in wet/snowy conditions + /// and efficient use of interior space. + /// FrontWheelDrive, + + /// + /// Rear-Wheel Drive (RWD) — power is sent to the rear wheels. + /// Provides better handling balance and is preferred in sports cars, + /// rear-engine vehicles and many classic/luxury models. + /// RearWheelDrive, + + /// + /// All-Wheel Drive (AWD) — power is distributed to all four wheels permanently or on-demand. + /// Offers improved traction and stability, especially in adverse weather, + /// without the full complexity of traditional 4×4 systems. + /// AllWheelDrive, + + /// + /// Four-Wheel Drive (4WD / 4×4) — typically a more robust system with selectable modes + /// (2WD / 4WD high / 4WD low) and often a transfer case. + /// Designed primarily for off-road capability and heavy-duty use. + /// FourWheelDrive -} +} \ No newline at end of file diff --git a/CarRental/CarRental.Domain/Enums/TransmissionType.cs b/CarRental/CarRental.Domain/Enums/TransmissionType.cs index a8241c82a..043f1482a 100644 --- a/CarRental/CarRental.Domain/Enums/TransmissionType.cs +++ b/CarRental/CarRental.Domain/Enums/TransmissionType.cs @@ -1,13 +1,37 @@ namespace CarRental.Domain.Enums; /// -/// Transmission type +/// Represents the type of vehicle transmission (gearbox), which determines how power is transferred from the engine to the wheels. /// public enum TransmissionType { + /// + /// Manual transmission — requires the driver to manually shift gears using a clutch pedal and gear stick. + /// Offers more control and often better fuel efficiency, popular among enthusiasts. + /// Manual, + + /// + /// Automatic transmission — shifts gears automatically without driver input. + /// Provides convenience and comfort, especially in city driving; modern versions are very efficient. + /// Automatic, + + /// + /// Robotic transmission (automated manual / AMT) — a manual gearbox with computer-controlled clutch and gear shifts. + /// Usually cheaper than classic automatic, but can have noticeable shift pauses. + /// Robotic, + + /// + /// Continuously Variable Transmission (CVT) — uses a belt/pulley system instead of fixed gears. + /// Provides seamless acceleration without noticeable gear shifts, often very fuel-efficient. + /// CVT, + + /// + /// Dual-clutch transmission (DCT / DSG / PDK) — uses two separate clutches for odd and even gears. + /// Combines fast, smooth shifts like a manual with the convenience of an automatic; very popular in performance cars. + /// DualClutch } \ No newline at end of file diff --git a/CarRental/CarRental.Infrastructure/CarRental.Infrastructure.csproj b/CarRental/CarRental.Infrastructure/CarRental.Infrastructure.csproj new file mode 100644 index 000000000..d83da6617 --- /dev/null +++ b/CarRental/CarRental.Infrastructure/CarRental.Infrastructure.csproj @@ -0,0 +1,25 @@ + + + + net8.0 + enable + enable + false + false + True + + + + + + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + + + + + \ No newline at end of file diff --git a/CarRental/CarRental.Infrastructure/Data/EfDataSeeder.cs b/CarRental/CarRental.Infrastructure/Data/EfDataSeeder.cs new file mode 100644 index 000000000..83650b8c3 --- /dev/null +++ b/CarRental/CarRental.Infrastructure/Data/EfDataSeeder.cs @@ -0,0 +1,303 @@ +using CarRental.Infrastructure.Data.Interfaces; +using CarRental.Infrastructure.Persistence; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Logging; +using CarRental.Domain.Data; +using CarRental.Domain.Enums; +using CarRental.Domain.Models; + +namespace CarRental.Infrastructure.Data; + +/// +/// Entity Framework data seeder that uses predefined data from DataSeed class. +/// Provides methods for seeding, clearing and resetting database data for Car Rental application. +/// +public class EfDataSeeder(AppDbContext context, ILogger logger, DataSeed data) + : IDataSeeder +{ + /// + /// Seeds the database with initial test or development data. + /// Entities are seeded in dependency order: independent entities first. + /// Explicit Id values from seed data are ignored — database generates them automatically. + /// + public async Task SeedAsync() + { + logger.LogInformation("Starting database seeding..."); + + try + { + await SeedCarModelsAsync(); + await SeedModelGenerationsAsync(); + await SeedCarsAsync(); + await SeedCustomersAsync(); + await SeedRentalsAsync(); + + logger.LogInformation("Database seeding completed successfully"); + } + catch (Exception ex) + { + logger.LogError(ex, "An error occurred during database seeding"); + throw; + } + } + + /// + /// Removes all data from the database tables. + /// Entities are cleared in reverse dependency order to avoid foreign key violations. + /// + public async Task ClearAsync() + { + logger.LogInformation("Clearing all database data..."); + + try + { + await ClearRentalsAsync(); + await ClearCarsAsync(); + await ClearModelGenerationsAsync(); + await ClearCarModelsAsync(); + await ClearCustomersAsync(); + + logger.LogInformation("Database clearing completed successfully"); + } + catch (Exception ex) + { + logger.LogError(ex, "An error occurred during database clearing"); + throw; + } + } + + /// + /// Clears the database and then seeds it with initial data. + /// Useful for development, testing and demo scenarios. + /// + public async Task ResetAsync() + { + logger.LogInformation("Resetting database..."); + await ClearAsync(); + await SeedAsync(); + logger.LogInformation("Database reset completed successfully"); + } + + /// + /// Seeds CarModels table if it is empty, ignoring explicit Id values. + /// + private async Task SeedCarModelsAsync() + { + if (await context.CarModels.AnyAsync()) + { + logger.LogInformation("CarModels already exist, skipping seeding"); + return; + } + + logger.LogInformation("Seeding {Count} car models", data.CarModels.Count); + + var modelsToInsert = data.CarModels.Select(m => new CarModel + { + Name = m.Name, + DriverType = m.DriverType, + SeatingCapacity = m.SeatingCapacity, + BodyType = m.BodyType, + CarClass = m.CarClass + }).ToList(); + + context.CarModels.AddRange(modelsToInsert); + await context.SaveChangesAsync(); + + logger.LogInformation("CarModels seeded successfully"); + } + + /// + /// Seeds ModelGenerations table if it is empty, ignoring explicit Id values. + /// References to CarModel are preserved via original Id mapping. + /// + private async Task SeedModelGenerationsAsync() + { + if (await context.ModelGenerations.AnyAsync()) + { + logger.LogInformation("ModelGenerations already exist, skipping seeding"); + return; + } + + logger.LogInformation("Seeding {Count} model generations", data.ModelGenerations.Count); + + // Создаём словарь для сопоставления старого Id модели → новой (сгенерированной базой) + var carModelIdMap = (await context.CarModels.ToListAsync()) + .ToDictionary( + cm => data.CarModels.First(ds => ds.Name == cm.Name && ds.CarClass == cm.CarClass).Id, + cm => cm.Id); + + var generationsToInsert = data.ModelGenerations.Select(g => new ModelGeneration + { + CarModelId = carModelIdMap[g.CarModelId], + ProductionYear = g.ProductionYear, + EngineVolumeLiters = g.EngineVolumeLiters, + TransmissionType = g.TransmissionType, + HourlyRate = g.HourlyRate + }).ToList(); + + context.ModelGenerations.AddRange(generationsToInsert); + await context.SaveChangesAsync(); + + logger.LogInformation("ModelGenerations seeded successfully"); + } + + /// + /// Seeds Cars table if it is empty, ignoring explicit Id values. + /// References to ModelGeneration are preserved via mapping. + /// + private async Task SeedCarsAsync() + { + if (await context.Cars.AnyAsync()) + { + logger.LogInformation("Cars already exist, skipping seeding"); + return; + } + + logger.LogInformation("Seeding {Count} cars", data.Cars.Count); + + // Словарь старый Id поколения → новый + var generationIdMap = (await context.ModelGenerations.ToListAsync()) + .ToDictionary( + mg => data.ModelGenerations.First(ds => ds.ProductionYear == mg.ProductionYear && ds.HourlyRate == mg.HourlyRate).Id, + mg => mg.Id); + + var carsToInsert = data.Cars.Select(c => new Car + { + LicensePlate = c.LicensePlate, + Color = c.Color, + ModelGenerationId = generationIdMap[c.ModelGenerationId] + }).ToList(); + + context.Cars.AddRange(carsToInsert); + await context.SaveChangesAsync(); + + logger.LogInformation("Cars seeded successfully"); + } + + /// + /// Seeds Customers table if it is empty, ignoring explicit Id values. + /// + private async Task SeedCustomersAsync() + { + if (await context.Customers.AnyAsync()) + { + logger.LogInformation("Customers already exist, skipping seeding"); + return; + } + + logger.LogInformation("Seeding {Count} customers", data.Customers.Count); + + var customersToInsert = data.Customers.Select(c => new Customer + { + DriverLicenseNumber = c.DriverLicenseNumber, + FullName = c.FullName, + DateOfBirth = c.DateOfBirth + }).ToList(); + + context.Customers.AddRange(customersToInsert); + await context.SaveChangesAsync(); + + logger.LogInformation("Customers seeded successfully"); + } + + /// + /// Seeds Rentals table if it is empty, ignoring explicit Id values. + /// References to Customer and Car are preserved via mapping. + /// + private async Task SeedRentalsAsync() + { + if (await context.Rentals.AnyAsync()) + { + logger.LogInformation("Rentals already exist, skipping seeding"); + return; + } + + logger.LogInformation("Seeding {Count} rentals", data.Rentals.Count); + + // Словари для сопоставления + var customerIdMap = (await context.Customers.ToListAsync()) + .ToDictionary( + c => data.Customers.First(ds => ds.DriverLicenseNumber == c.DriverLicenseNumber).Id, + c => c.Id); + + var carIdMap = (await context.Cars.ToListAsync()) + .ToDictionary( + c => data.Cars.First(ds => ds.LicensePlate == c.LicensePlate).Id, + c => c.Id); + + var rentalsToInsert = data.Rentals.Select(r => new Rental + { + CustomerId = customerIdMap[r.CustomerId], + CarId = carIdMap[r.CarId], + PickupDateTime = r.PickupDateTime, + Hours = r.Hours + }).ToList(); + + context.Rentals.AddRange(rentalsToInsert); + await context.SaveChangesAsync(); + + logger.LogInformation("Rentals seeded successfully"); + } + + /// + /// Removes all records from the Rentals table. + /// + private async Task ClearRentalsAsync() + { + var count = await context.Rentals.ExecuteDeleteAsync(); + LogClearResult("rentals", count); + } + + /// + /// Removes all records from the Cars table. + /// + private async Task ClearCarsAsync() + { + var count = await context.Cars.ExecuteDeleteAsync(); + LogClearResult("cars", count); + } + + /// + /// Removes all records from the ModelGenerations table. + /// + private async Task ClearModelGenerationsAsync() + { + var count = await context.ModelGenerations.ExecuteDeleteAsync(); + LogClearResult("model generations", count); + } + + /// + /// Removes all records from the CarModels table. + /// + private async Task ClearCarModelsAsync() + { + var count = await context.CarModels.ExecuteDeleteAsync(); + LogClearResult("car models", count); + } + + /// + /// Removes all records from the Customers table. + /// + private async Task ClearCustomersAsync() + { + var count = await context.Customers.ExecuteDeleteAsync(); + LogClearResult("customers", count); + } + + /// + /// Logs the result of a table clearing operation. + /// + /// Plural name of the entity type that was cleared. + /// Number of records that were removed. + private void LogClearResult(string entityName, int count) + { + if (count > 0) + { + logger.LogInformation("Removed {Count} {EntityName}", count, entityName); + } + else + { + logger.LogInformation("No {EntityName} to remove", entityName); + } + } +} \ No newline at end of file diff --git a/CarRental/CarRental.Infrastructure/Data/Interfaces/IDataSeeder.cs b/CarRental/CarRental.Infrastructure/Data/Interfaces/IDataSeeder.cs new file mode 100644 index 000000000..d1b299fb6 --- /dev/null +++ b/CarRental/CarRental.Infrastructure/Data/Interfaces/IDataSeeder.cs @@ -0,0 +1,22 @@ +namespace CarRental.Infrastructure.Data.Interfaces; + +/// +/// Defines the contract for data seeding operations. +/// Provides methods for initializing and clearing data in the underlying data store. +/// +public interface IDataSeeder +{ + /// + /// Seeds the data store with initial test or development data. + /// Populates the database with predefined entities for application initialization. + /// + /// A task representing the asynchronous seeding operation. + public Task SeedAsync(); + + /// + /// Clears all data from the data store. + /// Removes all entities to prepare for fresh data initialization or testing scenarios. + /// + /// A task representing the asynchronous clearing operation. + public Task ClearAsync(); +} diff --git a/CarRental/CarRental.Infrastructure/Migrations/20260222093112_InitialCreate.Designer.cs b/CarRental/CarRental.Infrastructure/Migrations/20260222093112_InitialCreate.Designer.cs new file mode 100644 index 000000000..5b6730710 --- /dev/null +++ b/CarRental/CarRental.Infrastructure/Migrations/20260222093112_InitialCreate.Designer.cs @@ -0,0 +1,223 @@ +// +using System; +using CarRental.Infrastructure.Persistence; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Metadata; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; + +#nullable disable + +namespace CarRental.Infrastructure.Migrations +{ + [DbContext(typeof(AppDbContext))] + [Migration("20260222093112_InitialCreate")] + partial class InitialCreate + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "9.0.10") + .HasAnnotation("Proxies:ChangeTracking", false) + .HasAnnotation("Proxies:CheckEquality", false) + .HasAnnotation("Proxies:LazyLoading", true) + .HasAnnotation("Relational:MaxIdentifierLength", 128); + + SqlServerModelBuilderExtensions.UseIdentityColumns(modelBuilder); + + modelBuilder.Entity("CarRental.Domain.Models.Car", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("Color") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("nvarchar(50)"); + + b.Property("LicensePlate") + .IsRequired() + .HasMaxLength(12) + .HasColumnType("nvarchar(12)"); + + b.Property("ModelGenerationId") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("LicensePlate") + .IsUnique(); + + b.HasIndex("ModelGenerationId"); + + b.ToTable("Cars"); + }); + + modelBuilder.Entity("CarRental.Domain.Models.CarModel", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("BodyType") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("CarClass") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("DriverType") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("SeatingCapacity") + .HasColumnType("tinyint"); + + b.HasKey("Id"); + + b.ToTable("CarModels"); + }); + + modelBuilder.Entity("CarRental.Domain.Models.Customer", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("DateOfBirth") + .HasColumnType("date"); + + b.Property("DriverLicenseNumber") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("nvarchar(20)"); + + b.Property("FullName") + .IsRequired() + .HasMaxLength(150) + .HasColumnType("nvarchar(150)"); + + b.HasKey("Id"); + + b.HasIndex("DriverLicenseNumber") + .IsUnique(); + + b.ToTable("Customers"); + }); + + modelBuilder.Entity("CarRental.Domain.Models.ModelGeneration", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("CarModelId") + .HasColumnType("int"); + + b.Property("EngineVolumeLiters") + .HasPrecision(4, 1) + .HasColumnType("decimal(4,1)"); + + b.Property("HourlyRate") + .HasPrecision(10, 2) + .HasColumnType("decimal(10,2)"); + + b.Property("ProductionYear") + .HasColumnType("int"); + + b.Property("TransmissionType") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("CarModelId"); + + b.ToTable("ModelGenerations"); + }); + + modelBuilder.Entity("CarRental.Domain.Models.Rental", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("CarId") + .HasColumnType("int"); + + b.Property("CustomerId") + .HasColumnType("int"); + + b.Property("Hours") + .HasColumnType("int"); + + b.Property("PickupDateTime") + .HasColumnType("datetime2"); + + b.HasKey("Id"); + + b.HasIndex("PickupDateTime"); + + b.HasIndex("CarId", "PickupDateTime"); + + b.HasIndex("CustomerId", "PickupDateTime"); + + b.ToTable("Rentals"); + }); + + modelBuilder.Entity("CarRental.Domain.Models.Car", b => + { + b.HasOne("CarRental.Domain.Models.ModelGeneration", null) + .WithMany() + .HasForeignKey("ModelGenerationId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("CarRental.Domain.Models.ModelGeneration", b => + { + b.HasOne("CarRental.Domain.Models.CarModel", null) + .WithMany() + .HasForeignKey("CarModelId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("CarRental.Domain.Models.Rental", b => + { + b.HasOne("CarRental.Domain.Models.Car", null) + .WithMany() + .HasForeignKey("CarId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("CarRental.Domain.Models.Customer", null) + .WithMany() + .HasForeignKey("CustomerId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/CarRental/CarRental.Infrastructure/Migrations/20260222093112_InitialCreate.cs b/CarRental/CarRental.Infrastructure/Migrations/20260222093112_InitialCreate.cs new file mode 100644 index 000000000..c5520fadb --- /dev/null +++ b/CarRental/CarRental.Infrastructure/Migrations/20260222093112_InitialCreate.cs @@ -0,0 +1,173 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace CarRental.Infrastructure.Migrations; + +/// +public partial class InitialCreate : Migration +{ + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "CarModels", + columns: table => new + { + Id = table.Column(type: "int", nullable: false) + .Annotation("SqlServer:Identity", "1, 1"), + Name = table.Column(type: "nvarchar(100)", maxLength: 100, nullable: false), + DriverType = table.Column(type: "nvarchar(max)", nullable: false), + SeatingCapacity = table.Column(type: "tinyint", nullable: false), + BodyType = table.Column(type: "nvarchar(max)", nullable: false), + CarClass = table.Column(type: "nvarchar(max)", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_CarModels", x => x.Id); + }); + + migrationBuilder.CreateTable( + name: "Customers", + columns: table => new + { + Id = table.Column(type: "int", nullable: false) + .Annotation("SqlServer:Identity", "1, 1"), + DriverLicenseNumber = table.Column(type: "nvarchar(20)", maxLength: 20, nullable: false), + FullName = table.Column(type: "nvarchar(150)", maxLength: 150, nullable: false), + DateOfBirth = table.Column(type: "date", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_Customers", x => x.Id); + }); + + migrationBuilder.CreateTable( + name: "ModelGenerations", + columns: table => new + { + Id = table.Column(type: "int", nullable: false) + .Annotation("SqlServer:Identity", "1, 1"), + ProductionYear = table.Column(type: "int", nullable: false), + EngineVolumeLiters = table.Column(type: "decimal(4,1)", precision: 4, scale: 1, nullable: false), + TransmissionType = table.Column(type: "nvarchar(max)", nullable: false), + HourlyRate = table.Column(type: "decimal(10,2)", precision: 10, scale: 2, nullable: false), + CarModelId = table.Column(type: "int", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_ModelGenerations", x => x.Id); + table.ForeignKey( + name: "FK_ModelGenerations_CarModels_CarModelId", + column: x => x.CarModelId, + principalTable: "CarModels", + principalColumn: "Id", + onDelete: ReferentialAction.Restrict); + }); + + migrationBuilder.CreateTable( + name: "Cars", + columns: table => new + { + Id = table.Column(type: "int", nullable: false) + .Annotation("SqlServer:Identity", "1, 1"), + LicensePlate = table.Column(type: "nvarchar(12)", maxLength: 12, nullable: false), + Color = table.Column(type: "nvarchar(50)", maxLength: 50, nullable: false), + ModelGenerationId = table.Column(type: "int", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_Cars", x => x.Id); + table.ForeignKey( + name: "FK_Cars_ModelGenerations_ModelGenerationId", + column: x => x.ModelGenerationId, + principalTable: "ModelGenerations", + principalColumn: "Id", + onDelete: ReferentialAction.Restrict); + }); + + migrationBuilder.CreateTable( + name: "Rentals", + columns: table => new + { + Id = table.Column(type: "int", nullable: false) + .Annotation("SqlServer:Identity", "1, 1"), + CustomerId = table.Column(type: "int", nullable: false), + CarId = table.Column(type: "int", nullable: false), + PickupDateTime = table.Column(type: "datetime2", nullable: false), + Hours = table.Column(type: "int", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_Rentals", x => x.Id); + table.ForeignKey( + name: "FK_Rentals_Cars_CarId", + column: x => x.CarId, + principalTable: "Cars", + principalColumn: "Id", + onDelete: ReferentialAction.Restrict); + table.ForeignKey( + name: "FK_Rentals_Customers_CustomerId", + column: x => x.CustomerId, + principalTable: "Customers", + principalColumn: "Id", + onDelete: ReferentialAction.Restrict); + }); + + migrationBuilder.CreateIndex( + name: "IX_Cars_LicensePlate", + table: "Cars", + column: "LicensePlate", + unique: true); + + migrationBuilder.CreateIndex( + name: "IX_Cars_ModelGenerationId", + table: "Cars", + column: "ModelGenerationId"); + + migrationBuilder.CreateIndex( + name: "IX_Customers_DriverLicenseNumber", + table: "Customers", + column: "DriverLicenseNumber", + unique: true); + + migrationBuilder.CreateIndex( + name: "IX_ModelGenerations_CarModelId", + table: "ModelGenerations", + column: "CarModelId"); + + migrationBuilder.CreateIndex( + name: "IX_Rentals_CarId_PickupDateTime", + table: "Rentals", + columns: new[] { "CarId", "PickupDateTime" }); + + migrationBuilder.CreateIndex( + name: "IX_Rentals_CustomerId_PickupDateTime", + table: "Rentals", + columns: new[] { "CustomerId", "PickupDateTime" }); + + migrationBuilder.CreateIndex( + name: "IX_Rentals_PickupDateTime", + table: "Rentals", + column: "PickupDateTime"); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "Rentals"); + + migrationBuilder.DropTable( + name: "Cars"); + + migrationBuilder.DropTable( + name: "Customers"); + + migrationBuilder.DropTable( + name: "ModelGenerations"); + + migrationBuilder.DropTable( + name: "CarModels"); + } +} diff --git a/CarRental/CarRental.Infrastructure/Migrations/AppDbContextModelSnapshot.cs b/CarRental/CarRental.Infrastructure/Migrations/AppDbContextModelSnapshot.cs new file mode 100644 index 000000000..1a7794d9e --- /dev/null +++ b/CarRental/CarRental.Infrastructure/Migrations/AppDbContextModelSnapshot.cs @@ -0,0 +1,220 @@ +// +using System; +using CarRental.Infrastructure.Persistence; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Metadata; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; + +#nullable disable + +namespace CarRental.Infrastructure.Migrations +{ + [DbContext(typeof(AppDbContext))] + partial class AppDbContextModelSnapshot : ModelSnapshot + { + protected override void BuildModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "9.0.10") + .HasAnnotation("Proxies:ChangeTracking", false) + .HasAnnotation("Proxies:CheckEquality", false) + .HasAnnotation("Proxies:LazyLoading", true) + .HasAnnotation("Relational:MaxIdentifierLength", 128); + + SqlServerModelBuilderExtensions.UseIdentityColumns(modelBuilder); + + modelBuilder.Entity("CarRental.Domain.Models.Car", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("Color") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("nvarchar(50)"); + + b.Property("LicensePlate") + .IsRequired() + .HasMaxLength(12) + .HasColumnType("nvarchar(12)"); + + b.Property("ModelGenerationId") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("LicensePlate") + .IsUnique(); + + b.HasIndex("ModelGenerationId"); + + b.ToTable("Cars"); + }); + + modelBuilder.Entity("CarRental.Domain.Models.CarModel", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("BodyType") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("CarClass") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("DriverType") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("SeatingCapacity") + .HasColumnType("tinyint"); + + b.HasKey("Id"); + + b.ToTable("CarModels"); + }); + + modelBuilder.Entity("CarRental.Domain.Models.Customer", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("DateOfBirth") + .HasColumnType("date"); + + b.Property("DriverLicenseNumber") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("nvarchar(20)"); + + b.Property("FullName") + .IsRequired() + .HasMaxLength(150) + .HasColumnType("nvarchar(150)"); + + b.HasKey("Id"); + + b.HasIndex("DriverLicenseNumber") + .IsUnique(); + + b.ToTable("Customers"); + }); + + modelBuilder.Entity("CarRental.Domain.Models.ModelGeneration", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("CarModelId") + .HasColumnType("int"); + + b.Property("EngineVolumeLiters") + .HasPrecision(4, 1) + .HasColumnType("decimal(4,1)"); + + b.Property("HourlyRate") + .HasPrecision(10, 2) + .HasColumnType("decimal(10,2)"); + + b.Property("ProductionYear") + .HasColumnType("int"); + + b.Property("TransmissionType") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("CarModelId"); + + b.ToTable("ModelGenerations"); + }); + + modelBuilder.Entity("CarRental.Domain.Models.Rental", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("CarId") + .HasColumnType("int"); + + b.Property("CustomerId") + .HasColumnType("int"); + + b.Property("Hours") + .HasColumnType("int"); + + b.Property("PickupDateTime") + .HasColumnType("datetime2"); + + b.HasKey("Id"); + + b.HasIndex("PickupDateTime"); + + b.HasIndex("CarId", "PickupDateTime"); + + b.HasIndex("CustomerId", "PickupDateTime"); + + b.ToTable("Rentals"); + }); + + modelBuilder.Entity("CarRental.Domain.Models.Car", b => + { + b.HasOne("CarRental.Domain.Models.ModelGeneration", null) + .WithMany() + .HasForeignKey("ModelGenerationId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("CarRental.Domain.Models.ModelGeneration", b => + { + b.HasOne("CarRental.Domain.Models.CarModel", null) + .WithMany() + .HasForeignKey("CarModelId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("CarRental.Domain.Models.Rental", b => + { + b.HasOne("CarRental.Domain.Models.Car", null) + .WithMany() + .HasForeignKey("CarId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("CarRental.Domain.Models.Customer", null) + .WithMany() + .HasForeignKey("CustomerId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/CarRental/CarRental.Infrastructure/Persistence/AppDbContext.cs b/CarRental/CarRental.Infrastructure/Persistence/AppDbContext.cs new file mode 100644 index 000000000..7bd41fe52 --- /dev/null +++ b/CarRental/CarRental.Infrastructure/Persistence/AppDbContext.cs @@ -0,0 +1,130 @@ +using CarRental.Domain.Models; +using Microsoft.EntityFrameworkCore; + +namespace CarRental.Infrastructure.Persistence; + +/// +/// Entity Framework database context for the Car Rental application. +/// Represents a session with the database and provides access to entity sets. +/// +public class AppDbContext : DbContext +{ + /// + /// Initializes a new instance of the AppDbContext class. + /// + /// The options to be used by the DbContext. + public AppDbContext(DbContextOptions options) : base(options) { } + + /// + /// Gets or sets the Cars entity set. + /// + public DbSet Cars { get; set; } = null!; + + /// + /// Gets or sets the CarModels entity set. + /// + public DbSet CarModels { get; set; } = null!; + + /// + /// Gets or sets the ModelGenerations entity set. + /// + public DbSet ModelGenerations { get; set; } = null!; + + /// + /// Gets or sets the Customers entity set. + /// + public DbSet Customers { get; set; } = null!; + + /// + /// Gets or sets the Rentals entity set. + /// + public DbSet Rentals { get; set; } = null!; + + /// + /// Configures the model that was discovered by convention from the entity types + /// exposed in DbSet properties on the derived context. + /// + /// The builder being used to construct the model for this context. + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + base.OnModelCreating(modelBuilder); + + modelBuilder.Entity(entity => + { + entity.HasKey(e => e.Id); + entity.Property(e => e.Id).ValueGeneratedOnAdd(); + entity.Property(e => e.DriverLicenseNumber).IsRequired().HasMaxLength(20); + entity.Property(e => e.FullName).IsRequired().HasMaxLength(150); + entity.Property(e => e.DateOfBirth).IsRequired(); + + entity.HasIndex(e => e.DriverLicenseNumber).IsUnique(); + }); + + modelBuilder.Entity(entity => + { + entity.HasKey(e => e.Id); + entity.Property(e => e.Id).ValueGeneratedOnAdd(); + entity.Property(e => e.Name).IsRequired().HasMaxLength(100); + entity.Property(e => e.DriverType).IsRequired().HasConversion(); + entity.Property(e => e.SeatingCapacity).IsRequired(); + entity.Property(e => e.BodyType).IsRequired().HasConversion(); + entity.Property(e => e.CarClass).IsRequired().HasConversion(); + }); + + modelBuilder.Entity(entity => + { + entity.HasKey(e => e.Id); + entity.Property(e => e.Id).ValueGeneratedOnAdd(); + entity.Property(e => e.ProductionYear).IsRequired(); + entity.Property(e => e.EngineVolumeLiters).IsRequired().HasPrecision(4, 1); + entity.Property(e => e.TransmissionType).IsRequired().HasConversion(); + entity.Property(e => e.HourlyRate).IsRequired().HasPrecision(10, 2); + entity.Property(e => e.CarModelId).IsRequired(); + + entity.HasOne() + .WithMany() + .HasForeignKey(e => e.CarModelId) + .OnDelete(DeleteBehavior.Restrict); + }); + + modelBuilder.Entity(entity => + { + entity.HasKey(e => e.Id); + entity.Property(e => e.Id).ValueGeneratedOnAdd(); + entity.Property(e => e.LicensePlate).IsRequired().HasMaxLength(12); + entity.Property(e => e.Color).IsRequired().HasMaxLength(50); + entity.Property(e => e.ModelGenerationId).IsRequired(); + + entity.HasOne() + .WithMany() + .HasForeignKey(e => e.ModelGenerationId) + .OnDelete(DeleteBehavior.Restrict); + + entity.HasIndex(e => e.LicensePlate).IsUnique(); + }); + + modelBuilder.Entity(entity => + { + entity.HasKey(e => e.Id); + entity.Property(e => e.Id).ValueGeneratedOnAdd(); + entity.Property(e => e.CustomerId).IsRequired(); + entity.Property(e => e.CarId).IsRequired(); + entity.Property(e => e.PickupDateTime).IsRequired(); + entity.Property(e => e.Hours).IsRequired(); + + entity.HasOne() + .WithMany() + .HasForeignKey(e => e.CustomerId) + .OnDelete(DeleteBehavior.Restrict); + + entity.HasOne() + .WithMany() + .HasForeignKey(e => e.CarId) + .OnDelete(DeleteBehavior.Restrict); + + entity.HasIndex(e => e.PickupDateTime); + entity.HasIndex(e => new { e.CarId, e.PickupDateTime }); + entity.HasIndex(e => new { e.CustomerId, e.PickupDateTime }); + }); + } +} \ No newline at end of file diff --git a/CarRental/CarRental.Infrastructure/Repositories/Interfaces/IRepository.cs b/CarRental/CarRental.Infrastructure/Repositories/Interfaces/IRepository.cs new file mode 100644 index 000000000..10be55257 --- /dev/null +++ b/CarRental/CarRental.Infrastructure/Repositories/Interfaces/IRepository.cs @@ -0,0 +1,43 @@ +namespace CarRental.Infrastructure.Repositories.Interfaces; + +/// Generic repository interface for performing CRUD operations on domain +/// entities. +/// Provides basic create, read, update, and delete functionality for all entity types. +/// +/// The type of entity this repository works with, must inherit from Model. +public interface IRepository +{ + /// + /// Creates a new entity in the repository. + /// + /// The entity to create. + /// The ID of the newly created entity. + public Task CreateAsync(T entity); + + /// + /// Retrieves all entities from the repository. + /// + /// A collection of all entities. + public Task> GetAsync(); + + /// + /// Retrieves a specific entity by its unique identifier. + /// + /// The ID of the entity to retrieve. + /// The entity if found; otherwise, null. + public Task GetAsync(int id); + + /// + /// Updates an existing entity in the repository. + /// + /// The entity with updated data. + /// The updated entity if successful; otherwise, null. + public Task UpdateAsync(T entity); + + /// + /// Deletes an entity from the repository by its unique identifier. + /// + /// The ID of the entity to delete. + /// True if the entity was successfully deleted; otherwise, false. + public Task DeleteAsync(int id); +} \ No newline at end of file diff --git a/CarRental/CarRental.Infrastructure/Repositories/Repository.cs b/CarRental/CarRental.Infrastructure/Repositories/Repository.cs new file mode 100644 index 000000000..a3b352030 --- /dev/null +++ b/CarRental/CarRental.Infrastructure/Repositories/Repository.cs @@ -0,0 +1,72 @@ +using CarRental.Domain.Models.Abstract; +using CarRental.Infrastructure.Repositories.Interfaces; +using CarRental.Infrastructure.Persistence; +using Microsoft.EntityFrameworkCore; + +namespace CarRental.Infrastructure.Repositories; + +/// +/// Generic repository implementation using Entity Framework Core for data access. +/// Provides CRUD operations for domain entities with database persistence. +/// +/// The type of entity this repository works with, must inherit from Model. +public class Repository(AppDbContext context) : IRepository where T : Model +{ + + /// + /// Creates a new entity in the database. + /// + /// The entity to create. + /// The ID of the newly created entity. + public async Task CreateAsync(T entity) + { + context.Set().Add(entity); + await context.SaveChangesAsync(); + return entity.Id; + } + + /// + /// Retrieves all entities from the database. + /// + /// A collection of all entities. + public async Task> GetAsync() + { + return await context.Set().ToListAsync(); + } + + /// + /// Retrieves a specific entity by its unique identifier. + /// + /// The ID of the entity to retrieve. + /// The entity if found; otherwise, null. + public async Task GetAsync(int id) + { + return await context.Set().FindAsync(id); + } + + /// + /// Updates an existing entity in the database. + /// + /// The entity with updated data. + /// The updated entity if successful; otherwise, null. + public async Task UpdateAsync(T entity) + { + context.Entry(entity).State = EntityState.Modified; + await context.SaveChangesAsync(); + return entity; + } + + /// + /// Deletes an entity from the database by its unique identifier. + /// + /// The ID of the entity to delete. + /// True if the entity was successfully deleted; otherwise, false. + public async Task DeleteAsync(int id) + { + var entity = await GetAsync(id); + if (entity == null) return false; + context.Set().Remove(entity); + await context.SaveChangesAsync(); + return true; + } +} \ No newline at end of file diff --git a/CarRental/CarRental.Producer/CarRental.Producer.csproj b/CarRental/CarRental.Producer/CarRental.Producer.csproj new file mode 100644 index 000000000..2bfcb8ade --- /dev/null +++ b/CarRental/CarRental.Producer/CarRental.Producer.csproj @@ -0,0 +1,25 @@ + + + + net8.0 + enable + enable + + + + + + + + + + + + + + + + + + + diff --git a/CarRental/CarRental.Producer/Configurations/GeneratorOptions.cs b/CarRental/CarRental.Producer/Configurations/GeneratorOptions.cs new file mode 100644 index 000000000..73afbc0ab --- /dev/null +++ b/CarRental/CarRental.Producer/Configurations/GeneratorOptions.cs @@ -0,0 +1,21 @@ +namespace CarRental.Producer.Configurations; + +public class GeneratorOptions +{ + public int BatchSize { get; set; } = 5; + public int PayloadLimit { get; set; } = 20; + public int WaitTime { get; set; } = 3; + public int MaxRetries { get; set; } = 3; + public int RetryDelaySeconds { get; set; } = 5; + public int GrpcTimeoutSeconds { get; set; } = 30; + public DataOptions Data { get; set; } = new(); +} + +public class DataOptions +{ + public RangeOptions CustomerIdRange { get; set; } = new(1, 7); + public RangeOptions CarIdRange { get; set; } = new(1, 6); + public RangeOptions HoursRange { get; set; } = new(2, 168); +} + +public record RangeOptions(int Min, int Max); \ No newline at end of file diff --git a/CarRental/CarRental.Producer/Controllers/GeneratorController.cs b/CarRental/CarRental.Producer/Controllers/GeneratorController.cs new file mode 100644 index 000000000..b995c671f --- /dev/null +++ b/CarRental/CarRental.Producer/Controllers/GeneratorController.cs @@ -0,0 +1,39 @@ +using CarRental.Producer.Services; +using Microsoft.AspNetCore.Mvc; + +namespace CarRental.Producer.Controllers; +[ApiController] +[Route("api/[controller]")] +public class GeneratorController(RequestGeneratorService generatorService, + ILogger logger) : ControllerBase +{ + /// + /// Starts automatic generation according to settings + /// + [HttpPost("auto")] + public ActionResult StartAutoGeneration() + { + try + { + logger.LogInformation("Auto generation started"); + + _ = generatorService.GenerateAutomatically(); + + return Ok(new + { + success = true, + message = "Auto generation started", + timestamp = DateTime.UtcNow + }); + } + catch (Exception ex) + { + logger.LogError(ex, "Error starting auto generation"); + return StatusCode(500, new + { + success = false, + error = ex.Message + }); + } + } +} \ No newline at end of file diff --git a/CarRental/CarRental.Producer/Program.cs b/CarRental/CarRental.Producer/Program.cs new file mode 100644 index 000000000..8ecbaa89a --- /dev/null +++ b/CarRental/CarRental.Producer/Program.cs @@ -0,0 +1,45 @@ +using CarRental.Producer.Services; +using Grpc.Net.Client; +using CarRental.Application.Dtos.Grpc; + +var builder = WebApplication.CreateBuilder(args); + +builder.Services.AddControllers(); +builder.Services.AddEndpointsApiExplorer(); +builder.Services.AddSwaggerGen(); + + +builder.Services.AddSingleton(serviceProvider => +{ + var grpcServiceUrl = builder.Configuration["Grpc:ServiceUrl"] + ?? throw new InvalidOperationException("Grpc:ServiceUrl is not configured"); + var httpHandler = new HttpClientHandler + { + ServerCertificateCustomValidationCallback = + HttpClientHandler.DangerousAcceptAnyServerCertificateValidator + }; + + var channel = GrpcChannel.ForAddress(grpcServiceUrl, new GrpcChannelOptions + { + HttpHandler = httpHandler + }); + + return new RentalStreaming.RentalStreamingClient(channel); +}); + +builder.Services.AddSingleton(); + +var app = builder.Build(); +app.MapGet("/", () => Results.Redirect("/swagger")); + +if (app.Environment.IsDevelopment()) +{ + app.UseSwagger(); + app.UseSwaggerUI(); +} + +app.UseHttpsRedirection(); +app.UseAuthorization(); +app.MapControllers(); + +app.Run(); \ No newline at end of file diff --git a/CarRental/CarRental.Producer/Properties/launchSettings.json b/CarRental/CarRental.Producer/Properties/launchSettings.json new file mode 100644 index 000000000..2f1cf85a8 --- /dev/null +++ b/CarRental/CarRental.Producer/Properties/launchSettings.json @@ -0,0 +1,41 @@ +{ + "$schema": "http://json.schemastore.org/launchsettings.json", + "iisSettings": { + "windowsAuthentication": false, + "anonymousAuthentication": true, + "iisExpress": { + "applicationUrl": "http://localhost:8024", + "sslPort": 44348 + } + }, + "profiles": { + "http": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": true, + "launchUrl": "swagger", + "applicationUrl": "http://localhost:5085", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + }, + "https": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": true, + "launchUrl": "swagger", + "applicationUrl": "https://localhost:7257;http://localhost:5085", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + }, + "IIS Express": { + "commandName": "IISExpress", + "launchBrowser": true, + "launchUrl": "swagger", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + } + } +} \ No newline at end of file diff --git a/CarRental/CarRental.Producer/Services/RequestStreamingService.cs b/CarRental/CarRental.Producer/Services/RequestStreamingService.cs new file mode 100644 index 000000000..71d440bb2 --- /dev/null +++ b/CarRental/CarRental.Producer/Services/RequestStreamingService.cs @@ -0,0 +1,132 @@ +using Bogus; +using CarRental.Application.Dtos.Grpc; +using CarRental.Producer.Configurations; +using Google.Protobuf.WellKnownTypes; +using Grpc.Core; +using Microsoft.Extensions.Options; + +namespace CarRental.Producer.Services; + +/// +/// Service for generating fake car rentals and sending them via gRPC streaming. +/// +public class RequestGeneratorService( + ILogger logger, + RentalStreaming.RentalStreamingClient client, + IOptions options) +{ + private readonly GeneratorOptions _options = options.Value; + + /// + /// Starts automatic generation of rental requests. + /// Uses settings from configuration for batch size and timing. + /// + public async Task GenerateAutomatically(CancellationToken stoppingToken = default) + { + logger.LogInformation( + "Starting automatic rental generation: batchSize={BatchSize}, limit={Limit}, wait={Wait}s", + _options.BatchSize, _options.PayloadLimit, _options.WaitTime); + + var counter = 0; + + while (counter < _options.PayloadLimit && !stoppingToken.IsCancellationRequested) + { + var success = await GenerateAndSendRentals(_options.BatchSize, stoppingToken); + + if (success) + { + counter += _options.BatchSize; + logger.LogDebug("Sent batch of {BatchSize} rentals. Total: {Total}", _options.BatchSize, counter); + } + + await Task.Delay(_options.WaitTime * 1000, stoppingToken); + } + + logger.LogInformation("Automatic generation finished. Total sent: {Total}", counter); + } + + /// + /// Generates and sends a batch of rental requests via gRPC streaming. + /// Retries up to configured number of times if sending fails. + /// + private async Task GenerateAndSendRentals(int count, CancellationToken stoppingToken = default) + { + var retryCount = 0; + + while (retryCount < _options.MaxRetries && !stoppingToken.IsCancellationRequested) + { + try + { + var faker = new Faker(); + + using var call = client.StreamRentals( + deadline: DateTime.UtcNow.AddSeconds(_options.GrpcTimeoutSeconds), + cancellationToken: stoppingToken); + + for (var i = 0; i < count; i++) + { + var request = new RentalRequestMessage + { + CustomerId = faker.Random.Int( + _options.Data.CustomerIdRange.Min, + _options.Data.CustomerIdRange.Max), + + CarId = faker.Random.Int( + _options.Data.CarIdRange.Min, + _options.Data.CarIdRange.Max), + + PickupDateTime = Timestamp.FromDateTime( + faker.Date.Between( + DateTime.UtcNow.AddDays(-30), + DateTime.UtcNow.AddDays(90))), + + Hours = faker.Random.Int(2, 336), + }; + + await call.RequestStream.WriteAsync(request, stoppingToken); + + logger.LogDebug( + "Sent rental {Index} with customer {CustomerId}, car {CarId}, hours {Hours}", + i + 1, request.CustomerId, request.CarId, request.Hours); + } + + await call.RequestStream.CompleteAsync(); + + var responses = new List(); + + await foreach (var response in call.ResponseStream.ReadAllAsync(stoppingToken)) + { + responses.Add(response); + logger.LogDebug("Received response: {Success} - {Message}", response.Success, response.Message); + } + + logger.LogInformation("Successfully completed batch with {ResponseCount} responses", responses.Count); + + return true; + } + catch (Exception ex) + { + retryCount++; + + logger.LogWarning(ex, + "Failed to send batch (attempt {RetryCount}/{MaxRetries})", + retryCount, _options.MaxRetries); + + if (retryCount < _options.MaxRetries) + { + await Task.Delay(TimeSpan.FromSeconds(_options.RetryDelaySeconds), stoppingToken); + } + else + { + logger.LogError(ex, + "Failed to send batch after {MaxRetries} attempts", + _options.MaxRetries); + + return false; + } + } + } + + return false; + } +} \ No newline at end of file diff --git a/CarRental/CarRental.Producer/appsettings.Development.json b/CarRental/CarRental.Producer/appsettings.Development.json new file mode 100644 index 000000000..e549e082b --- /dev/null +++ b/CarRental/CarRental.Producer/appsettings.Development.json @@ -0,0 +1,33 @@ +{ + "Grpc": { + "ServiceUrl": "https://localhost:7002" + }, + "Generator": { + "BatchSize": 10, + "PayloadLimit": 100, + "WaitTime": 5, + "MaxRetries": 3, + "RetryDelaySeconds": 5, + "GrpcTimeoutSeconds": 45, + "Data": { + "CustomerIdRange": { + "Min": 1, + "Max": 5000 + }, + "CarIdRange": { + "Min": 100, + "Max": 2000 + }, + "HoursRange": { + "Min": 2, + "Max": 168 + } + } + }, + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + } +} \ No newline at end of file diff --git a/CarRental/CarRental.Producer/appsettings.json b/CarRental/CarRental.Producer/appsettings.json new file mode 100644 index 000000000..801d9d230 --- /dev/null +++ b/CarRental/CarRental.Producer/appsettings.json @@ -0,0 +1,14 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + }, + "AllowedHosts": "*", + "Kestrel": { + "EndpointDefaults": { + "Protocols": "Http2" + } + } +} \ No newline at end of file diff --git a/CarRental/CarRental.ServiceDefaults/CarRental.ServiceDefaults.csproj b/CarRental/CarRental.ServiceDefaults/CarRental.ServiceDefaults.csproj new file mode 100644 index 000000000..91d81dea1 --- /dev/null +++ b/CarRental/CarRental.ServiceDefaults/CarRental.ServiceDefaults.csproj @@ -0,0 +1,22 @@ + + + + net8.0 + enable + enable + true + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/CarRental/CarRental.ServiceDefaults/Extensions.cs b/CarRental/CarRental.ServiceDefaults/Extensions.cs new file mode 100644 index 000000000..eb445ee1e --- /dev/null +++ b/CarRental/CarRental.ServiceDefaults/Extensions.cs @@ -0,0 +1,96 @@ +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Diagnostics.HealthChecks; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Diagnostics.HealthChecks; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; +using OpenTelemetry; +using OpenTelemetry.Metrics; +using OpenTelemetry.Trace; + +namespace CarRental.ServiceDefaults; + +public static class Extensions +{ + private const string HealthEndpointPath = "/health"; + private const string AlivenessEndpointPath = "/alive"; + + public static TBuilder AddServiceDefaults(this TBuilder builder) where TBuilder : IHostApplicationBuilder + { + builder.ConfigureOpenTelemetry(); + + builder.AddDefaultHealthChecks(); + + builder.Services.AddServiceDiscovery(); + + builder.Services.ConfigureHttpClientDefaults(http => + { + http.AddStandardResilienceHandler(); + + http.AddServiceDiscovery(); + }); + + return builder; + } + + public static TBuilder ConfigureOpenTelemetry(this TBuilder builder) where TBuilder : IHostApplicationBuilder + { + builder.Logging.AddOpenTelemetry(logging => + { + logging.IncludeFormattedMessage = true; + logging.IncludeScopes = true; + }); + + builder.Services.AddOpenTelemetry() + .WithMetrics(metrics => + { + metrics.AddAspNetCoreInstrumentation() + .AddHttpClientInstrumentation() + .AddRuntimeInstrumentation(); + }) + .WithTracing(tracing => + { + tracing.AddSource(builder.Environment.ApplicationName) + .AddAspNetCoreInstrumentation(tracing => + tracing.Filter = context => + !context.Request.Path.StartsWithSegments(HealthEndpointPath) && + !context.Request.Path.StartsWithSegments(AlivenessEndpointPath)) + .AddHttpClientInstrumentation(); + }); + + builder.AddOpenTelemetryExporters(); + return builder; + } + + private static TBuilder AddOpenTelemetryExporters(this TBuilder builder) where TBuilder : IHostApplicationBuilder + { + if (!string.IsNullOrWhiteSpace(builder.Configuration["OTEL_EXPORTER_OTLP_ENDPOINT"])) + { + builder.Services.AddOpenTelemetry().UseOtlpExporter(); + } + + return builder; + } + + public static TBuilder AddDefaultHealthChecks(this TBuilder builder) where TBuilder : IHostApplicationBuilder + { + builder.Services.AddHealthChecks() + .AddCheck("self", () => HealthCheckResult.Healthy(), new[] { "live" }); + + return builder; + } + + public static WebApplication MapDefaultEndpoints(this WebApplication app) + { + if (app.Environment.IsDevelopment()) + { + app.MapHealthChecks(HealthEndpointPath); + app.MapHealthChecks(AlivenessEndpointPath, new HealthCheckOptions + { + Predicate = r => r.Tags.Contains("live") + }); + } + + return app; + } +} \ No newline at end of file diff --git a/CarRental/CarRental.Tests/LinqQueryTests.cs b/CarRental/CarRental.Tests/LinqQueryTests.cs index 29d2c4204..c1b3dbb40 100644 --- a/CarRental/CarRental.Tests/LinqQueryTests.cs +++ b/CarRental/CarRental.Tests/LinqQueryTests.cs @@ -1,9 +1,11 @@ -namespace CarRental.Tests; +using CarRental.Domain.Data; + +namespace CarRental.Tests; /// /// Tests for car rental functionalities. /// -public class LinqQueryTests(CarRentalDataFixture testData) : IClassFixture +public class LinqQueryTests(DataSeed testData) : IClassFixture { [Fact] public void GetCustomersByModel() diff --git a/CarRental/CarRental.sln b/CarRental/CarRental.sln index 666e49be5..c05a79539 100644 --- a/CarRental/CarRental.sln +++ b/CarRental/CarRental.sln @@ -5,7 +5,21 @@ VisualStudioVersion = 17.10.35122.118 MinimumVisualStudioVersion = 10.0.40219.1 Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "CarRental.Domain", "CarRental.Domain\CarRental.Domain.csproj", "{2EF714F9-3283-45EB-8C57-D6109531F46B}" EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "CarRental.Tests", "CarRental.Tests\CarRental.Tests.csproj", "{134839E5-014D-4CF4-825C-387251D4216C}" +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "CarRental.Tests", "CarRental.Tests\CarRental.Tests.csproj", "{134839E5-014D-4CF4-825C-387251D4216C}" +EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "CarRental.Infrastructure", "CarRental.Infrastructure\CarRental.Infrastructure.csproj", "{803FFBB6-2E50-4DED-9293-9CB2F5AFF604}" +EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "CarRental.Application", "CarRental.Application\CarRental.Application.csproj", "{B90162B5-FDF2-49A0-8E45-C5532EC86566}" +EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "CarRental.Api", "CarRental.Api\CarRental.Api.csproj", "{F26D43DB-ED75-44F3-9F9E-34023E8DC188}" +EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "CarRental.ServiceDefaults", "CarRental.ServiceDefaults\CarRental.ServiceDefaults.csproj", "{41066FB6-5571-4687-8AD0-DA7AFD7B1F2B}" +EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "CarRental.AppHost", "CarRental.AppHost\CarRental.AppHost.csproj", "{6912A061-4DC4-4A62-857A-EC4790CC9349}" +EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "CarRental.Producer", "CarRental.Producer\CarRental.Producer.csproj", "{53AA0612-1BD3-4ACC-9884-96C2D5551A2D}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "CarRental.Consumer", "CarRental.Consumer\CarRental.Consumer.csproj", "{A5F48A16-1704-45C8-8537-35373CA9868F}" EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution @@ -13,10 +27,6 @@ Global Release|Any CPU = Release|Any CPU EndGlobalSection GlobalSection(ProjectConfigurationPlatforms) = postSolution - {CD66114E-38DD-4497-A835-343983BA4262}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {CD66114E-38DD-4497-A835-343983BA4262}.Debug|Any CPU.Build.0 = Debug|Any CPU - {CD66114E-38DD-4497-A835-343983BA4262}.Release|Any CPU.ActiveCfg = Release|Any CPU - {CD66114E-38DD-4497-A835-343983BA4262}.Release|Any CPU.Build.0 = Release|Any CPU {2EF714F9-3283-45EB-8C57-D6109531F46B}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {2EF714F9-3283-45EB-8C57-D6109531F46B}.Debug|Any CPU.Build.0 = Debug|Any CPU {2EF714F9-3283-45EB-8C57-D6109531F46B}.Release|Any CPU.ActiveCfg = Release|Any CPU @@ -25,6 +35,34 @@ Global {134839E5-014D-4CF4-825C-387251D4216C}.Debug|Any CPU.Build.0 = Debug|Any CPU {134839E5-014D-4CF4-825C-387251D4216C}.Release|Any CPU.ActiveCfg = Release|Any CPU {134839E5-014D-4CF4-825C-387251D4216C}.Release|Any CPU.Build.0 = Release|Any CPU + {803FFBB6-2E50-4DED-9293-9CB2F5AFF604}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {803FFBB6-2E50-4DED-9293-9CB2F5AFF604}.Debug|Any CPU.Build.0 = Debug|Any CPU + {803FFBB6-2E50-4DED-9293-9CB2F5AFF604}.Release|Any CPU.ActiveCfg = Release|Any CPU + {803FFBB6-2E50-4DED-9293-9CB2F5AFF604}.Release|Any CPU.Build.0 = Release|Any CPU + {B90162B5-FDF2-49A0-8E45-C5532EC86566}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {B90162B5-FDF2-49A0-8E45-C5532EC86566}.Debug|Any CPU.Build.0 = Debug|Any CPU + {B90162B5-FDF2-49A0-8E45-C5532EC86566}.Release|Any CPU.ActiveCfg = Release|Any CPU + {B90162B5-FDF2-49A0-8E45-C5532EC86566}.Release|Any CPU.Build.0 = Release|Any CPU + {F26D43DB-ED75-44F3-9F9E-34023E8DC188}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {F26D43DB-ED75-44F3-9F9E-34023E8DC188}.Debug|Any CPU.Build.0 = Debug|Any CPU + {F26D43DB-ED75-44F3-9F9E-34023E8DC188}.Release|Any CPU.ActiveCfg = Release|Any CPU + {F26D43DB-ED75-44F3-9F9E-34023E8DC188}.Release|Any CPU.Build.0 = Release|Any CPU + {41066FB6-5571-4687-8AD0-DA7AFD7B1F2B}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {41066FB6-5571-4687-8AD0-DA7AFD7B1F2B}.Debug|Any CPU.Build.0 = Debug|Any CPU + {41066FB6-5571-4687-8AD0-DA7AFD7B1F2B}.Release|Any CPU.ActiveCfg = Release|Any CPU + {41066FB6-5571-4687-8AD0-DA7AFD7B1F2B}.Release|Any CPU.Build.0 = Release|Any CPU + {6912A061-4DC4-4A62-857A-EC4790CC9349}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {6912A061-4DC4-4A62-857A-EC4790CC9349}.Debug|Any CPU.Build.0 = Debug|Any CPU + {6912A061-4DC4-4A62-857A-EC4790CC9349}.Release|Any CPU.ActiveCfg = Release|Any CPU + {6912A061-4DC4-4A62-857A-EC4790CC9349}.Release|Any CPU.Build.0 = Release|Any CPU + {53AA0612-1BD3-4ACC-9884-96C2D5551A2D}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {53AA0612-1BD3-4ACC-9884-96C2D5551A2D}.Debug|Any CPU.Build.0 = Debug|Any CPU + {53AA0612-1BD3-4ACC-9884-96C2D5551A2D}.Release|Any CPU.ActiveCfg = Release|Any CPU + {53AA0612-1BD3-4ACC-9884-96C2D5551A2D}.Release|Any CPU.Build.0 = Release|Any CPU + {A5F48A16-1704-45C8-8537-35373CA9868F}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {A5F48A16-1704-45C8-8537-35373CA9868F}.Debug|Any CPU.Build.0 = Debug|Any CPU + {A5F48A16-1704-45C8-8537-35373CA9868F}.Release|Any CPU.ActiveCfg = Release|Any CPU + {A5F48A16-1704-45C8-8537-35373CA9868F}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE From 0714c07269049760b034c32280aefdbcd8e95bec Mon Sep 17 00:00:00 2001 From: bl0b3s Date: Sun, 22 Feb 2026 17:56:41 +0300 Subject: [PATCH 7/8] Update README.md --- README.md | 187 +++++++++++++++--------------------------------------- 1 file changed, 50 insertions(+), 137 deletions(-) diff --git a/README.md b/README.md index 76afcbfdd..1d535040e 100644 --- a/README.md +++ b/README.md @@ -1,137 +1,50 @@ -# Разработка корпоративных приложений -[Таблица с успеваемостью](https://docs.google.com/spreadsheets/d/1JD6aiOG6r7GrA79oJncjgUHWtfeW4g_YZ9ayNgxb_w0/edit?usp=sharing) - -## Задание -### Цель -Реализация проекта сервисно-ориентированного приложения. - -### Задачи -* Реализация объектно-ориентированной модели данных, -* Изучение реализации серверных приложений на базе WebAPI/OpenAPI, -* Изучение работы с брокерами сообщений, -* Изучение паттернов проектирования, -* Изучение работы со средствами оркестрации на примере .NET Aspire, -* Повторение основ работы с системами контроля версий, -* Unit-тестирование. - -### Лабораторные работы -
-1. «Классы» - Реализация объектной модели данных и unit-тестов -
-В рамках первой лабораторной работы необходимо подготовить структуру классов, описывающих предметную область, определяемую в задании. В каждом из заданий присутствует часть, связанная с обработкой данных, представленная в разделе «Unit-тесты». Данную часть необходимо реализовать в виде unit-тестов: подготовить тестовые данные, выполнить запрос с использованием LINQ, проверить результаты. - -Хранение данных на этом этапе допускается осуществлять в памяти в виде коллекций. -Необходимо включить **как минимум 10** экземпляров каждого класса в датасид. - -
-
-2. «Сервер» - Реализация серверного приложения с использованием REST API -
-Во второй лабораторной работе необходимо реализовать серверное приложение, которое должно: -- Осуществлять базовые CRUD-операции с реализованными в первой лабораторной сущностями -- Предоставлять результаты аналитических запросов (раздел «Unit-тесты» задания) - -Хранение данных на этом этапе допускается осуществлять в памяти в виде коллекций. -
-
-
-3. «ORM» - Реализация объектно-реляционной модели. Подключение к базе данных и настройка оркестрации -
-В третьей лабораторной работе хранение должно быть переделано c инмемори коллекций на базу данных. -Должны быть созданы миграции для создания таблиц в бд и их первоначального заполнения. -
-Также необходимо настроить оркестратор Aspire на запуск сервера и базы данных. -
-
-
-4. «Инфраструктура» - Реализация сервиса генерации данных и его интеграция с сервером -
-В четвертой лабораторной работе необходимо имплементировать сервис, который генерировал бы контракты. Контракты далее передаются в сервер и сохраняются в бд. -Сервис должен представлять из себя отдельное приложение без референсов к серверным проектам за исключением библиотеки с контрактами. -Отправка контрактов при помощи gRPC должна выполняться в потоковом виде. -При использовании брокеров сообщений, необходимо предусмотреть ретраи при подключении к брокеру. - -Также необходимо добавить в конфигурацию Aspire запуск генератора и (если того требует вариант) брокера сообщений. -
-
-
-5. «Клиент» - Интеграция клиентского приложения с оркестратором -
-В пятой лабораторной необходимо добавить в конфигурацию Aspire запуск клиентского приложения для написанного ранее сервера. Клиент создается в рамках курса "Веб разработка". -
-
- -## Задание. Общая часть -**Обязательно**: -* Реализация серверной части на [.NET 8](https://learn.microsoft.com/ru-ru/dotnet/core/whats-new/dotnet-8/overview). -* Реализация серверной части на [ASP.NET](https://dotnet.microsoft.com/ru-ru/apps/aspnet). -* Реализация unit-тестов с использованием [xUnit](https://xunit.net/?tabs=cs). -* Использование хранения данных в базе данных согласно варианту задания. -* Оркестрация проектов при помощи [.NET Aspire](https://learn.microsoft.com/ru-ru/dotnet/aspire/get-started/aspire-overview) -* Реализация сервиса генерации данных при помощи [Bogus](https://github.com/bchavez/Bogus) и его взаимодейсвие с сервером согласно варианту задания. -* Автоматизация тестирования на уровне репозитория через [GitHub Actions](https://docs.github.com/en/actions/learn-github-actions/understanding-github-actions). -* Создание минимальной документации к проекту: страница на GitHub с информацией о задании, скриншоты приложения и прочая информация. - -**Факультативно**: -* Реализация авторизации/аутентификации. -* Реализация atomic batch publishing/atomic batch consumption для брокеров, поддерживающих такой функционал. -* Реализация интеграционных тестов при помощи .NET Aspire. -* Реализация клиента на Blazor WASM. - -Внимательно прочитайте [дискуссии](https://github.com/itsecd/enterprise-development/discussions/1) о том, как работает автоматическое распределение на ревью. -Сразу корректно называйте свои pr, чтобы они попали на ревью нужному преподавателю. - -По итогу работы в семестре должна получиться следующая информационная система: -
-C4 диаграмма - -image1 - -
- -## Варианты заданий -Номер варианта задания присваивается в начале семестра. Изменить его нельзя. Каждый вариант имеет уникальную комбинацию из предметной области, базы данных и технологии для общения сервиса генерации данных и сервера апи. - -[Список вариантов](https://docs.google.com/document/d/1Wc8AvsKS_1JptpsxHO-cwfAxz2ghxvQRQ0fy4el2ZOc/edit?usp=sharing) -[Список предметных областей](https://docs.google.com/document/d/15jWhXMwd2K8giFMKku_yrY_s2uQNEu4ugJXLYPvYJAE/edit?usp=sharing) -[Вопросы к экзамену](https://docs.google.com/document/d/1bjfvtzjyMljJbcu8YCvC8DzDegDUAmDeNtBz9M6FQes/edit?usp=sharing) - -## Схема сдачи - -На каждую из лабораторных работ необходимо сделать отдельный [Pull Request (PR)](https://docs.github.com/en/pull-requests). - -Общая схема: -1. Сделать форк данного репозитория -2. Выполнить задание -3. Сделать PR в данный репозиторий -4. Исправить замечания после code review -5. Получить approve -6. Прийти на занятие и защитить работу - -## Критерии оценивания - -Конкурентный принцип. -Так как задания в первой лабораторной будут повторяться между студентами, то выделяются следующие показатели для оценки: -1. Скорость разработки -2. Качество разработки -3. Полнота выполнения задания - -Быстрее делаете PR - у вас преимущество. -Быстрее получаете Approve - у вас преимущество. -Выполните нечто немного выходящее за рамки проекта - у вас преимущество. - -### Шкала оценивания - -- **3 балла** за качество кода, из них: - - 2 балла - базовая оценка - - 1 балл (но не более) можно получить за выполнение любого из следующих пунктов: - - Реализация факультативного функционала - - Выполнение работы раньше других: первые 5 человек из каждой группы, которые сделали PR и получили approve, получают дополнительный балл -- **3 балла** за защиту: при сдаче лабораторной работы вам задается 3 вопроса, за каждый правильный ответ - 1 балл - -У вас 2 попытки пройти ревью (первичное ревью, ревью по результатам исправления). Если замечания по итогу не исправлены, то снимается один балл за код лабораторной работы. - -## Вопросы и обратная связь по курсу - -Чтобы задать вопрос по лабораторной, воспользуйтесь [соотвествующим разделом дискуссий](https://github.com/itsecd/enterprise-development/discussions/categories/questions) или заведите [ишью](https://github.com/itsecd/enterprise-development/issues/new). -Если у вас появились идеи/пожелания/прочие полезные мысли по преподаваемой дисциплине, их можно оставить [здесь](https://github.com/itsecd/enterprise-development/discussions/categories/ideas). +# Лабораторная работа 4: Генератор контрактов и обмен сообщениями через gRPC + + +## Вариант 80 + +* **Platform**: .NET 8 (C# 12) +* **Database**: SqlServer +* **ORM**: Entity Framework Core 8 +* **Messaging**: gRPC +* **Mapping**: AutoMapper +* **Testing**: xUnit, Bogus (Fake Data) +* **Orchestration**: .NET Aspire + + +## Описание предметной области + +Реализована объектная модель для пункта проката автомобилей со следующими сущностями: + +- **`CarModel`** – модель автомобиля (справочник): тип привода, класс, тип кузова, количество мест. +- **`ModelGeneration`** – поколение модели: год выпуска, объём двигателя, коробка передач, стоимость часа аренды. +- **`Car`** – физический экземпляр автомобиля: госномер, цвет, поколение модели. +- **`Customer`** – клиент: номер водительского удостоверения, ФИО, дата рождения. +- **`Rental`** – договор аренды: клиент, автомобиль, время выдачи, длительность в часах. + +## Реализованные компоненты + +### CarRental.Producer + +Сервис-генератор контрактов аренды с REST API: + +- **`RequestStreamingService`** — генерирует тестовые DTO записей об аренде с использованием библиотеки Bogus. отправляет пачки контрактов через протокол gRPC +- **`GeneratorController`** — REST-контроллер для запуска генерации через HTTP-запрос: + +### CarRental.Consumer + +Слой для приема сообщений от Producer: + +- **`RequestStreamingService`** — сервис который: + - Обрабатывает входящие сообщения с контрактами + - Валидирует связи с существующими `Car` и `Client` + - Сохраняет валидные записи об аренде в базу данных + + +## Результат 4 лабораторной работы + +- Реализован сервис-генератор контрактов (CarRental.Producer), который создаёт тестовые записи об аренде и отправляет их через gRPC Server Streaming. +- Реализован gRPC-клиент (CarRental.Consumer), который получает контракты из потока и сохраняет их в базу данных с валидацией связей. +- Создан proto-контракт взаимодействия в слое Application. +- В конфигурацию Aspire добавлен запуск Producer и Consumer с автоматическим обнаружением адресов. +- Все компоненты запускаются через единый оркестратор с автоматическим управлением зависимостями \ No newline at end of file From 2c4f8bd249043a4495709d69aaa8d79dc5da03e0 Mon Sep 17 00:00:00 2001 From: bl0b3s Date: Sun, 22 Feb 2026 17:57:20 +0300 Subject: [PATCH 8/8] Update README.md --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 1d535040e..aae72f1d4 100644 --- a/README.md +++ b/README.md @@ -1,7 +1,7 @@ # Лабораторная работа 4: Генератор контрактов и обмен сообщениями через gRPC -## Вариант 80 +## Вариант 12 * **Platform**: .NET 8 (C# 12) * **Database**: SqlServer