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.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 new file mode 100644 index 000000000..e1f6a46db --- /dev/null +++ b/CarRental/CarRental.Domain/CarRental.Domain.csproj @@ -0,0 +1,11 @@ + + + + Library + net8.0 + enable + True + enable + + + \ No newline at end of file diff --git a/CarRental/CarRental.Domain/Data/DataSeed.cs b/CarRental/CarRental.Domain/Data/DataSeed.cs new file mode 100644 index 000000000..0947e52f6 --- /dev/null +++ b/CarRental/CarRental.Domain/Data/DataSeed.cs @@ -0,0 +1,82 @@ +using CarRental.Domain.Enums; +using CarRental.Domain.Models; + +namespace CarRental.Domain.Data; + +/// +/// Provides seed data for the Car Rental domain entities. +/// This class contains initial data for database seeding and testing purposes. +/// +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 }, + 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 }, + }; + + /// + /// 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 }, + 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 }, + }; + + /// + /// Gets the list of cars for seeding. + /// + 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 }, + }; + + /// + /// 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) }, + 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) }, + }; + + /// + /// 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 }, + 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.Domain/Enums/BodyType.cs b/CarRental/CarRental.Domain/Enums/BodyType.cs new file mode 100644 index 000000000..ffd283770 --- /dev/null +++ b/CarRental/CarRental.Domain/Enums/BodyType.cs @@ -0,0 +1,57 @@ +namespace CarRental.Domain.Enums; + +/// +/// 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 new file mode 100644 index 000000000..9dac81d42 --- /dev/null +++ b/CarRental/CarRental.Domain/Enums/CarClass.cs @@ -0,0 +1,57 @@ +namespace CarRental.Domain.Enums; + +/// +/// 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 new file mode 100644 index 000000000..ee2911577 --- /dev/null +++ b/CarRental/CarRental.Domain/Enums/DriverType.cs @@ -0,0 +1,36 @@ +namespace CarRental.Domain.Enums; + +/// +/// 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 new file mode 100644 index 000000000..043f1482a --- /dev/null +++ b/CarRental/CarRental.Domain/Enums/TransmissionType.cs @@ -0,0 +1,37 @@ +namespace CarRental.Domain.Enums; + +/// +/// 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.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/CarModel.cs b/CarRental/CarRental.Domain/Models/CarModel.cs new file mode 100644 index 000000000..dfa895622 --- /dev/null +++ b/CarRental/CarRental.Domain/Models/CarModel.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 DriverType { 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.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/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/LinqQueryTests.cs b/CarRental/CarRental.Tests/LinqQueryTests.cs new file mode 100644 index 000000000..c1b3dbb40 --- /dev/null +++ b/CarRental/CarRental.Tests/LinqQueryTests.cs @@ -0,0 +1,164 @@ +using CarRental.Domain.Data; + +namespace CarRental.Tests; + +/// +/// Tests for car rental functionalities. +/// +public class LinqQueryTests(DataSeed testData) : IClassFixture +{ + [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 { r.CustomerId, c.ModelGenerationId }) + .Where(x => testData.ModelGenerations + .Any(mg => mg.Id == x.ModelGenerationId && mg.CarModelId == targetModelId)) + .Select(x => x.CustomerId) + .Distinct() + .Join(testData.Customers, + cid => cid, + c => c.Id, + (_, c) => c.FullName) + .OrderBy(name => name) + .ToList(); + + Assert.Equal(expectedFullNames, actualFullNames); + } + + [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) + .Distinct() + .OrderBy(plate => plate) + .ToList(); + + Assert.Equal(expectedPlates, actualPlates); + } + + [Fact] + public void GetTop5MostRentedCars() + { + var expectedPlates = new List + { + "А123ВС 777", + "Е789КХ 777", + "В456ОР 777", + "К001МР 777", + "Н567УХ 777" + }; + + var actualPlates = testData.Rentals + .GroupBy(r => r.CarId) + .Select(g => new + { + CarId = g.Key, + Count = g.Count() + }) + .OrderByDescending(x => x.Count) + .ThenBy(x => testData.Cars.First(c => c.Id == x.CarId).LicensePlate) + .Take(5) + .Join(testData.Cars, + x => x.CarId, + c => c.Id, + (x, c) => c.LicensePlate) + .ToList(); + + Assert.Equal(expectedPlates, actualPlates); + } + + [Fact] + public void GetRentalCountForEachCar() + { + var expected = new Dictionary + { + { "А123ВС 777", 5 }, + { "В456ОР 777", 1 }, + { "Е789КХ 777", 3 }, + { "К001МР 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); + } + + [Fact] + public void GetTop5CustomersByTotalCost() + { + var expectedFullNames = new List + { + "Петрова Анна Сергеевна", + "Иванов Иван Иванович", + "Кузнецова Мария Дмитриевна", + "Смирнов Дмитрий Александрович", + "Сидоров Алексей Петрович" + }; + + var actualFullNames = testData.Rentals + .Join(testData.Cars, + r => r.CarId, + c => c.Id, + (r, c) => new { r.CustomerId, c.ModelGenerationId, r.Hours }) + .Join(testData.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 => testData.Customers.First(c => c.Id == x.CustomerId).FullName) + .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..c05a79539 --- /dev/null +++ b/CarRental/CarRental.sln @@ -0,0 +1,73 @@ + +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("{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 + Debug|Any CPU = Debug|Any CPU + Release|Any CPU = Release|Any CPU + EndGlobalSection + GlobalSection(ProjectConfigurationPlatforms) = postSolution + {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 + {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 + EndGlobalSection + GlobalSection(ExtensibilityGlobals) = postSolution + SolutionGuid = {311FD1FC-B518-4A98-ADF2-DCD3E2A70164} + EndGlobalSection +EndGlobal diff --git a/README.md b/README.md index 76afcbfdd..aae72f1d4 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 + + +## Вариант 12 + +* **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