diff --git a/.github/workflows/dotnet_tests.yml b/.github/workflows/dotnet_tests.yml new file mode 100644 index 000000000..8a6b671ce --- /dev/null +++ b/.github/workflows/dotnet_tests.yml @@ -0,0 +1,31 @@ +name: .NET Tests + +on: + push: + branches: ["main", "master"] + pull_request: + branches: ["main", "master"] + pull_request_target: + branches: ["main", "master"] + +jobs: + test: + runs-on: windows-latest + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Setup .NET + uses: actions/setup-dotnet@v4 + with: + dotnet-version: '8.x' + + - name: Restore dependencies + run: dotnet restore Bikes.sln + + - name: Build + run: dotnet build Bikes.sln --no-restore --configuration Release + + - name: Run tests + run: dotnet test Bikes.Tests/Bikes.Tests.csproj --no-build --configuration Release --verbosity normal \ No newline at end of file diff --git a/Bikes.Api.Host/Bikes.Api.Host.csproj b/Bikes.Api.Host/Bikes.Api.Host.csproj new file mode 100644 index 000000000..332a054c9 --- /dev/null +++ b/Bikes.Api.Host/Bikes.Api.Host.csproj @@ -0,0 +1,22 @@ + + + net8.0 + enable + enable + + + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + + + + + + + + + \ No newline at end of file diff --git a/Bikes.Api.Host/Bikes.Api.Host.http b/Bikes.Api.Host/Bikes.Api.Host.http new file mode 100644 index 000000000..00666aa4a --- /dev/null +++ b/Bikes.Api.Host/Bikes.Api.Host.http @@ -0,0 +1,6 @@ +@Bikes.Api.Host_HostAddress = http://localhost:5145 + +GET {{Bikes.Api.Host_HostAddress}}/weatherforecast/ +Accept: application/json + +### diff --git a/Bikes.Api.Host/Controllers/AnalyticsController.cs b/Bikes.Api.Host/Controllers/AnalyticsController.cs new file mode 100644 index 000000000..aabff1312 --- /dev/null +++ b/Bikes.Api.Host/Controllers/AnalyticsController.cs @@ -0,0 +1,127 @@ +using Bikes.Application.Contracts.Analytics; +using Bikes.Application.Contracts.Bikes; +using Microsoft.AspNetCore.Mvc; + +namespace Bikes.Api.Host.Controllers; + +/// +/// Controller for rental analytics +/// +[ApiController] +[Route("api/[controller]")] +public class AnalyticsController(IAnalyticsService analyticsService) : ControllerBase +{ + /// + /// Get all sport bikes + /// + [HttpGet("sport-bikes")] + [ProducesResponseType(typeof(List), StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status500InternalServerError)] + public ActionResult> GetSportBikes() + { + try + { + var sportBikes = analyticsService.GetSportBikes(); + return Ok(sportBikes); + } + catch (Exception ex) + { + return StatusCode(500, $"Internal server error: {ex.Message}"); + } + } + + /// + /// Get top 5 models by profit + /// + [HttpGet("top-models-by-profit")] + [ProducesResponseType(typeof(List), StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status500InternalServerError)] + public ActionResult> GetTopModelsByProfit() + { + try + { + var topModels = analyticsService.GetTop5ModelsByProfit(); + return Ok(topModels); + } + catch (Exception ex) + { + return StatusCode(500, $"Internal server error: {ex.Message}"); + } + } + + /// + /// Get top 5 models by rental duration + /// + [HttpGet("top-models-by-duration")] + [ProducesResponseType(typeof(List), StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status500InternalServerError)] + public ActionResult> GetTopModelsByDuration() + { + try + { + var topModels = analyticsService.GetTop5ModelsByRentalDuration(); + return Ok(topModels); + } + catch (Exception ex) + { + return StatusCode(500, $"Internal server error: {ex.Message}"); + } + } + + /// + /// Get rental statistics + /// + [HttpGet("rental-statistics")] + [ProducesResponseType(typeof(RentalStatistics), StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status500InternalServerError)] + public ActionResult GetRentalStatistics() + { + try + { + var statistics = analyticsService.GetRentalStatistics(); + return Ok(statistics); + } + catch (Exception ex) + { + return StatusCode(500, $"Internal server error: {ex.Message}"); + } + } + + /// + /// Get total rental time by bike type + /// + [HttpGet("rental-time-by-type")] + [ProducesResponseType(typeof(Dictionary), StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status500InternalServerError)] + public ActionResult> GetRentalTimeByType() + { + try + { + var rentalTime = analyticsService.GetTotalRentalTimeByBikeType(); + return Ok(rentalTime); + } + catch (Exception ex) + { + return StatusCode(500, $"Internal server error: {ex.Message}"); + } + } + + /// + /// Get top renters by rental count + /// + [HttpGet("top-renters")] + [ProducesResponseType(typeof(List), StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status500InternalServerError)] + public ActionResult> GetTopRenters() + { + try + { + var topRenters = analyticsService.GetTopRentersByRentalCount(); + return Ok(topRenters); + } + catch (Exception ex) + { + return StatusCode(500, $"Internal server error: {ex.Message}"); + } + } +} \ No newline at end of file diff --git a/Bikes.Api.Host/Controllers/BikeModelsController.cs b/Bikes.Api.Host/Controllers/BikeModelsController.cs new file mode 100644 index 000000000..167b7604d --- /dev/null +++ b/Bikes.Api.Host/Controllers/BikeModelsController.cs @@ -0,0 +1,16 @@ +using Bikes.Application.Contracts; +using Bikes.Application.Contracts.Models; +using Microsoft.AspNetCore.Mvc; + +namespace Bikes.Api.Host.Controllers; + +/// +/// Controller for bike model management +/// +[ApiController] +[Route("api/[controller]")] +public class BikeModelsController(IBikeModelService bikeModelService) + : CrudControllerBase +{ + protected override IApplicationService Service => bikeModelService; +} \ No newline at end of file diff --git a/Bikes.Api.Host/Controllers/BikesController.cs b/Bikes.Api.Host/Controllers/BikesController.cs new file mode 100644 index 000000000..c2f349706 --- /dev/null +++ b/Bikes.Api.Host/Controllers/BikesController.cs @@ -0,0 +1,16 @@ +using Bikes.Application.Contracts; +using Bikes.Application.Contracts.Bikes; +using Microsoft.AspNetCore.Mvc; + +namespace Bikes.Api.Host.Controllers; + +/// +/// Controller for bike management +/// +[ApiController] +[Route("api/[controller]")] +public class BikesController(IBikeService bikeService) + : CrudControllerBase +{ + protected override IApplicationService Service => bikeService; +} \ No newline at end of file diff --git a/Bikes.Api.Host/Controllers/CrudControllerBase.cs b/Bikes.Api.Host/Controllers/CrudControllerBase.cs new file mode 100644 index 000000000..28a402df4 --- /dev/null +++ b/Bikes.Api.Host/Controllers/CrudControllerBase.cs @@ -0,0 +1,145 @@ +using Bikes.Application.Contracts; +using Microsoft.AspNetCore.Mvc; + +namespace Bikes.Api.Host.Controllers; + +/// +/// Base controller for CRUD operations +/// +/// DTO type +/// Create/Update DTO type +[ApiController] +[Route("api/[controller]")] +public abstract class CrudControllerBase : ControllerBase + where TDto : class + where TCreateUpdateDto : class +{ + protected abstract IApplicationService Service { get; } + + /// + /// Get all entities + /// + [HttpGet] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status500InternalServerError)] + public virtual ActionResult> GetAll() + { + try + { + var entities = Service.GetAll(); + return Ok(entities); + } + catch (Exception ex) + { + return StatusCode(500, $"Internal server error: {ex.Message}"); + } + } + + /// + /// Get entity by id + /// + [HttpGet("{id}")] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + [ProducesResponseType(StatusCodes.Status500InternalServerError)] + public virtual ActionResult GetById(int id) + { + try + { + var entity = Service.GetById(id); + return entity == null ? NotFound() : Ok(entity); + } + catch (Exception ex) + { + return StatusCode(500, $"Internal server error: {ex.Message}"); + } + } + + /// + /// Create new entity + /// + [HttpPost] + [ProducesResponseType(StatusCodes.Status201Created)] + [ProducesResponseType(StatusCodes.Status400BadRequest)] + [ProducesResponseType(StatusCodes.Status500InternalServerError)] + public virtual ActionResult Create([FromBody] TCreateUpdateDto request) + { + try + { + if (!ModelState.IsValid) + { + return BadRequest(ModelState); + } + + var entity = Service.Create(request); + return CreatedAtAction(nameof(GetById), new { id = GetEntityId(entity) }, entity); + } + catch (InvalidOperationException ex) + { + return BadRequest(ex.Message); + } + catch (Exception ex) + { + return StatusCode(500, $"Internal server error: {ex.Message}"); + } + } + + /// + /// Update entity + /// + [HttpPut("{id}")] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status400BadRequest)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + [ProducesResponseType(StatusCodes.Status500InternalServerError)] + public virtual ActionResult Update(int id, [FromBody] TCreateUpdateDto request) + { + try + { + if (!ModelState.IsValid) + { + return BadRequest(ModelState); + } + + var entity = Service.Update(id, request); + return entity == null ? NotFound() : Ok(entity); + } + catch (InvalidOperationException ex) + { + return BadRequest(ex.Message); + } + catch (Exception ex) + { + return StatusCode(500, $"Internal server error: {ex.Message}"); + } + } + + /// + /// Delete entity + /// + [HttpDelete("{id}")] + [ProducesResponseType(StatusCodes.Status204NoContent)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + [ProducesResponseType(StatusCodes.Status500InternalServerError)] + public virtual ActionResult Delete(int id) + { + try + { + var result = Service.Delete(id); + return result ? NoContent() : NotFound(); + } + catch (Exception ex) + { + return StatusCode(500, $"Internal server error: {ex.Message}"); + } + } + + /// + /// Extract entity ID for CreatedAtAction + /// + private static int GetEntityId(TDto entity) + { + var idProperty = typeof(TDto).GetProperty("Id"); + return idProperty != null ? (int)idProperty.GetValue(entity)! : 0; + } +} \ No newline at end of file diff --git a/Bikes.Api.Host/Controllers/RentersController.cs b/Bikes.Api.Host/Controllers/RentersController.cs new file mode 100644 index 000000000..4a12de855 --- /dev/null +++ b/Bikes.Api.Host/Controllers/RentersController.cs @@ -0,0 +1,16 @@ +using Bikes.Application.Contracts; +using Bikes.Application.Contracts.Renters; +using Microsoft.AspNetCore.Mvc; + +namespace Bikes.Api.Host.Controllers; + +/// +/// Controller for renter management +/// +[ApiController] +[Route("api/[controller]")] +public class RentersController(IRenterService renterService) + : CrudControllerBase +{ + protected override IApplicationService Service => renterService; +} \ No newline at end of file diff --git a/Bikes.Api.Host/Controllers/RentsController.cs b/Bikes.Api.Host/Controllers/RentsController.cs new file mode 100644 index 000000000..8ed05ef61 --- /dev/null +++ b/Bikes.Api.Host/Controllers/RentsController.cs @@ -0,0 +1,16 @@ +using Bikes.Application.Contracts; +using Bikes.Application.Contracts.Rents; +using Microsoft.AspNetCore.Mvc; + +namespace Bikes.Api.Host.Controllers; + +/// +/// Controller for rent management +/// +[ApiController] +[Route("api/[controller]")] +public class RentsController(IRentService rentService) + : CrudControllerBase +{ + protected override IApplicationService Service => rentService; +} \ No newline at end of file diff --git a/Bikes.Api.Host/Program.cs b/Bikes.Api.Host/Program.cs new file mode 100644 index 000000000..14b3b6434 --- /dev/null +++ b/Bikes.Api.Host/Program.cs @@ -0,0 +1,69 @@ +using Bikes.Application.Contracts.Analytics; +using Bikes.Application.Contracts.Bikes; +using Bikes.Application.Contracts.Models; +using Bikes.Application.Contracts.Renters; +using Bikes.Application.Contracts.Rents; +using Bikes.Application.Services; +using Bikes.Domain.Repositories; +using Bikes.Infrastructure.EfCore; +using Bikes.ServiceDefaults; +using Microsoft.EntityFrameworkCore; + +var builder = WebApplication.CreateBuilder(args); +builder.AddServiceDefaults(); + +builder.Services.AddControllers(); +builder.Services.AddEndpointsApiExplorer(); +builder.Services.AddSwaggerGen(); + +builder.Services.AddCors(options => +{ + options.AddDefaultPolicy( + policy => + { + policy.AllowAnyOrigin() + .AllowAnyMethod() + .AllowAnyHeader(); + }); +}); + +builder.Services.AddDbContext(options => +{ + options.UseNpgsql(builder.Configuration.GetConnectionString("bikes-db")); + options.EnableSensitiveDataLogging(); + options.LogTo(Console.WriteLine, LogLevel.Information); +}); + +builder.Services.AddScoped(); +builder.Services.AddScoped(); +builder.Services.AddScoped(); +builder.Services.AddScoped(); + +builder.Services.AddScoped(); +builder.Services.AddScoped(); +builder.Services.AddScoped(); +builder.Services.AddScoped(); +builder.Services.AddScoped(); + +var app = builder.Build(); + +app.UseCors(); + +if (app.Environment.IsDevelopment()) +{ + app.UseSwagger(); + app.UseSwaggerUI(); +} + +app.UseAuthorization(); +app.MapDefaultEndpoints(); +app.MapControllers(); + +using (var scope = app.Services.CreateScope()) +{ + var context = scope.ServiceProvider.GetRequiredService(); + context.Database.Migrate(); + context.Seed(); +} + +app.Run(); \ No newline at end of file diff --git a/Bikes.Api.Host/Properties/launchSettings.json b/Bikes.Api.Host/Properties/launchSettings.json new file mode 100644 index 000000000..d339d863d --- /dev/null +++ b/Bikes.Api.Host/Properties/launchSettings.json @@ -0,0 +1,24 @@ +{ + "profiles": { + "http": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": true, + "launchUrl": "swagger", + "applicationUrl": "http://localhost:5186", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + }, + "https": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": true, + "launchUrl": "swagger", + "applicationUrl": "https://localhost:7186;http://localhost:5186", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + } + } +} \ No newline at end of file diff --git a/Bikes.Api.Host/appsettings.Development.json b/Bikes.Api.Host/appsettings.Development.json new file mode 100644 index 000000000..0c208ae91 --- /dev/null +++ b/Bikes.Api.Host/appsettings.Development.json @@ -0,0 +1,8 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + } +} diff --git a/Bikes.Api.Host/appsettings.json b/Bikes.Api.Host/appsettings.json new file mode 100644 index 000000000..bf32e9174 --- /dev/null +++ b/Bikes.Api.Host/appsettings.json @@ -0,0 +1,12 @@ +{ + "ConnectionStrings": { + "DefaultConnection": "Host=localhost;Port=5432;Database=bikes_db;Username=postgres;Password=postgres123" + }, + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + }, + "AllowedHosts": "*" +} \ No newline at end of file diff --git a/Bikes.AppHost/AppHost.cs b/Bikes.AppHost/AppHost.cs new file mode 100644 index 000000000..e40704eab --- /dev/null +++ b/Bikes.AppHost/AppHost.cs @@ -0,0 +1,43 @@ +using Microsoft.Extensions.Configuration; + +var builder = DistributedApplication.CreateBuilder(args); + +var postgres = builder.AddPostgres("postgres"); +var bikesDb = postgres.AddDatabase("bikes-db"); + +var api = builder.AddProject("bikes-api") + .WithReference(bikesDb) + .WaitFor(bikesDb); + +var kafka = builder.AddKafka("bikes-kafka") + .WithKafkaUI(); + +var generatorSettings = builder.Configuration.GetSection("Generator"); +var batchSize = generatorSettings.GetValue("BatchSize", 10); +var payloadLimit = generatorSettings.GetValue("PayloadLimit", 100); +var waitTime = generatorSettings.GetValue("WaitTime", 10000); + +var kafkaSettings = builder.Configuration.GetSection("Kafka"); +var topic = kafkaSettings["Topic"] ?? "bike-rents"; +var groupId = kafkaSettings["GroupId"] ?? "bikes-consumer-group"; + +var consumer = builder.AddProject("bikes-consumer") + .WithReference(kafka) + .WithReference(bikesDb) + .WaitFor(kafka) + .WaitFor(bikesDb) + .WithEnvironment("Kafka__Topic", topic) + .WithEnvironment("Kafka__GroupId", groupId) + .WithEnvironment("ConnectionStrings__bikes-db", bikesDb); + +_ = builder.AddProject("bikes-generator") + .WithReference(kafka) + .WaitFor(kafka) + .WaitFor(api) + .WaitFor(consumer) + .WithEnvironment("Kafka__Topic", topic) + .WithEnvironment("Generator__BatchSize", batchSize.ToString()) + .WithEnvironment("Generator__PayloadLimit", payloadLimit.ToString()) + .WithEnvironment("Generator__WaitTime", waitTime.ToString()); + +builder.Build().Run(); \ No newline at end of file diff --git a/Bikes.AppHost/Bikes.AppHost.csproj b/Bikes.AppHost/Bikes.AppHost.csproj new file mode 100644 index 000000000..0d24da477 --- /dev/null +++ b/Bikes.AppHost/Bikes.AppHost.csproj @@ -0,0 +1,26 @@ + + + + + Exe + net8.0 + enable + enable + true + aspire-host-id + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/Bikes.AppHost/Properties/launchSettings.json b/Bikes.AppHost/Properties/launchSettings.json new file mode 100644 index 000000000..eac85cbcb --- /dev/null +++ b/Bikes.AppHost/Properties/launchSettings.json @@ -0,0 +1,29 @@ +{ + "$schema": "https://json.schemastore.org/launchsettings.json", + "profiles": { + "https": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": true, + "applicationUrl": "https://localhost:17150;http://localhost:15096", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development", + "DOTNET_ENVIRONMENT": "Development", + "ASPIRE_DASHBOARD_OTLP_ENDPOINT_URL": "https://localhost:21165", + "ASPIRE_RESOURCE_SERVICE_ENDPOINT_URL": "https://localhost:22192" + } + }, + "http": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": true, + "applicationUrl": "http://localhost:15096", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development", + "DOTNET_ENVIRONMENT": "Development", + "ASPIRE_DASHBOARD_OTLP_ENDPOINT_URL": "http://localhost:19260", + "ASPIRE_RESOURCE_SERVICE_ENDPOINT_URL": "http://localhost:20243" + } + } + } +} diff --git a/Bikes.AppHost/appsettings.json b/Bikes.AppHost/appsettings.json new file mode 100644 index 000000000..833239c93 --- /dev/null +++ b/Bikes.AppHost/appsettings.json @@ -0,0 +1,21 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.Hosting.Lifetime": "Information" + } + }, + "ConnectionStrings": { + "bikes-kafka": "localhost:51251", + "bikes-db": "Host=localhost;Port=5432;Database=bikes;Username=postgres;Password=postgres" + }, + "Kafka": { + "Topic": "bike-rents", + "GroupId": "bikes-consumer-group" + }, + "Generator": { + "BatchSize": 10, + "PayloadLimit": 100, + "WaitTime": 10000 + } +} \ No newline at end of file diff --git a/Bikes.Application.Contracts/Analytics/AnalyticsDto.cs b/Bikes.Application.Contracts/Analytics/AnalyticsDto.cs new file mode 100644 index 000000000..9b76610c0 --- /dev/null +++ b/Bikes.Application.Contracts/Analytics/AnalyticsDto.cs @@ -0,0 +1,73 @@ +namespace Bikes.Application.Contracts.Analytics; + +/// +/// DTO for bike models analytics +/// +public class BikeModelAnalyticsDto +{ + /// + /// Model identifier + /// + public int Id { get; set; } + + /// + /// Model name + /// + public string Name { get; set; } = string.Empty; + + /// + /// Bike type + /// + public string Type { get; set; } = string.Empty; + + /// + /// Price per rental hour + /// + public decimal PricePerHour { get; set; } + + /// + /// Total profit + /// + public decimal TotalProfit { get; set; } + + /// + /// Total rental duration + /// + public int TotalDuration { get; set; } +} + +/// +/// DTO for renters analytics +/// +public class RenterAnalyticsDto +{ + /// + /// Renter full name + /// + public string FullName { get; set; } = string.Empty; + + /// + /// Number of rentals + /// + public int RentalCount { get; set; } +} + +/// +/// Rental statistics +/// +public record RentalStatistics( + /// + /// Minimum rental duration + /// + int MinDuration, + + /// + /// Maximum rental duration + /// + int MaxDuration, + + /// + /// Average rental duration + /// + double AvgDuration +); \ No newline at end of file diff --git a/Bikes.Application.Contracts/Analytics/IAnalyticsService.cs b/Bikes.Application.Contracts/Analytics/IAnalyticsService.cs new file mode 100644 index 000000000..a18f7b382 --- /dev/null +++ b/Bikes.Application.Contracts/Analytics/IAnalyticsService.cs @@ -0,0 +1,39 @@ +using Bikes.Application.Contracts.Bikes; + +namespace Bikes.Application.Contracts.Analytics; + +/// +/// Service for bike rental analytics +/// +public interface IAnalyticsService +{ + /// + /// Get all sport bikes + /// + public List GetSportBikes(); + + /// + /// Get top 5 models by profit + /// + public List GetTop5ModelsByProfit(); + + /// + /// Get top 5 models by rental duration + /// + public List GetTop5ModelsByRentalDuration(); + + /// + /// Get rental statistics + /// + public RentalStatistics GetRentalStatistics(); + + /// + /// Get total rental time by bike type + /// + public Dictionary GetTotalRentalTimeByBikeType(); + + /// + /// Get top renters by rental count + /// + public List GetTopRentersByRentalCount(); +} \ No newline at end of file diff --git a/Bikes.Application.Contracts/Bikes.Application.Contracts.csproj b/Bikes.Application.Contracts/Bikes.Application.Contracts.csproj new file mode 100644 index 000000000..d44b7bba6 --- /dev/null +++ b/Bikes.Application.Contracts/Bikes.Application.Contracts.csproj @@ -0,0 +1,9 @@ + + + + net8.0 + enable + enable + + + \ No newline at end of file diff --git a/Bikes.Application.Contracts/Bikes/BikeCreateUpdateDto.cs b/Bikes.Application.Contracts/Bikes/BikeCreateUpdateDto.cs new file mode 100644 index 000000000..b7618bae2 --- /dev/null +++ b/Bikes.Application.Contracts/Bikes/BikeCreateUpdateDto.cs @@ -0,0 +1,30 @@ +using System.ComponentModel.DataAnnotations; + +namespace Bikes.Application.Contracts.Bikes; + +/// +/// DTO for creating and updating bikes +/// +public class BikeCreateUpdateDto +{ + /// + /// Bike serial number + /// + [Required(ErrorMessage = "Serial number is required")] + [StringLength(50, MinimumLength = 3, ErrorMessage = "Serial number must be between 3 and 50 characters")] + public required string SerialNumber { get; set; } + + /// + /// Bike model identifier + /// + [Required(ErrorMessage = "Model ID is required")] + [Range(1, int.MaxValue, ErrorMessage = "Model ID must be a positive number")] + public required int ModelId { get; set; } + + /// + /// Bike color + /// + [Required(ErrorMessage = "Color is required")] + [StringLength(30, MinimumLength = 2, ErrorMessage = "Color must be between 2 and 30 characters")] + public required string Color { get; set; } +} \ No newline at end of file diff --git a/Bikes.Application.Contracts/Bikes/BikeDto.cs b/Bikes.Application.Contracts/Bikes/BikeDto.cs new file mode 100644 index 000000000..87da07606 --- /dev/null +++ b/Bikes.Application.Contracts/Bikes/BikeDto.cs @@ -0,0 +1,32 @@ +namespace Bikes.Application.Contracts.Bikes; + +/// +/// DTO for bike representation +/// +public class BikeDto +{ + /// + /// Bike identifier + /// + public int Id { get; set; } + + /// + /// Bike serial number + /// + public string SerialNumber { get; set; } = string.Empty; + + /// + /// Bike model identifier + /// + public int ModelId { get; set; } + + /// + /// Bike color + /// + public string Color { get; set; } = string.Empty; + + /// + /// Availability status for rental + /// + public bool IsAvailable { get; set; } +} \ No newline at end of file diff --git a/Bikes.Application.Contracts/Bikes/IBikeService.cs b/Bikes.Application.Contracts/Bikes/IBikeService.cs new file mode 100644 index 000000000..d880287c9 --- /dev/null +++ b/Bikes.Application.Contracts/Bikes/IBikeService.cs @@ -0,0 +1,8 @@ +namespace Bikes.Application.Contracts.Bikes; + +/// +/// Service for bike management +/// +public interface IBikeService : IApplicationService +{ +} \ No newline at end of file diff --git a/Bikes.Application.Contracts/IApplicationService.cs b/Bikes.Application.Contracts/IApplicationService.cs new file mode 100644 index 000000000..350e0aba6 --- /dev/null +++ b/Bikes.Application.Contracts/IApplicationService.cs @@ -0,0 +1,36 @@ +namespace Bikes.Application.Contracts; + +/// +/// Base interface for all application services with CRUD operations +/// +/// DTO type for reading +/// DTO type for creating and updating +public interface IApplicationService + where TDto : class + where TCreateUpdateDto : class +{ + /// + /// Get all entities + /// + public List GetAll(); + + /// + /// Get entity by identifier + /// + public TDto? GetById(int id); + + /// + /// Create new entity + /// + public TDto Create(TCreateUpdateDto request); + + /// + /// Update entity + /// + public TDto? Update(int id, TCreateUpdateDto request); + + /// + /// Delete entity + /// + public bool Delete(int id); +} \ No newline at end of file diff --git a/Bikes.Application.Contracts/Models/BikeModelCreateUpdateDto.cs b/Bikes.Application.Contracts/Models/BikeModelCreateUpdateDto.cs new file mode 100644 index 000000000..93173580e --- /dev/null +++ b/Bikes.Application.Contracts/Models/BikeModelCreateUpdateDto.cs @@ -0,0 +1,65 @@ +using System.ComponentModel.DataAnnotations; + +namespace Bikes.Application.Contracts.Models; + +/// +/// DTO for creating and updating bike models +/// +public class BikeModelCreateUpdateDto +{ + /// + /// Model name + /// + [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; } + + /// + /// Bike type + /// + [Required(ErrorMessage = "Bike type is required")] + [RegularExpression("^(Mountain|Road|Hybrid|City|Sport)$", ErrorMessage = "Bike type must be Mountain, Road, Hybrid, City, or Sport")] + public required string Type { get; set; } + + /// + /// Wheel size in inches + /// + [Required(ErrorMessage = "Wheel size is required")] + [Range(10, 30, ErrorMessage = "Wheel size must be between 10 and 30 inches")] + public required decimal WheelSize { get; set; } + + /// + /// Maximum weight capacity in kg + /// + [Required(ErrorMessage = "Maximum weight capacity is required")] + [Range(50, 300, ErrorMessage = "Maximum weight capacity must be between 50 and 300 kg")] + public required decimal MaxWeight { get; set; } + + /// + /// Bike weight in kg + /// + [Required(ErrorMessage = "Bike weight is required")] + [Range(5, 50, ErrorMessage = "Bike weight must be between 5 and 50 kg")] + public required decimal Weight { get; set; } + + /// + /// Brake type + /// + [Required(ErrorMessage = "Brake type is required")] + [RegularExpression("^(Mechanical|Hydraulic|Rim)$", ErrorMessage = "Brake type must be Mechanical, Hydraulic, or Rim")] + public required string BrakeType { get; set; } + + /// + /// Model year + /// + [Required(ErrorMessage = "Model year is required")] + [Range(2000, 2030, ErrorMessage = "Model year must be between 2000 and 2030")] + public required int ModelYear { get; set; } + + /// + /// Price per rental hour + /// + [Required(ErrorMessage = "Price per hour is required")] + [Range(0.01, 1000, ErrorMessage = "Price per hour must be between 0.01 and 1000")] + public required decimal PricePerHour { get; set; } +} \ No newline at end of file diff --git a/Bikes.Application.Contracts/Models/BikeModelDto.cs b/Bikes.Application.Contracts/Models/BikeModelDto.cs new file mode 100644 index 000000000..82a96c08a --- /dev/null +++ b/Bikes.Application.Contracts/Models/BikeModelDto.cs @@ -0,0 +1,52 @@ +namespace Bikes.Application.Contracts.Models; + +/// +/// DTO for bike model representation +/// +public class BikeModelDto +{ + /// + /// Model identifier + /// + public int Id { get; set; } + + /// + /// Model name + /// + public string Name { get; set; } = string.Empty; + + /// + /// Bike type + /// + public string Type { get; set; } = string.Empty; + + /// + /// Wheel size in inches + /// + public decimal WheelSize { get; set; } + + /// + /// Maximum weight capacity in kg + /// + public decimal MaxWeight { get; set; } + + /// + /// Bike weight in kg + /// + public decimal Weight { get; set; } + + /// + /// Brake type + /// + public string BrakeType { get; set; } = string.Empty; + + /// + /// Model year + /// + public int ModelYear { get; set; } + + /// + /// Price per rental hour + /// + public decimal PricePerHour { get; set; } +} \ No newline at end of file diff --git a/Bikes.Application.Contracts/Models/IBikeModelService.cs b/Bikes.Application.Contracts/Models/IBikeModelService.cs new file mode 100644 index 000000000..e47678268 --- /dev/null +++ b/Bikes.Application.Contracts/Models/IBikeModelService.cs @@ -0,0 +1,8 @@ +namespace Bikes.Application.Contracts.Models; + +/// +/// Service for bike model management +/// +public interface IBikeModelService : IApplicationService +{ +} \ No newline at end of file diff --git a/Bikes.Application.Contracts/Renters/IRenterService.cs b/Bikes.Application.Contracts/Renters/IRenterService.cs new file mode 100644 index 000000000..ecd43212a --- /dev/null +++ b/Bikes.Application.Contracts/Renters/IRenterService.cs @@ -0,0 +1,8 @@ +namespace Bikes.Application.Contracts.Renters; + +/// +/// Service for renter management +/// +public interface IRenterService : IApplicationService +{ +} \ No newline at end of file diff --git a/Bikes.Application.Contracts/Renters/RenterCreateUpdateDto.cs b/Bikes.Application.Contracts/Renters/RenterCreateUpdateDto.cs new file mode 100644 index 000000000..8e9c26154 --- /dev/null +++ b/Bikes.Application.Contracts/Renters/RenterCreateUpdateDto.cs @@ -0,0 +1,23 @@ +using System.ComponentModel.DataAnnotations; + +namespace Bikes.Application.Contracts.Renters; + +/// +/// DTO for creating and updating renters +/// +public class RenterCreateUpdateDto +{ + /// + /// Renter full name + /// + [Required(ErrorMessage = "Full name is required")] + [StringLength(100, MinimumLength = 2, ErrorMessage = "Full name must be between 2 and 100 characters")] + public required string FullName { get; set; } + + /// + /// Contact phone number + /// + [Required(ErrorMessage = "Phone number is required")] + [RegularExpression(@"^\+?[1-9]\d{1,14}$", ErrorMessage = "Phone number must be in valid international format")] + public required string Phone { get; set; } +} \ No newline at end of file diff --git a/Bikes.Application.Contracts/Renters/RenterDto.cs b/Bikes.Application.Contracts/Renters/RenterDto.cs new file mode 100644 index 000000000..8d97e66cd --- /dev/null +++ b/Bikes.Application.Contracts/Renters/RenterDto.cs @@ -0,0 +1,22 @@ +namespace Bikes.Application.Contracts.Renters; + +/// +/// DTO for renter representation +/// +public class RenterDto +{ + /// + /// Renter identifier + /// + public int Id { get; set; } + + /// + /// Renter full name + /// + public string FullName { get; set; } = string.Empty; + + /// + /// Contact phone number + /// + public string Phone { get; set; } = string.Empty; +} \ No newline at end of file diff --git a/Bikes.Application.Contracts/Rents/IRentService.cs b/Bikes.Application.Contracts/Rents/IRentService.cs new file mode 100644 index 000000000..a2e03394c --- /dev/null +++ b/Bikes.Application.Contracts/Rents/IRentService.cs @@ -0,0 +1,12 @@ +namespace Bikes.Application.Contracts.Rents; + +/// +/// Service for rent management +/// +public interface IRentService : IApplicationService +{ + /// + /// Receive and process list of rent contracts from Kafka + /// + public Task ReceiveContract(IList contracts); +} \ No newline at end of file diff --git a/Bikes.Application.Contracts/Rents/RentCreateUpdateDto.cs b/Bikes.Application.Contracts/Rents/RentCreateUpdateDto.cs new file mode 100644 index 000000000..4c5c3cf34 --- /dev/null +++ b/Bikes.Application.Contracts/Rents/RentCreateUpdateDto.cs @@ -0,0 +1,36 @@ +using System.ComponentModel.DataAnnotations; + +namespace Bikes.Application.Contracts.Rents; + +/// +/// DTO for creating and updating rents +/// +public class RentCreateUpdateDto +{ + /// + /// Bike identifier + /// + [Required(ErrorMessage = "Bike ID is required")] + [Range(1, int.MaxValue, ErrorMessage = "Bike ID must be a positive number")] + public required int BikeId { get; set; } + + /// + /// Renter identifier + /// + [Required(ErrorMessage = "Renter ID is required")] + [Range(1, int.MaxValue, ErrorMessage = "Renter ID must be a positive number")] + public required int RenterId { get; set; } + + /// + /// Rental start time + /// + [Required(ErrorMessage = "Start time is required")] + public required DateTime StartTime { get; set; } + + /// + /// Rental duration in hours + /// + [Required(ErrorMessage = "Duration is required")] + [Range(1, 24 * 7, ErrorMessage = "Duration must be between 1 and 168 hours (1 week)")] + public required int DurationHours { get; set; } +} \ No newline at end of file diff --git a/Bikes.Application.Contracts/Rents/RentDto.cs b/Bikes.Application.Contracts/Rents/RentDto.cs new file mode 100644 index 000000000..c54826ce3 --- /dev/null +++ b/Bikes.Application.Contracts/Rents/RentDto.cs @@ -0,0 +1,37 @@ +namespace Bikes.Application.Contracts.Rents; + +/// +/// DTO for rent representation +/// +public class RentDto +{ + /// + /// Rent identifier + /// + public int Id { get; set; } + + /// + /// Rented bike identifier + /// + public int BikeId { get; set; } + + /// + /// Renter identifier + /// + public int RenterId { get; set; } + + /// + /// Rental start time + /// + public DateTime StartTime { get; set; } + + /// + /// Rental duration in hours + /// + public int DurationHours { get; set; } + + /// + /// Total rental cost + /// + public decimal TotalCost { get; set; } +} \ No newline at end of file diff --git a/Bikes.Application/Bikes.Application.csproj b/Bikes.Application/Bikes.Application.csproj new file mode 100644 index 000000000..00730a53a --- /dev/null +++ b/Bikes.Application/Bikes.Application.csproj @@ -0,0 +1,14 @@ + + + + net8.0 + enable + enable + + + + + + + + \ No newline at end of file diff --git a/Bikes.Application/Services/AnalyticsService.cs b/Bikes.Application/Services/AnalyticsService.cs new file mode 100644 index 000000000..8be828e02 --- /dev/null +++ b/Bikes.Application/Services/AnalyticsService.cs @@ -0,0 +1,119 @@ +using Bikes.Application.Contracts.Analytics; +using Bikes.Application.Contracts.Bikes; +using Bikes.Domain.Models; +using Bikes.Domain.Repositories; + +namespace Bikes.Application.Services; + +/// +/// Implementation of analytics service +/// +public class AnalyticsService( + IBikeRepository bikeRepository, + IRentRepository rentRepository) : IAnalyticsService +{ + /// + /// Get all sport bikes + /// + public List GetSportBikes() + { + return [.. bikeRepository.GetAllBikes() + .Where(b => b.Model.Type == BikeType.Sport) + .Select(b => new BikeDto + { + Id = b.Id, + SerialNumber = b.SerialNumber, + ModelId = b.Model.Id, + Color = b.Color, + IsAvailable = b.IsAvailable + })]; + } + + /// + /// Get top 5 models by profit + /// + public List GetTop5ModelsByProfit() + { + var rents = rentRepository.GetAllRents(); + + return [.. rents + .GroupBy(r => r.Bike.Model) + .Select(g => new BikeModelAnalyticsDto + { + Id = g.Key.Id, + Name = g.Key.Name, + Type = g.Key.Type.ToString(), + PricePerHour = g.Key.PricePerHour, + TotalProfit = g.Sum(r => r.Bike.Model.PricePerHour * r.DurationHours), + TotalDuration = g.Sum(r => r.DurationHours) + }) + .OrderByDescending(x => x.TotalProfit) + .Take(5)]; + } + + /// + /// Get top 5 models by rental duration + /// + public List GetTop5ModelsByRentalDuration() + { + var rents = rentRepository.GetAllRents(); + + return [.. rents + .GroupBy(r => r.Bike.Model) + .Select(g => new BikeModelAnalyticsDto + { + Id = g.Key.Id, + Name = g.Key.Name, + Type = g.Key.Type.ToString(), + PricePerHour = g.Key.PricePerHour, + TotalProfit = g.Sum(r => r.Bike.Model.PricePerHour * r.DurationHours), + TotalDuration = g.Sum(r => r.DurationHours) + }) + .OrderByDescending(x => x.TotalDuration) + .Take(5)]; + } + + /// + /// Get rental statistics + /// + public RentalStatistics GetRentalStatistics() + { + var durations = rentRepository.GetAllRents() + .Select(r => r.DurationHours); + + return new RentalStatistics( + MinDuration: durations.Min(), + MaxDuration: durations.Max(), + AvgDuration: durations.Average() + ); + } + + /// + /// Get total rental time by bike type + /// + public Dictionary GetTotalRentalTimeByBikeType() + { + return rentRepository.GetAllRents() + .GroupBy(r => r.Bike.Model.Type) + .ToDictionary( + g => g.Key.ToString(), + g => g.Sum(r => r.DurationHours) + ); + } + + /// + /// Get top renters by rental count + /// + public List GetTopRentersByRentalCount() + { + return [.. rentRepository.GetAllRents() + .GroupBy(r => r.Renter) + .Select(g => new RenterAnalyticsDto + { + FullName = g.Key.FullName, + RentalCount = g.Count() + }) + .OrderByDescending(x => x.RentalCount) + .Take(5)]; + } +} \ No newline at end of file diff --git a/Bikes.Application/Services/BikeModelService.cs b/Bikes.Application/Services/BikeModelService.cs new file mode 100644 index 000000000..0d6dd892a --- /dev/null +++ b/Bikes.Application/Services/BikeModelService.cs @@ -0,0 +1,142 @@ +using Bikes.Application.Contracts.Models; +using Bikes.Domain.Models; +using Bikes.Domain.Repositories; + +namespace Bikes.Application.Services; + +/// +/// Implementation of bike model service +/// +public class BikeModelService(IBikeModelRepository bikeModelRepository) : IBikeModelService +{ + /// + /// Get all bike models + /// + public List GetAll() + { + return [.. bikeModelRepository.GetAllModels().Select(m => new BikeModelDto + { + Id = m.Id, + Name = m.Name, + Type = m.Type.ToString(), + WheelSize = m.WheelSize, + MaxWeight = m.MaxWeight, + Weight = m.Weight, + BrakeType = m.BrakeType, + ModelYear = m.ModelYear, + PricePerHour = m.PricePerHour + })]; + } + + /// + /// Get bike model by identifier + /// + public BikeModelDto? GetById(int id) + { + var model = bikeModelRepository.GetModelById(id); + return model == null ? null : new BikeModelDto + { + Id = model.Id, + Name = model.Name, + Type = model.Type.ToString(), + WheelSize = model.WheelSize, + MaxWeight = model.MaxWeight, + Weight = model.Weight, + BrakeType = model.BrakeType, + ModelYear = model.ModelYear, + PricePerHour = model.PricePerHour + }; + } + + /// + /// Create new bike model + /// + public BikeModelDto Create(BikeModelCreateUpdateDto request) + { + ArgumentNullException.ThrowIfNull(request); + + if (!Enum.TryParse(request.Type, out var bikeType)) + throw new InvalidOperationException($"Invalid bike type: {request.Type}"); + + var models = bikeModelRepository.GetAllModels(); + var maxId = models.Count == 0 ? 0 : models.Max(m => m.Id); + + var newModel = new BikeModel + { + Id = maxId + 1, + Name = request.Name, + Type = bikeType, + WheelSize = request.WheelSize, + MaxWeight = request.MaxWeight, + Weight = request.Weight, + BrakeType = request.BrakeType, + ModelYear = request.ModelYear, + PricePerHour = request.PricePerHour + }; + + bikeModelRepository.AddModel(newModel); + + return new BikeModelDto + { + Id = newModel.Id, + Name = newModel.Name, + Type = newModel.Type.ToString(), + WheelSize = newModel.WheelSize, + MaxWeight = newModel.MaxWeight, + Weight = newModel.Weight, + BrakeType = newModel.BrakeType, + ModelYear = newModel.ModelYear, + PricePerHour = newModel.PricePerHour + }; + } + + /// + /// Update bike model + /// + public BikeModelDto? Update(int id, BikeModelCreateUpdateDto request) + { + ArgumentNullException.ThrowIfNull(request); + + if (!Enum.TryParse(request.Type, out var bikeType)) + throw new InvalidOperationException($"Invalid bike type: {request.Type}"); + + var model = bikeModelRepository.GetModelById(id); + if (model == null) return null; + + model.Name = request.Name; + model.Type = bikeType; + model.WheelSize = request.WheelSize; + model.MaxWeight = request.MaxWeight; + model.Weight = request.Weight; + model.BrakeType = request.BrakeType; + model.ModelYear = request.ModelYear; + model.PricePerHour = request.PricePerHour; + + bikeModelRepository.UpdateModel(model); + + return new BikeModelDto + { + Id = model.Id, + Name = model.Name, + Type = model.Type.ToString(), + WheelSize = model.WheelSize, + MaxWeight = model.MaxWeight, + Weight = model.Weight, + BrakeType = model.BrakeType, + ModelYear = model.ModelYear, + PricePerHour = model.PricePerHour + }; + } + + /// + /// Delete bike model + /// + public bool Delete(int id) + { + var model = bikeModelRepository.GetModelById(id); + if (model == null) return false; + + bikeModelRepository.DeleteModel(id); + return true; + } +} \ No newline at end of file diff --git a/Bikes.Application/Services/BikeService.cs b/Bikes.Application/Services/BikeService.cs new file mode 100644 index 000000000..c2b304060 --- /dev/null +++ b/Bikes.Application/Services/BikeService.cs @@ -0,0 +1,117 @@ +using Bikes.Application.Contracts.Bikes; +using Bikes.Domain.Models; +using Bikes.Domain.Repositories; + +namespace Bikes.Application.Services; + +/// +/// Implementation of bike service +/// +public class BikeService( + IBikeRepository bikeRepository, + IBikeModelRepository bikeModelRepository) : IBikeService +{ + /// + /// Get all bikes + /// + public List GetAll() + { + return [.. bikeRepository.GetAllBikes().Select(b => new BikeDto + { + Id = b.Id, + SerialNumber = b.SerialNumber, + ModelId = b.Model.Id, + Color = b.Color, + IsAvailable = b.IsAvailable + })]; + } + + /// + /// Get bike by identifier + /// + public BikeDto? GetById(int id) + { + var bike = bikeRepository.GetBikeById(id); + return bike == null ? null : new BikeDto + { + Id = bike.Id, + SerialNumber = bike.SerialNumber, + ModelId = bike.Model.Id, + Color = bike.Color, + IsAvailable = bike.IsAvailable + }; + } + + /// + /// Create new bike + /// + public BikeDto Create(BikeCreateUpdateDto request) + { + ArgumentNullException.ThrowIfNull(request); + + var modelExists = bikeModelRepository.GetModelById(request.ModelId) != null; + if (!modelExists) + throw new InvalidOperationException("Model not found"); + + var newBike = new Bike + { + SerialNumber = request.SerialNumber, + ModelId = request.ModelId, + Color = request.Color, + IsAvailable = true + }; + + bikeRepository.AddBike(newBike); + + return new BikeDto + { + Id = newBike.Id, + SerialNumber = newBike.SerialNumber, + ModelId = newBike.ModelId, + Color = newBike.Color, + IsAvailable = newBike.IsAvailable + }; + } + + /// + /// Update bike + /// + public BikeDto? Update(int id, BikeCreateUpdateDto request) + { + ArgumentNullException.ThrowIfNull(request); + + var bike = bikeRepository.GetBikeById(id); + if (bike == null) return null; + + var modelExists = bikeModelRepository.GetModelById(request.ModelId) != null; + if (!modelExists) + throw new InvalidOperationException("Model not found"); + + bike.SerialNumber = request.SerialNumber; + bike.ModelId = request.ModelId; + bike.Color = request.Color; + + bikeRepository.UpdateBike(bike); + + return new BikeDto + { + Id = bike.Id, + SerialNumber = bike.SerialNumber, + ModelId = bike.ModelId, + Color = bike.Color, + IsAvailable = bike.IsAvailable + }; + } + + /// + /// Delete bike + /// + public bool Delete(int id) + { + var bike = bikeRepository.GetBikeById(id); + if (bike == null) return false; + + bikeRepository.DeleteBike(id); + return true; + } +} \ No newline at end of file diff --git a/Bikes.Application/Services/RentService.cs b/Bikes.Application/Services/RentService.cs new file mode 100644 index 000000000..91eebbd2a --- /dev/null +++ b/Bikes.Application/Services/RentService.cs @@ -0,0 +1,155 @@ +using Bikes.Application.Contracts.Rents; +using Bikes.Domain.Models; +using Bikes.Domain.Repositories; + +namespace Bikes.Application.Services; + +/// +/// Implementation of rent service +/// +public class RentService( + IRentRepository rentRepository, + IBikeRepository bikeRepository, + IRenterRepository renterRepository) : IRentService +{ + /// + /// Receive and process list of rent contracts from Kafka + /// + public async Task ReceiveContract(IList contracts) + { + if (contracts == null || contracts.Count == 0) + return; + + foreach (var contract in contracts) + { + try + { + Create(contract); + } + catch (InvalidOperationException) + { + continue; + } + } + + await Task.CompletedTask; + } + + /// + /// Get all rents + /// + public List GetAll() + { + return [.. rentRepository.GetAllRents().Select(r => new RentDto + { + Id = r.Id, + BikeId = r.Bike.Id, + RenterId = r.Renter.Id, + StartTime = r.StartTime, + DurationHours = r.DurationHours, + TotalCost = r.Bike.Model.PricePerHour * r.DurationHours + })]; + } + + /// + /// Get rent by identifier + /// + public RentDto? GetById(int id) + { + var rent = rentRepository.GetRentById(id); + return rent == null ? null : new RentDto + { + Id = rent.Id, + BikeId = rent.Bike.Id, + RenterId = rent.Renter.Id, + StartTime = rent.StartTime, + DurationHours = rent.DurationHours, + TotalCost = rent.Bike.Model.PricePerHour * rent.DurationHours + }; + } + + /// + /// Create new rent + /// + public RentDto Create(RentCreateUpdateDto request) + { + ArgumentNullException.ThrowIfNull(request); + + var bike = bikeRepository.GetBikeById(request.BikeId); + var renter = renterRepository.GetRenterById(request.RenterId); + var rents = rentRepository.GetAllRents(); + + if (bike == null) + throw new InvalidOperationException("Bike not found"); + if (renter == null) + throw new InvalidOperationException("Renter not found"); + + var maxId = rents.Count == 0 ? 0 : rents.Max(r => r.Id); + + var newRent = new Rent + { + Id = maxId + 1, + Bike = bike, + Renter = renter, + StartTime = request.StartTime, + DurationHours = request.DurationHours + }; + + rentRepository.AddRent(newRent); + + return new RentDto + { + Id = newRent.Id, + BikeId = newRent.Bike.Id, + RenterId = newRent.Renter.Id, + StartTime = newRent.StartTime, + DurationHours = newRent.DurationHours, + TotalCost = newRent.Bike.Model.PricePerHour * newRent.DurationHours + }; + } + + /// + /// Update rent + /// + public RentDto? Update(int id, RentCreateUpdateDto request) + { + ArgumentNullException.ThrowIfNull(request); + + var rent = rentRepository.GetRentById(id); + if (rent == null) return null; + + var bike = bikeRepository.GetBikeById(request.BikeId) + ?? throw new InvalidOperationException("Bike not found"); + var renter = renterRepository.GetRenterById(request.RenterId) + ?? throw new InvalidOperationException("Renter not found"); + + rent.Bike = bike; + rent.Renter = renter; + rent.StartTime = request.StartTime; + rent.DurationHours = request.DurationHours; + + rentRepository.UpdateRent(rent); + + return new RentDto + { + Id = rent.Id, + BikeId = rent.Bike.Id, + RenterId = rent.Renter.Id, + StartTime = rent.StartTime, + DurationHours = rent.DurationHours, + TotalCost = rent.Bike.Model.PricePerHour * rent.DurationHours + }; + } + + /// + /// Delete rent + /// + public bool Delete(int id) + { + var rent = rentRepository.GetRentById(id); + if (rent == null) return false; + + rentRepository.DeleteRent(id); + return true; + } +} \ No newline at end of file diff --git a/Bikes.Application/Services/RenterService.cs b/Bikes.Application/Services/RenterService.cs new file mode 100644 index 000000000..bcc0fe14c --- /dev/null +++ b/Bikes.Application/Services/RenterService.cs @@ -0,0 +1,100 @@ +using Bikes.Application.Contracts.Renters; +using Bikes.Domain.Models; +using Bikes.Domain.Repositories; + +namespace Bikes.Application.Services; + +/// +/// Implementation of renter service +/// +public class RenterService(IRenterRepository renterRepository) : IRenterService +{ + /// + /// Get all renters + /// + public List GetAll() + { + return [.. renterRepository.GetAllRenters().Select(r => new RenterDto + { + Id = r.Id, + FullName = r.FullName, + Phone = r.Phone + })]; + } + + /// + /// Get renter by identifier + /// + public RenterDto? GetById(int id) + { + var renter = renterRepository.GetRenterById(id); + return renter == null ? null : new RenterDto + { + Id = renter.Id, + FullName = renter.FullName, + Phone = renter.Phone + }; + } + + /// + /// Create new renter + /// + public RenterDto Create(RenterCreateUpdateDto request) + { + ArgumentNullException.ThrowIfNull(request); + + var renters = renterRepository.GetAllRenters(); + var maxId = renters.Count == 0 ? 0 : renters.Max(r => r.Id); + + var newRenter = new Renter + { + Id = maxId + 1, + FullName = request.FullName, + Phone = request.Phone + }; + + renterRepository.AddRenter(newRenter); + + return new RenterDto + { + Id = newRenter.Id, + FullName = newRenter.FullName, + Phone = newRenter.Phone + }; + } + + /// + /// Update renter + /// + public RenterDto? Update(int id, RenterCreateUpdateDto request) + { + ArgumentNullException.ThrowIfNull(request); + + var renter = renterRepository.GetRenterById(id); + if (renter == null) return null; + + renter.FullName = request.FullName; + renter.Phone = request.Phone; + + renterRepository.UpdateRenter(renter); + + return new RenterDto + { + Id = renter.Id, + FullName = renter.FullName, + Phone = renter.Phone + }; + } + + /// + /// Delete renter + /// + public bool Delete(int id) + { + var renter = renterRepository.GetRenterById(id); + if (renter == null) return false; + + renterRepository.DeleteRenter(id); + return true; + } +} \ No newline at end of file diff --git a/Bikes.Domain/Bikes.Domain.csproj b/Bikes.Domain/Bikes.Domain.csproj new file mode 100644 index 000000000..fa71b7ae6 --- /dev/null +++ b/Bikes.Domain/Bikes.Domain.csproj @@ -0,0 +1,9 @@ + + + + net8.0 + enable + enable + + + diff --git a/Bikes.Domain/Models/Bike.cs b/Bikes.Domain/Models/Bike.cs new file mode 100644 index 000000000..75be88566 --- /dev/null +++ b/Bikes.Domain/Models/Bike.cs @@ -0,0 +1,57 @@ +using System.ComponentModel.DataAnnotations; +using System.ComponentModel.DataAnnotations.Schema; + +namespace Bikes.Domain.Models; + +/// +/// Bicycle entity +/// +[Table("bikes")] +public class Bike +{ + /// + /// Unique identifier + /// + [Key] + [Column("id")] + [DatabaseGenerated(DatabaseGeneratedOption.Identity)] + public int Id { get; set; } + + /// + /// Serial number of the bicycle + /// + [Required] + [Column("serial_number")] + public string SerialNumber { get; set; } = null!; + + /// + /// Foreign key for bike model + /// + [Required] + [Column("model_id")] + public int ModelId { get; set; } + + /// + /// Reference to bike model + /// + public virtual BikeModel Model { get; set; } = null!; + + /// + /// Color of the bicycle + /// + [Required] + [Column("color")] + public string Color { get; set; } = null!; + + /// + /// Availability status for rental + /// + [Required] + [Column("is_available")] + public bool IsAvailable { get; set; } = true; + + /// + /// Navigation property for rents + /// + public virtual List Rents { get; set; } = []; +} \ No newline at end of file diff --git a/Bikes.Domain/Models/BikeModel.cs b/Bikes.Domain/Models/BikeModel.cs new file mode 100644 index 000000000..dd573ef64 --- /dev/null +++ b/Bikes.Domain/Models/BikeModel.cs @@ -0,0 +1,79 @@ +using System.ComponentModel.DataAnnotations; +using System.ComponentModel.DataAnnotations.Schema; + +namespace Bikes.Domain.Models; + +/// +/// Bicycle model information +/// +[Table("bike_models")] +public class BikeModel +{ + /// + /// Unique identifier + /// + [Key] + [Column("id")] + public int Id { get; set; } + + /// + /// Model name + /// + [Required] + [Column("name")] + public string Name { get; set; } = null!; + + /// + /// Type of bicycle + /// + [Required] + [Column("type")] + public BikeType Type { get; set; } + + /// + /// Wheel size in inches + /// + [Required] + [Column("wheel_size")] + public decimal WheelSize { get; set; } + + /// + /// Maximum permissible passenger weight in kg + /// + [Required] + [Column("max_weight")] + public decimal MaxWeight { get; set; } + + /// + /// Bicycle weight in kg + /// + [Required] + [Column("weight")] + public decimal Weight { get; set; } + + /// + /// Type of brakes + /// + [Required] + [Column("brake_type")] + public string BrakeType { get; set; } = null!; + + /// + /// Model year + /// + [Required] + [Column("model_year")] + public int ModelYear { get; set; } + + /// + /// Price per hour of rental + /// + [Required] + [Column("price_per_hour")] + public decimal PricePerHour { get; set; } + + /// + /// Navigation property for bikes + /// + public virtual List Bikes { get; set; } = []; +} \ No newline at end of file diff --git a/Bikes.Domain/Models/BikeType.cs b/Bikes.Domain/Models/BikeType.cs new file mode 100644 index 000000000..d7e75aa9f --- /dev/null +++ b/Bikes.Domain/Models/BikeType.cs @@ -0,0 +1,32 @@ +namespace Bikes.Domain.Models; + +/// +/// Type of bicycle +/// +public enum BikeType +{ + /// + /// Road bike + /// + Road, + + /// + /// Mountain bike + /// + Mountain, + + /// + /// Sport bike + /// + Sport, + + /// + /// Hybrid bike + /// + Hybrid, + + /// + /// Electric bike + /// + Electric +} \ No newline at end of file diff --git a/Bikes.Domain/Models/Rent.cs b/Bikes.Domain/Models/Rent.cs new file mode 100644 index 000000000..6345ca998 --- /dev/null +++ b/Bikes.Domain/Models/Rent.cs @@ -0,0 +1,62 @@ +using System.ComponentModel.DataAnnotations; +using System.ComponentModel.DataAnnotations.Schema; + +namespace Bikes.Domain.Models; + +/// +/// Bicycle rental record +/// +[Table("rents")] +public class Rent +{ + /// + /// Unique identifier + /// + [Key] + [Column("id")] + public int Id { get; set; } + + /// + /// Foreign key for bike + /// + [Required] + [Column("bike_id")] + public int BikeId { get; set; } + + /// + /// Reference to rented bicycle + /// + public virtual Bike Bike { get; set; } = null!; + + /// + /// Foreign key for renter + /// + [Required] + [Column("renter_id")] + public int RenterId { get; set; } + + /// + /// Reference to renter + /// + public virtual Renter Renter { get; set; } = null!; + + /// + /// Rental start time + /// + [Required] + [Column("start_time")] + public DateTime StartTime { get; set; } + + /// + /// Rental duration in hours + /// + [Required] + [Column("duration_hours")] + public int DurationHours { get; set; } + + /// + /// Total rental cost + /// + [Column("total_cost")] + public decimal TotalCost => Bike.Model.PricePerHour * DurationHours; +} \ No newline at end of file diff --git a/Bikes.Domain/Models/Renter.cs b/Bikes.Domain/Models/Renter.cs new file mode 100644 index 000000000..608025bc8 --- /dev/null +++ b/Bikes.Domain/Models/Renter.cs @@ -0,0 +1,37 @@ +using System.ComponentModel.DataAnnotations; +using System.ComponentModel.DataAnnotations.Schema; + +namespace Bikes.Domain.Models; + +/// +/// Bicycle renter information +/// +[Table("renters")] +public class Renter +{ + /// + /// Unique identifier + /// + [Key] + [Column("id")] + public int Id { get; set; } + + /// + /// Full name of the renter + /// + [Required] + [Column("full_name")] + public string FullName { get; set; } = null!; + + /// + /// Contact phone number + /// + [Required] + [Column("phone")] + public string Phone { get; set; } = null!; + + /// + /// Navigation property for rents + /// + public virtual List Rents { get; set; } = []; +} \ No newline at end of file diff --git a/Bikes.Domain/Repositories/IBikeModelRepository.cs b/Bikes.Domain/Repositories/IBikeModelRepository.cs new file mode 100644 index 000000000..ca3281fd5 --- /dev/null +++ b/Bikes.Domain/Repositories/IBikeModelRepository.cs @@ -0,0 +1,15 @@ +using Bikes.Domain.Models; + +namespace Bikes.Domain.Repositories; + +/// +/// Repository for bike model data access +/// +public interface IBikeModelRepository +{ + public List GetAllModels(); + public BikeModel? GetModelById(int id); + public void AddModel(BikeModel model); + public void UpdateModel(BikeModel model); + public void DeleteModel(int id); +} \ No newline at end of file diff --git a/Bikes.Domain/Repositories/IBikeRepository.cs b/Bikes.Domain/Repositories/IBikeRepository.cs new file mode 100644 index 000000000..b63b496be --- /dev/null +++ b/Bikes.Domain/Repositories/IBikeRepository.cs @@ -0,0 +1,15 @@ +using Bikes.Domain.Models; + +namespace Bikes.Domain.Repositories; + +/// +/// Repository for bike data access +/// +public interface IBikeRepository +{ + public List GetAllBikes(); + public Bike? GetBikeById(int id); + public void AddBike(Bike bike); + public void UpdateBike(Bike bike); + public void DeleteBike(int id); +} \ No newline at end of file diff --git a/Bikes.Domain/Repositories/IRentRepository.cs b/Bikes.Domain/Repositories/IRentRepository.cs new file mode 100644 index 000000000..ca1fc55d3 --- /dev/null +++ b/Bikes.Domain/Repositories/IRentRepository.cs @@ -0,0 +1,15 @@ +using Bikes.Domain.Models; + +namespace Bikes.Domain.Repositories; + +/// +/// Repository for rent data access +/// +public interface IRentRepository +{ + public List GetAllRents(); + public Rent? GetRentById(int id); + public void AddRent(Rent rent); + public void UpdateRent(Rent rent); + public void DeleteRent(int id); +} \ No newline at end of file diff --git a/Bikes.Domain/Repositories/IRenterRepository.cs b/Bikes.Domain/Repositories/IRenterRepository.cs new file mode 100644 index 000000000..2443e2560 --- /dev/null +++ b/Bikes.Domain/Repositories/IRenterRepository.cs @@ -0,0 +1,15 @@ +using Bikes.Domain.Models; + +namespace Bikes.Domain.Repositories; + +/// +/// Repository for renter data access +/// +public interface IRenterRepository +{ + public List GetAllRenters(); + public Renter? GetRenterById(int id); + public void AddRenter(Renter renter); + public void UpdateRenter(Renter renter); + public void DeleteRenter(int id); +} \ No newline at end of file diff --git a/Bikes.Generator.Kafka/Bikes.Generator.Kafka.csproj b/Bikes.Generator.Kafka/Bikes.Generator.Kafka.csproj new file mode 100644 index 000000000..453d31b77 --- /dev/null +++ b/Bikes.Generator.Kafka/Bikes.Generator.Kafka.csproj @@ -0,0 +1,22 @@ + + + + net8.0 + enable + enable + dotnet-Bikes.Generator.Kafka-b1d58c3b-9059-4dd2-8b92-981ad7eb6764 + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/Bikes.Generator.Kafka/IDataGenerator.cs b/Bikes.Generator.Kafka/IDataGenerator.cs new file mode 100644 index 000000000..6495026de --- /dev/null +++ b/Bikes.Generator.Kafka/IDataGenerator.cs @@ -0,0 +1,12 @@ +namespace Bikes.Generator.Kafka; + +/// +/// Interface for generating random test data +/// +public interface IDataGenerator +{ + public IEnumerable GenerateBikeModels(int count); + public IEnumerable GenerateBikes(int count, List modelIds); + public IEnumerable GenerateRenters(int count); + public IEnumerable GenerateRents(int count, List bikeIds, List renterIds); +} \ No newline at end of file diff --git a/Bikes.Generator.Kafka/KafkaProducerService.cs b/Bikes.Generator.Kafka/KafkaProducerService.cs new file mode 100644 index 000000000..80dfc88b9 --- /dev/null +++ b/Bikes.Generator.Kafka/KafkaProducerService.cs @@ -0,0 +1,87 @@ +using Bikes.Generator.Kafka.Services; + +namespace Bikes.Generator.Kafka; + +/// +/// Background service for generating and sending a specified number of contracts at specified intervals +/// +public class KafkaProducerService : BackgroundService +{ + private readonly int _batchSize; + private readonly int _payloadLimit; + private readonly int _waitTime; + private readonly IProducerService _producer; + private readonly ILogger _logger; + private readonly IDataGenerator _dataGenerator; + + public KafkaProducerService( + IConfiguration configuration, + IProducerService producer, + ILogger logger, + IDataGenerator dataGenerator) + { + _batchSize = configuration.GetValue("Generator:BatchSize", 10); + _payloadLimit = configuration.GetValue("Generator:PayloadLimit", 100); + _waitTime = configuration.GetValue("Generator:WaitTime", 10000); + + if (_batchSize <= 0) + throw new ArgumentException($"Invalid argument value for BatchSize: {_batchSize}"); + if (_payloadLimit <= 0) + throw new ArgumentException($"Invalid argument value for PayloadLimit: {_payloadLimit}"); + if (_waitTime <= 0) + throw new ArgumentException($"Invalid argument value for WaitTime: {_waitTime}"); + + _producer = producer; + _logger = logger; + _dataGenerator = dataGenerator; + } + + protected override async Task ExecuteAsync(CancellationToken stoppingToken) + { + _logger.LogInformation("Waiting 15 seconds for Kafka to become fully ready..."); + await Task.Delay(15000, stoppingToken); + + _logger.LogInformation("Starting to send {total} messages with {time}s interval with {batch} messages in batch", + _payloadLimit, _waitTime / 1000, _batchSize); + + var counter = 0; + while (counter < _payloadLimit && !stoppingToken.IsCancellationRequested) + { + try + { + await GenerateAndSendBatchAsync(); + counter += _batchSize; + _logger.LogInformation("Sent {sent} messages, total: {total}/{limit}", + _batchSize, counter, _payloadLimit); + } + catch (Exception ex) + { + _logger.LogError(ex, "Send batch with error. Retry"); + } + + await Task.Delay(_waitTime, stoppingToken); + } + + _logger.LogInformation("Finished sending {total} messages with {time}s interval with {batch} messages in batch", + _payloadLimit, _waitTime / 1000, _batchSize); + } + + private async Task GenerateAndSendBatchAsync() + { + // Generate required data for rents + var models = _dataGenerator.GenerateBikeModels(_batchSize / 4).ToList(); + var renters = _dataGenerator.GenerateRenters(_batchSize / 4).ToList(); + var modelIds = Enumerable.Range(1, models.Count).ToList(); + var renterIds = Enumerable.Range(1, renters.Count).ToList(); + var bikes = _dataGenerator.GenerateBikes(_batchSize / 4, modelIds).ToList(); + var bikeIds = Enumerable.Range(1, bikes.Count).ToList(); + + // Generate only rents + var rents = _dataGenerator.GenerateRents(_batchSize, bikeIds, renterIds).ToList(); + + // Send list of rents + await _producer.SendAsync("bike-rents", rents); + + _logger.LogInformation("Generated {RentCount} rents", rents.Count); + } +} \ No newline at end of file diff --git a/Bikes.Generator.Kafka/Program.cs b/Bikes.Generator.Kafka/Program.cs new file mode 100644 index 000000000..9fcb54bd0 --- /dev/null +++ b/Bikes.Generator.Kafka/Program.cs @@ -0,0 +1,31 @@ +using Bikes.Generator.Kafka; +using Bikes.Generator.Kafka.Services; +using Bikes.ServiceDefaults; + +var builder = Host.CreateApplicationBuilder(args); + +Console.WriteLine("=== Debug Info ==="); +Console.WriteLine($"ConnectionStrings__bikes-kafka: {builder.Configuration.GetConnectionString("bikes-kafka")}"); +Console.WriteLine($"Kafka:BootstrapServers: {builder.Configuration["Kafka:BootstrapServers"]}"); +Console.WriteLine($"Kafka__BootstrapServers: {builder.Configuration["Kafka__BootstrapServers"]}"); + +Console.WriteLine("=== All Kafka-related env vars ==="); +foreach (var env in Environment.GetEnvironmentVariables().Cast()) +{ + var key = env.Key.ToString()!; + if (key.Contains("kafka", StringComparison.OrdinalIgnoreCase) || + key.Contains("KAFKA", StringComparison.OrdinalIgnoreCase)) + { + Console.WriteLine($"{key} = {env.Value}"); + } +} +Console.WriteLine("=================="); + +builder.AddServiceDefaults(); + +builder.Services.AddSingleton(); +builder.Services.AddSingleton(); +builder.Services.AddHostedService(); + +var host = builder.Build(); +await host.RunAsync(); \ No newline at end of file diff --git a/Bikes.Generator.Kafka/Properties/launchSettings.json b/Bikes.Generator.Kafka/Properties/launchSettings.json new file mode 100644 index 000000000..9507c37f1 --- /dev/null +++ b/Bikes.Generator.Kafka/Properties/launchSettings.json @@ -0,0 +1,12 @@ +{ + "$schema": "http://json.schemastore.org/launchsettings.json", + "profiles": { + "Bikes.Generator.Kafka": { + "commandName": "Project", + "dotnetRunMessages": true, + "environmentVariables": { + "DOTNET_ENVIRONMENT": "Development" + } + } + } +} diff --git a/Bikes.Generator.Kafka/RandomDataGenerator.cs b/Bikes.Generator.Kafka/RandomDataGenerator.cs new file mode 100644 index 000000000..400e142ae --- /dev/null +++ b/Bikes.Generator.Kafka/RandomDataGenerator.cs @@ -0,0 +1,62 @@ +using Bogus; +using Bikes.Application.Contracts.Bikes; +using Bikes.Application.Contracts.Models; +using Bikes.Application.Contracts.Renters; +using Bikes.Application.Contracts.Rents; + +namespace Bikes.Generator.Kafka; + +/// +/// Implementation of IDataGenerator using Bogus for generating random test data +/// +public class RandomDataGenerator : IDataGenerator +{ + private readonly Faker _bikeModelFaker; + private readonly Faker _bikeFaker; + private readonly Faker _renterFaker; + private readonly Faker _rentFaker; + + public RandomDataGenerator() + { + var bikeTypes = new[] { "Mountain", "Road", "Hybrid", "City", "Sport" }; + var brakeTypes = new[] { "Mechanical", "Hydraulic", "Rim" }; + var colors = new[] { "Red", "Blue", "Green", "Black", "White", "Yellow", "Silver" }; + + _bikeModelFaker = new Faker() + .RuleFor(m => m.Name, f => $"Model {f.Commerce.ProductName()}") + .RuleFor(m => m.Type, f => f.PickRandom(bikeTypes)) + .RuleFor(m => m.WheelSize, f => f.Random.Decimal(10, 30)) + .RuleFor(m => m.MaxWeight, f => f.Random.Decimal(50, 300)) + .RuleFor(m => m.Weight, f => f.Random.Decimal(5, 50)) + .RuleFor(m => m.BrakeType, f => f.PickRandom(brakeTypes)) + .RuleFor(m => m.ModelYear, f => f.Random.Int(2000, 2024)) + .RuleFor(m => m.PricePerHour, f => f.Random.Decimal(1, 50)); + + _bikeFaker = new Faker() + .RuleFor(b => b.SerialNumber, f => $"SN{f.Random.AlphaNumeric(8).ToUpper()}") + .RuleFor(b => b.ModelId, f => f.Random.Int(1, 11)) + .RuleFor(b => b.Color, f => f.PickRandom(colors)); + + _renterFaker = new Faker() + .RuleFor(r => r.FullName, f => f.Name.FullName()) + .RuleFor(r => r.Phone, f => f.Phone.PhoneNumber("+7##########")); + + _rentFaker = new Faker() + .RuleFor(r => r.BikeId, f => f.Random.Int(1, 10)) + .RuleFor(r => r.RenterId, f => f.Random.Int(1, 12)) + .RuleFor(r => r.StartTime, f => f.Date.Recent(30).ToUniversalTime()) + .RuleFor(r => r.DurationHours, f => f.Random.Int(1, 168)); + } + + public IEnumerable GenerateBikeModels(int count) + => _bikeModelFaker.Generate(count); + + public IEnumerable GenerateBikes(int count, List modelIds) + => _bikeFaker.Generate(count); + + public IEnumerable GenerateRenters(int count) + => _renterFaker.Generate(count); + + public IEnumerable GenerateRents(int count, List bikeIds, List renterIds) + => _rentFaker.Generate(count); +} \ No newline at end of file diff --git a/Bikes.Generator.Kafka/Serializers/KeySerializer.cs b/Bikes.Generator.Kafka/Serializers/KeySerializer.cs new file mode 100644 index 000000000..cf9b30659 --- /dev/null +++ b/Bikes.Generator.Kafka/Serializers/KeySerializer.cs @@ -0,0 +1,15 @@ +using Confluent.Kafka; +using System.Text; + +namespace Bikes.Generator.Kafka.Serializers; + +/// +/// Serializer for Guid keys in Kafka messages +/// +public class KeySerializer : ISerializer +{ + public byte[] Serialize(Guid data, SerializationContext context) + { + return Encoding.UTF8.GetBytes(data.ToString()); + } +} \ No newline at end of file diff --git a/Bikes.Generator.Kafka/Serializers/ValueSerializer.cs b/Bikes.Generator.Kafka/Serializers/ValueSerializer.cs new file mode 100644 index 000000000..b6fdc83b8 --- /dev/null +++ b/Bikes.Generator.Kafka/Serializers/ValueSerializer.cs @@ -0,0 +1,21 @@ +using Confluent.Kafka; +using System.Text; +using System.Text.Json; +using Bikes.Application.Contracts.Rents; + +namespace Bikes.Generator.Kafka.Serializers; + +/// +/// Serializer for list of RentCreateUpdateDto values in Kafka messages +/// +public class ValueSerializer : ISerializer> +{ + public byte[] Serialize(IList data, SerializationContext context) + { + if (data == null || data.Count == 0) + return []; + + var json = JsonSerializer.Serialize(data); + return Encoding.UTF8.GetBytes(json); + } +} \ No newline at end of file diff --git a/Bikes.Generator.Kafka/Services/IProducerService.cs b/Bikes.Generator.Kafka/Services/IProducerService.cs new file mode 100644 index 000000000..185d2371b --- /dev/null +++ b/Bikes.Generator.Kafka/Services/IProducerService.cs @@ -0,0 +1,8 @@ +using Bikes.Application.Contracts.Rents; + +namespace Bikes.Generator.Kafka.Services; + +public interface IProducerService +{ + public Task SendAsync(string topic, IList items); +} \ No newline at end of file diff --git a/Bikes.Generator.Kafka/Services/KafkaGeneratorService.cs b/Bikes.Generator.Kafka/Services/KafkaGeneratorService.cs new file mode 100644 index 000000000..8cd7196a2 --- /dev/null +++ b/Bikes.Generator.Kafka/Services/KafkaGeneratorService.cs @@ -0,0 +1,107 @@ +using Bikes.Generator.Kafka.Serializers; +using Confluent.Kafka; +using Polly; +using Bikes.Application.Contracts.Rents; + +namespace Bikes.Generator.Kafka.Services; + +/// +/// Service for producing messages to Kafka with retry logic and error handling +/// +public class KafkaGeneratorService : IProducerService, IDisposable +{ + private readonly ILogger _logger; + private readonly IProducer> _producer; + private readonly int _retryCount; + private readonly int _retryDelayMs; + private bool _disposed = false; + + public KafkaGeneratorService( + ILogger logger, + IConfiguration configuration) + { + _logger = logger; + + var bootstrapServers = configuration.GetConnectionString("bikes-kafka") + ?? configuration["Kafka:BootstrapServers"] + ?? configuration["Kafka__BootstrapServers"] + ?? "localhost:9092"; + + _logger.LogInformation("Kafka BootstrapServers: {BootstrapServers}", bootstrapServers); + + var config = new ProducerConfig + { + BootstrapServers = bootstrapServers, + MessageSendMaxRetries = 20, + RetryBackoffMs = 10000, + SocketTimeoutMs = 60000, + MessageTimeoutMs = 120000, + EnableDeliveryReports = true, + Acks = Acks.All, + SecurityProtocol = SecurityProtocol.Plaintext + }; + + _producer = new ProducerBuilder>(config) + .SetKeySerializer(new KeySerializer()) + .SetValueSerializer(new ValueSerializer()) + .SetLogHandler((_, message) => + { + _logger.LogDebug("Kafka log: {Facility} {Message}", message.Facility, message.Message); + }) + .SetErrorHandler((_, error) => + { + if (error.IsFatal) + _logger.LogError("Kafka fatal error: {Reason} (Code: {Code})", error.Reason, error.Code); + else + _logger.LogWarning("Kafka error: {Reason} (Code: {Code})", error.Reason, error.Code); + }) + .Build(); + + _retryCount = configuration.GetValue("Kafka:RetryCount", 10); + _retryDelayMs = configuration.GetValue("Kafka:RetryDelayMs", 5000); + } + + public async Task SendAsync(string topic, IList items) + { + if (items == null || items.Count == 0) + return; + + var retryPolicy = Policy + .Handle() + .WaitAndRetryAsync( + _retryCount, + attempt => TimeSpan.FromMilliseconds(_retryDelayMs * Math.Pow(2, attempt - 1)), + onRetry: (exception, delay, attempt, context) => + { + _logger.LogWarning(exception, + "Retry {Attempt}/{MaxAttempts} after {Delay}ms", + attempt, _retryCount, delay.TotalMilliseconds); + }); + + await retryPolicy.ExecuteAsync(async () => + { + var key = Guid.NewGuid(); + + var message = new Message> + { + Key = key, + Value = items + }; + + var result = await _producer.ProduceAsync(topic, message); + _logger.LogInformation("Sent {Count} items to Kafka topic {Topic} (Partition: {Partition}, Offset: {Offset})", + items.Count, topic, result.Partition, result.Offset); + }); + } + + public void Dispose() + { + if (!_disposed) + { + _producer?.Flush(TimeSpan.FromSeconds(5)); + _producer?.Dispose(); + _disposed = true; + } + GC.SuppressFinalize(this); + } +} \ No newline at end of file diff --git a/Bikes.Generator.Kafka/appsettings.Development.json b/Bikes.Generator.Kafka/appsettings.Development.json new file mode 100644 index 000000000..b2dcdb674 --- /dev/null +++ b/Bikes.Generator.Kafka/appsettings.Development.json @@ -0,0 +1,8 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.Hosting.Lifetime": "Information" + } + } +} diff --git a/Bikes.Generator.Kafka/appsettings.json b/Bikes.Generator.Kafka/appsettings.json new file mode 100644 index 000000000..21582870d --- /dev/null +++ b/Bikes.Generator.Kafka/appsettings.json @@ -0,0 +1,19 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.Hosting.Lifetime": "Information" + } + }, + "Generator": { + "BatchSize": 10, + "PayloadLimit": 100, + "WaitTime": 10000 + }, + "Kafka": { + "BootstrapServers": "bikes-kafka:9092", + "Topic": "bike-rents", + "RetryCount": 10, + "RetryDelayMs": 5000 + } +} \ No newline at end of file diff --git a/Bikes.Infrastructure.EfCore/Bikes.Infrastructure.EfCore.csproj b/Bikes.Infrastructure.EfCore/Bikes.Infrastructure.EfCore.csproj new file mode 100644 index 000000000..9833c6855 --- /dev/null +++ b/Bikes.Infrastructure.EfCore/Bikes.Infrastructure.EfCore.csproj @@ -0,0 +1,24 @@ + + + net8.0 + enable + enable + + + + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + + + \ No newline at end of file diff --git a/Bikes.Infrastructure.EfCore/BikesDbContext.cs b/Bikes.Infrastructure.EfCore/BikesDbContext.cs new file mode 100644 index 000000000..2266ede0e --- /dev/null +++ b/Bikes.Infrastructure.EfCore/BikesDbContext.cs @@ -0,0 +1,87 @@ +using Microsoft.EntityFrameworkCore; +using Bikes.Domain.Models; + +namespace Bikes.Infrastructure.EfCore; + +/// +/// Database context for bikes rental system +/// +public class BikesDbContext(DbContextOptions options) : DbContext(options) +{ + /// + /// Bike models table + /// + public DbSet BikeModels { get; set; } = null!; + + /// + /// Bikes table + /// + public DbSet Bikes { get; set; } = null!; + + /// + /// Renters table + /// + public DbSet Renters { get; set; } = null!; + + /// + /// Rents table + /// + public DbSet Rents { get; set; } = null!; + + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + base.OnModelCreating(modelBuilder); + + // BikeModel configuration + modelBuilder.Entity(entity => + { + entity.HasKey(e => e.Id); + entity.Property(e => e.Name).IsRequired().HasMaxLength(100); + entity.Property(e => e.Type).IsRequired().HasConversion(); + entity.Property(e => e.WheelSize).HasPrecision(5, 2); + entity.Property(e => e.MaxWeight).HasPrecision(7, 2); + entity.Property(e => e.Weight).HasPrecision(7, 2); + entity.Property(e => e.BrakeType).IsRequired().HasMaxLength(50); + entity.Property(e => e.PricePerHour).HasPrecision(10, 2); + }); + + // Bike configuration + modelBuilder.Entity(entity => + { + entity.HasKey(e => e.Id); + entity.Property(e => e.SerialNumber).IsRequired().HasMaxLength(50); + entity.Property(e => e.Color).IsRequired().HasMaxLength(30); + + entity.HasOne(e => e.Model) + .WithMany(m => m.Bikes) + .HasForeignKey(e => e.ModelId) + .OnDelete(DeleteBehavior.Restrict); + }); + + // Renter configuration + modelBuilder.Entity(entity => + { + entity.HasKey(e => e.Id); + entity.Property(e => e.FullName).IsRequired().HasMaxLength(100); + entity.Property(e => e.Phone).IsRequired().HasMaxLength(20); + }); + + // Rent configuration + modelBuilder.Entity(entity => + { + entity.HasKey(e => e.Id); + entity.Property(e => e.StartTime).IsRequired(); + entity.Property(e => e.DurationHours).IsRequired(); + + entity.HasOne(e => e.Bike) + .WithMany(b => b.Rents) + .HasForeignKey(e => e.BikeId) + .OnDelete(DeleteBehavior.Restrict); + + entity.HasOne(e => e.Renter) + .WithMany(r => r.Rents) + .HasForeignKey(e => e.RenterId) + .OnDelete(DeleteBehavior.Restrict); + }); + } +} \ No newline at end of file diff --git a/Bikes.Infrastructure.EfCore/DataSeeder.cs b/Bikes.Infrastructure.EfCore/DataSeeder.cs new file mode 100644 index 000000000..90ddf024f --- /dev/null +++ b/Bikes.Infrastructure.EfCore/DataSeeder.cs @@ -0,0 +1,179 @@ +using Bikes.Domain.Models; +using Microsoft.EntityFrameworkCore; + +namespace Bikes.Infrastructure.EfCore; + +/// +/// Seeds initial data to database +/// +public static class DataSeeder +{ + /// + /// Seed initial data + /// + public static void Seed(this BikesDbContext context) + { + if (context.BikeModels.Any()) return; + + var models = InitializeModels(); + var bikes = InitializeBikes(models); + var renters = InitializeRenters(); + var rents = InitializeRents(bikes, renters); + + context.BikeModels.AddRange(models); + context.Bikes.AddRange(bikes); + context.Renters.AddRange(renters); + context.Rents.AddRange(rents); + + context.SaveChanges(); + + FixSequences(context); + } + + private static void FixSequences(BikesDbContext context) + { + var tables = new[] { "bike_models", "bikes", "renters", "rents" }; + + foreach (var table in tables) + { + var sql = $@" + SELECT setval( + pg_get_serial_sequence('{table}', 'id'), + COALESCE((SELECT MAX(id) FROM {table}), 1), + true + )"; + + context.Database.ExecuteSqlRaw(sql); + } + } + + private static List InitializeModels() + { + return + [ + new() { Id = 1, Name = "Sport Pro 1000", Type = BikeType.Sport, WheelSize = 28, MaxWeight = 120, Weight = 10, BrakeType = "Disc", ModelYear = 2023, PricePerHour = 15 }, + new() { Id = 2, Name = "Mountain Extreme", Type = BikeType.Mountain, WheelSize = 29, MaxWeight = 130, Weight = 12, BrakeType = "Hydraulic", ModelYear = 2023, PricePerHour = 12 }, + new() { Id = 3, Name = "Road Racer", Type = BikeType.Road, WheelSize = 26, MaxWeight = 110, Weight = 8, BrakeType = "Rim", ModelYear = 2023, PricePerHour = 10 }, + new() { Id = 4, Name = "Sport Elite", Type = BikeType.Sport, WheelSize = 27.5m, MaxWeight = 125, Weight = 11, BrakeType = "Disc", ModelYear = 2023, PricePerHour = 14 }, + new() { Id = 5, Name = "Hybrid Comfort", Type = BikeType.Hybrid, WheelSize = 28, MaxWeight = 135, Weight = 13, BrakeType = "Rim", ModelYear = 2023, PricePerHour = 8 }, + new() { Id = 6, Name = "Electric City", Type = BikeType.Electric, WheelSize = 26, MaxWeight = 140, Weight = 20, BrakeType = "Disc", ModelYear = 2023, PricePerHour = 20 }, + new() { Id = 7, Name = "Sport Lightning", Type = BikeType.Sport, WheelSize = 29, MaxWeight = 115, Weight = 9.5m, BrakeType = "Hydraulic", ModelYear = 2023, PricePerHour = 16 }, + new() { Id = 8, Name = "Mountain King", Type = BikeType.Mountain, WheelSize = 27.5m, MaxWeight = 128, Weight = 11.5m, BrakeType = "Disc", ModelYear = 2023, PricePerHour = 13 }, + new() { Id = 9, Name = "Road Speed", Type = BikeType.Road, WheelSize = 28, MaxWeight = 105, Weight = 7.5m, BrakeType = "Rim", ModelYear = 2023, PricePerHour = 11 }, + new() { Id = 10, Name = "Sport Thunder", Type = BikeType.Sport, WheelSize = 26, MaxWeight = 122, Weight = 10.5m, BrakeType = "Disc", ModelYear = 2023, PricePerHour = 15.5m }, + new() { Id = 11, Name = "Electric Mountain", Type = BikeType.Electric, WheelSize = 29, MaxWeight = 145, Weight = 22, BrakeType = "Hydraulic", ModelYear = 2023, PricePerHour = 25 } + ]; + } + + private static List InitializeBikes(List models) + { + var bikes = new List(); + var colors = new[] { "Red", "Blue", "Green", "Black", "White", "Yellow" }; + + var bikeConfigurations = new[] + { + new { ModelIndex = 0, ColorIndex = 0 }, + new { ModelIndex = 1, ColorIndex = 1 }, + new { ModelIndex = 2, ColorIndex = 2 }, + new { ModelIndex = 3, ColorIndex = 3 }, + new { ModelIndex = 4, ColorIndex = 4 }, + new { ModelIndex = 5, ColorIndex = 5 }, + new { ModelIndex = 6, ColorIndex = 0 }, + new { ModelIndex = 7, ColorIndex = 1 }, + new { ModelIndex = 8, ColorIndex = 2 }, + new { ModelIndex = 9, ColorIndex = 3 }, + new { ModelIndex = 10, ColorIndex = 4 }, + new { ModelIndex = 0, ColorIndex = 5 }, + new { ModelIndex = 1, ColorIndex = 0 }, + new { ModelIndex = 2, ColorIndex = 1 }, + new { ModelIndex = 3, ColorIndex = 2 } + }; + + for (var i = 0; i < bikeConfigurations.Length; i++) + { + var config = bikeConfigurations[i]; + var model = models[config.ModelIndex]; + var color = colors[config.ColorIndex]; + + bikes.Add(new Bike + { + Id = i + 1, + SerialNumber = $"SN{(i + 1):D6}", + ModelId = model.Id, + Model = model, + Color = color, + IsAvailable = i % 2 == 0 + }); + } + + return bikes; + } + + private static List InitializeRenters() + { + return + [ + new() { Id = 1, FullName = "Ivanov Ivan", Phone = "+79111111111" }, + new() { Id = 2, FullName = "Petrov Petr", Phone = "+79112222222" }, + new() { Id = 3, FullName = "Sidorov Alexey", Phone = "+79113333333" }, + new() { Id = 4, FullName = "Kuznetsova Maria", Phone = "+79114444444" }, + new() { Id = 5, FullName = "Smirnov Dmitry", Phone = "+79115555555" }, + new() { Id = 6, FullName = "Vasilyeva Ekaterina", Phone = "+79116666666" }, + new() { Id = 7, FullName = "Popov Artem", Phone = "+79117777777" }, + new() { Id = 8, FullName = "Lebedeva Olga", Phone = "+79118888888" }, + new() { Id = 9, FullName = "Novikov Sergey", Phone = "+79119999999" }, + new() { Id = 10, FullName = "Morozova Anna", Phone = "+79110000000" }, + new() { Id = 11, FullName = "Volkov Pavel", Phone = "+79121111111" }, + new() { Id = 12, FullName = "Sokolova Irina", Phone = "+79122222222" } + ]; + } + + private static List InitializeRents(List bikes, List renters) + { + var rents = new List(); + var rentId = 1; + + var rentalData = new[] + { + new { BikeIndex = 0, RenterIndex = 0, Duration = 5, DaysAgo = 2 }, + new { BikeIndex = 1, RenterIndex = 1, Duration = 3, DaysAgo = 5 }, + new { BikeIndex = 2, RenterIndex = 2, Duration = 8, DaysAgo = 1 }, + new { BikeIndex = 0, RenterIndex = 3, Duration = 2, DaysAgo = 7 }, + new { BikeIndex = 3, RenterIndex = 4, Duration = 12, DaysAgo = 3 }, + new { BikeIndex = 1, RenterIndex = 5, Duration = 6, DaysAgo = 4 }, + new { BikeIndex = 4, RenterIndex = 6, Duration = 4, DaysAgo = 6 }, + new { BikeIndex = 2, RenterIndex = 7, Duration = 10, DaysAgo = 2 }, + new { BikeIndex = 5, RenterIndex = 8, Duration = 7, DaysAgo = 1 }, + new { BikeIndex = 3, RenterIndex = 9, Duration = 3, DaysAgo = 8 }, + new { BikeIndex = 6, RenterIndex = 10, Duration = 15, DaysAgo = 2 }, + new { BikeIndex = 4, RenterIndex = 11, Duration = 9, DaysAgo = 5 }, + new { BikeIndex = 7, RenterIndex = 0, Duration = 2, DaysAgo = 3 }, + new { BikeIndex = 5, RenterIndex = 1, Duration = 6, DaysAgo = 4 }, + new { BikeIndex = 8, RenterIndex = 2, Duration = 11, DaysAgo = 1 }, + new { BikeIndex = 6, RenterIndex = 3, Duration = 4, DaysAgo = 7 }, + new { BikeIndex = 9, RenterIndex = 4, Duration = 8, DaysAgo = 2 }, + new { BikeIndex = 7, RenterIndex = 5, Duration = 5, DaysAgo = 5 }, + new { BikeIndex = 10, RenterIndex = 6, Duration = 13, DaysAgo = 3 }, + new { BikeIndex = 8, RenterIndex = 7, Duration = 7, DaysAgo = 4 } + }; + + foreach (var data in rentalData) + { + var bike = bikes[data.BikeIndex]; + var renter = renters[data.RenterIndex]; + + rents.Add(new Rent + { + Id = rentId++, + BikeId = bike.Id, + Bike = bike, + RenterId = renter.Id, + Renter = renter, + StartTime = DateTime.UtcNow.AddDays(-data.DaysAgo), + DurationHours = data.Duration + }); + } + + return rents; + } +} \ No newline at end of file diff --git a/Bikes.Infrastructure.EfCore/EfCoreBikeModelRepository.cs b/Bikes.Infrastructure.EfCore/EfCoreBikeModelRepository.cs new file mode 100644 index 000000000..27c195b68 --- /dev/null +++ b/Bikes.Infrastructure.EfCore/EfCoreBikeModelRepository.cs @@ -0,0 +1,62 @@ +using Microsoft.EntityFrameworkCore; +using Bikes.Domain.Models; +using Bikes.Domain.Repositories; + +namespace Bikes.Infrastructure.EfCore; + +/// +/// EF Core implementation of bike model repository +/// +public class EfCoreBikeModelRepository(BikesDbContext context) : IBikeModelRepository +{ + /// + /// Get all bike models + /// + public List GetAllModels() + { + return [.. context.BikeModels.AsNoTracking()]; + } + + /// + /// Get bike model by identifier + /// + public BikeModel? GetModelById(int id) + { + return context.BikeModels + .AsNoTracking() + .FirstOrDefault(m => m.Id == id); + } + + /// + /// Add new bike model + /// + public void AddModel(BikeModel model) + { + ArgumentNullException.ThrowIfNull(model); + context.BikeModels.Add(model); + context.SaveChanges(); + } + + /// + /// Update bike model + /// + public void UpdateModel(BikeModel model) + { + ArgumentNullException.ThrowIfNull(model); + context.BikeModels.Update(model); + context.SaveChanges(); + } + + /// + /// Delete bike model + /// + public void DeleteModel(int id) + { + var model = context.BikeModels.Find(id); + if (model != null) + { + context.BikeModels.Remove(model); + context.SaveChanges(); + } + } +} \ No newline at end of file diff --git a/Bikes.Infrastructure.EfCore/EfCoreBikeRepository.cs b/Bikes.Infrastructure.EfCore/EfCoreBikeRepository.cs new file mode 100644 index 000000000..2085cc40d --- /dev/null +++ b/Bikes.Infrastructure.EfCore/EfCoreBikeRepository.cs @@ -0,0 +1,65 @@ +using Microsoft.EntityFrameworkCore; +using Bikes.Domain.Models; +using Bikes.Domain.Repositories; + +namespace Bikes.Infrastructure.EfCore; + +/// +/// EF Core implementation of bike repository +/// +public class EfCoreBikeRepository(BikesDbContext context) : IBikeRepository +{ + /// + /// Get all bikes + /// + public List GetAllBikes() + { + return [.. context.Bikes + .Include(b => b.Model) + .AsNoTracking()]; + } + + /// + /// Get bike by identifier + /// + public Bike? GetBikeById(int id) + { + return context.Bikes + .Include(b => b.Model) + .AsNoTracking() + .FirstOrDefault(b => b.Id == id); + } + + /// + /// Add new bike + /// + public void AddBike(Bike bike) + { + ArgumentNullException.ThrowIfNull(bike); + context.Bikes.Add(bike); + context.SaveChanges(); + } + + /// + /// Update bike + /// + public void UpdateBike(Bike bike) + { + ArgumentNullException.ThrowIfNull(bike); + context.Bikes.Update(bike); + context.SaveChanges(); + } + + /// + /// Delete bike + /// + public void DeleteBike(int id) + { + var bike = context.Bikes.Find(id); + if (bike != null) + { + context.Bikes.Remove(bike); + context.SaveChanges(); + } + } +} \ No newline at end of file diff --git a/Bikes.Infrastructure.EfCore/EfCoreRentRepository.cs b/Bikes.Infrastructure.EfCore/EfCoreRentRepository.cs new file mode 100644 index 000000000..46d211c35 --- /dev/null +++ b/Bikes.Infrastructure.EfCore/EfCoreRentRepository.cs @@ -0,0 +1,85 @@ +using Microsoft.EntityFrameworkCore; +using Bikes.Domain.Models; +using Bikes.Domain.Repositories; + +namespace Bikes.Infrastructure.EfCore; + +/// +/// EF Core implementation of rent repository +/// +public class EfCoreRentRepository(BikesDbContext context) : IRentRepository +{ + /// + /// Get all rents + /// + public List GetAllRents() + { + return [.. context.Rents + .Include(r => r.Bike) + .ThenInclude(b => b.Model) + .Include(r => r.Renter) + .AsNoTracking()]; + } + + /// + /// Get rent by identifier + /// + public Rent? GetRentById(int id) + { + return context.Rents + .Include(r => r.Bike) + .ThenInclude(b => b.Model) + .Include(r => r.Renter) + .AsNoTracking() + .FirstOrDefault(r => r.Id == id); + } + + /// + /// Add new rent + /// + public void AddRent(Rent rent) + { + ArgumentNullException.ThrowIfNull(rent); + + if (rent.Bike != null && context.Entry(rent.Bike).State == EntityState.Detached) + { + context.Attach(rent.Bike); + + if (rent.Bike.Model != null && context.Entry(rent.Bike.Model).State == EntityState.Detached) + { + context.Attach(rent.Bike.Model); + } + } + + if (rent.Renter != null && context.Entry(rent.Renter).State == EntityState.Detached) + { + context.Attach(rent.Renter); + } + + context.Rents.Add(rent); + context.SaveChanges(); + } + + /// + /// Update rent + /// + public void UpdateRent(Rent rent) + { + ArgumentNullException.ThrowIfNull(rent); + context.Rents.Update(rent); + context.SaveChanges(); + } + + /// + /// Delete rent + /// + public void DeleteRent(int id) + { + var rent = context.Rents.Find(id); + if (rent != null) + { + context.Rents.Remove(rent); + context.SaveChanges(); + } + } +} \ No newline at end of file diff --git a/Bikes.Infrastructure.EfCore/EfCoreRenterRepository.cs b/Bikes.Infrastructure.EfCore/EfCoreRenterRepository.cs new file mode 100644 index 000000000..cb1f6aec0 --- /dev/null +++ b/Bikes.Infrastructure.EfCore/EfCoreRenterRepository.cs @@ -0,0 +1,62 @@ +using Microsoft.EntityFrameworkCore; +using Bikes.Domain.Models; +using Bikes.Domain.Repositories; + +namespace Bikes.Infrastructure.EfCore; + +/// +/// EF Core implementation of renter repository +/// +public class EfCoreRenterRepository(BikesDbContext context) : IRenterRepository +{ + /// + /// Get all renters + /// + public List GetAllRenters() + { + return [.. context.Renters.AsNoTracking()]; + } + + /// + /// Get renter by identifier + /// + public Renter? GetRenterById(int id) + { + return context.Renters + .AsNoTracking() + .FirstOrDefault(r => r.Id == id); + } + + /// + /// Add new renter + /// + public void AddRenter(Renter renter) + { + ArgumentNullException.ThrowIfNull(renter); + context.Renters.Add(renter); + context.SaveChanges(); + } + + /// + /// Update renter + /// + public void UpdateRenter(Renter renter) + { + ArgumentNullException.ThrowIfNull(renter); + context.Renters.Update(renter); + context.SaveChanges(); + } + + /// + /// Delete renter + /// + public void DeleteRenter(int id) + { + var renter = context.Renters.Find(id); + if (renter != null) + { + context.Renters.Remove(renter); + context.SaveChanges(); + } + } +} \ No newline at end of file diff --git a/Bikes.Infrastructure.EfCore/Migrations/20251117201300_Initial.Designer.cs b/Bikes.Infrastructure.EfCore/Migrations/20251117201300_Initial.Designer.cs new file mode 100644 index 000000000..99e06b90f --- /dev/null +++ b/Bikes.Infrastructure.EfCore/Migrations/20251117201300_Initial.Designer.cs @@ -0,0 +1,226 @@ +// +using System; +using Bikes.Infrastructure.EfCore; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; + +#nullable disable + +namespace Bikes.Infrastructure.EfCore.Migrations +{ + [DbContext(typeof(BikesDbContext))] + [Migration("20251117201300_Initial")] + partial class Initial + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "8.0.0") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("Bikes.Domain.Models.Bike", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasColumnName("id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("Color") + .IsRequired() + .HasMaxLength(30) + .HasColumnType("character varying(30)") + .HasColumnName("color"); + + b.Property("IsAvailable") + .HasColumnType("boolean") + .HasColumnName("is_available"); + + b.Property("ModelId") + .HasColumnType("integer") + .HasColumnName("model_id"); + + b.Property("SerialNumber") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)") + .HasColumnName("serial_number"); + + b.HasKey("Id"); + + b.HasIndex("ModelId"); + + b.ToTable("bikes"); + }); + + modelBuilder.Entity("Bikes.Domain.Models.BikeModel", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasColumnName("id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("BrakeType") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)") + .HasColumnName("brake_type"); + + b.Property("MaxWeight") + .HasPrecision(7, 2) + .HasColumnType("numeric(7,2)") + .HasColumnName("max_weight"); + + b.Property("ModelYear") + .HasColumnType("integer") + .HasColumnName("model_year"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("name"); + + b.Property("PricePerHour") + .HasPrecision(10, 2) + .HasColumnType("numeric(10,2)") + .HasColumnName("price_per_hour"); + + b.Property("Type") + .IsRequired() + .HasColumnType("text") + .HasColumnName("type"); + + b.Property("Weight") + .HasPrecision(7, 2) + .HasColumnType("numeric(7,2)") + .HasColumnName("weight"); + + b.Property("WheelSize") + .HasPrecision(5, 2) + .HasColumnType("numeric(5,2)") + .HasColumnName("wheel_size"); + + b.HasKey("Id"); + + b.ToTable("bike_models"); + }); + + modelBuilder.Entity("Bikes.Domain.Models.Rent", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasColumnName("id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("BikeId") + .HasColumnType("integer") + .HasColumnName("bike_id"); + + b.Property("DurationHours") + .HasColumnType("integer") + .HasColumnName("duration_hours"); + + b.Property("RenterId") + .HasColumnType("integer") + .HasColumnName("renter_id"); + + b.Property("StartTime") + .HasColumnType("timestamp with time zone") + .HasColumnName("start_time"); + + b.HasKey("Id"); + + b.HasIndex("BikeId"); + + b.HasIndex("RenterId"); + + b.ToTable("rents"); + }); + + modelBuilder.Entity("Bikes.Domain.Models.Renter", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasColumnName("id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("FullName") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("full_name"); + + b.Property("Phone") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)") + .HasColumnName("phone"); + + b.HasKey("Id"); + + b.ToTable("renters"); + }); + + modelBuilder.Entity("Bikes.Domain.Models.Bike", b => + { + b.HasOne("Bikes.Domain.Models.BikeModel", "Model") + .WithMany("Bikes") + .HasForeignKey("ModelId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Model"); + }); + + modelBuilder.Entity("Bikes.Domain.Models.Rent", b => + { + b.HasOne("Bikes.Domain.Models.Bike", "Bike") + .WithMany("Rents") + .HasForeignKey("BikeId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("Bikes.Domain.Models.Renter", "Renter") + .WithMany("Rents") + .HasForeignKey("RenterId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Bike"); + + b.Navigation("Renter"); + }); + + modelBuilder.Entity("Bikes.Domain.Models.Bike", b => + { + b.Navigation("Rents"); + }); + + modelBuilder.Entity("Bikes.Domain.Models.BikeModel", b => + { + b.Navigation("Bikes"); + }); + + modelBuilder.Entity("Bikes.Domain.Models.Renter", b => + { + b.Navigation("Rents"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/Bikes.Infrastructure.EfCore/Migrations/20251117201300_Initial.cs b/Bikes.Infrastructure.EfCore/Migrations/20251117201300_Initial.cs new file mode 100644 index 000000000..fbb96b66c --- /dev/null +++ b/Bikes.Infrastructure.EfCore/Migrations/20251117201300_Initial.cs @@ -0,0 +1,129 @@ +using Microsoft.EntityFrameworkCore.Migrations; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; + +#nullable disable + +namespace Bikes.Infrastructure.EfCore.Migrations; + +/// +public partial class Initial : Migration +{ + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "bike_models", + columns: table => new + { + id = table.Column(type: "integer", nullable: false) + .Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn), + name = table.Column(type: "character varying(100)", maxLength: 100, nullable: false), + type = table.Column(type: "text", nullable: false), + wheel_size = table.Column(type: "numeric(5,2)", precision: 5, scale: 2, nullable: false), + max_weight = table.Column(type: "numeric(7,2)", precision: 7, scale: 2, nullable: false), + weight = table.Column(type: "numeric(7,2)", precision: 7, scale: 2, nullable: false), + brake_type = table.Column(type: "character varying(50)", maxLength: 50, nullable: false), + model_year = table.Column(type: "integer", nullable: false), + price_per_hour = table.Column(type: "numeric(10,2)", precision: 10, scale: 2, nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_bike_models", x => x.id); + }); + + migrationBuilder.CreateTable( + name: "renters", + columns: table => new + { + id = table.Column(type: "integer", nullable: false) + .Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn), + full_name = table.Column(type: "character varying(100)", maxLength: 100, nullable: false), + phone = table.Column(type: "character varying(20)", maxLength: 20, nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_renters", x => x.id); + }); + + migrationBuilder.CreateTable( + name: "bikes", + columns: table => new + { + id = table.Column(type: "integer", nullable: false) + .Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn), + serial_number = table.Column(type: "character varying(50)", maxLength: 50, nullable: false), + model_id = table.Column(type: "integer", nullable: false), + color = table.Column(type: "character varying(30)", maxLength: 30, nullable: false), + is_available = table.Column(type: "boolean", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_bikes", x => x.id); + table.ForeignKey( + name: "FK_bikes_bike_models_model_id", + column: x => x.model_id, + principalTable: "bike_models", + principalColumn: "id", + onDelete: ReferentialAction.Restrict); + }); + + migrationBuilder.CreateTable( + name: "rents", + columns: table => new + { + id = table.Column(type: "integer", nullable: false) + .Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn), + bike_id = table.Column(type: "integer", nullable: false), + renter_id = table.Column(type: "integer", nullable: false), + start_time = table.Column(type: "timestamp with time zone", nullable: false), + duration_hours = table.Column(type: "integer", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_rents", x => x.id); + table.ForeignKey( + name: "FK_rents_bikes_bike_id", + column: x => x.bike_id, + principalTable: "bikes", + principalColumn: "id", + onDelete: ReferentialAction.Restrict); + table.ForeignKey( + name: "FK_rents_renters_renter_id", + column: x => x.renter_id, + principalTable: "renters", + principalColumn: "id", + onDelete: ReferentialAction.Restrict); + }); + + migrationBuilder.CreateIndex( + name: "IX_bikes_model_id", + table: "bikes", + column: "model_id"); + + migrationBuilder.CreateIndex( + name: "IX_rents_bike_id", + table: "rents", + column: "bike_id"); + + migrationBuilder.CreateIndex( + name: "IX_rents_renter_id", + table: "rents", + column: "renter_id"); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "rents"); + + migrationBuilder.DropTable( + name: "bikes"); + + migrationBuilder.DropTable( + name: "renters"); + + migrationBuilder.DropTable( + name: "bike_models"); + } +} \ No newline at end of file diff --git a/Bikes.Infrastructure.EfCore/Migrations/BikesDbContextModelSnapshot.cs b/Bikes.Infrastructure.EfCore/Migrations/BikesDbContextModelSnapshot.cs new file mode 100644 index 000000000..071076da6 --- /dev/null +++ b/Bikes.Infrastructure.EfCore/Migrations/BikesDbContextModelSnapshot.cs @@ -0,0 +1,223 @@ +// +using System; +using Bikes.Infrastructure.EfCore; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; + +#nullable disable + +namespace Bikes.Infrastructure.EfCore.Migrations +{ + [DbContext(typeof(BikesDbContext))] + partial class BikesDbContextModelSnapshot : ModelSnapshot + { + protected override void BuildModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "8.0.0") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("Bikes.Domain.Models.Bike", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasColumnName("id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("Color") + .IsRequired() + .HasMaxLength(30) + .HasColumnType("character varying(30)") + .HasColumnName("color"); + + b.Property("IsAvailable") + .HasColumnType("boolean") + .HasColumnName("is_available"); + + b.Property("ModelId") + .HasColumnType("integer") + .HasColumnName("model_id"); + + b.Property("SerialNumber") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)") + .HasColumnName("serial_number"); + + b.HasKey("Id"); + + b.HasIndex("ModelId"); + + b.ToTable("bikes"); + }); + + modelBuilder.Entity("Bikes.Domain.Models.BikeModel", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasColumnName("id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("BrakeType") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)") + .HasColumnName("brake_type"); + + b.Property("MaxWeight") + .HasPrecision(7, 2) + .HasColumnType("numeric(7,2)") + .HasColumnName("max_weight"); + + b.Property("ModelYear") + .HasColumnType("integer") + .HasColumnName("model_year"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("name"); + + b.Property("PricePerHour") + .HasPrecision(10, 2) + .HasColumnType("numeric(10,2)") + .HasColumnName("price_per_hour"); + + b.Property("Type") + .IsRequired() + .HasColumnType("text") + .HasColumnName("type"); + + b.Property("Weight") + .HasPrecision(7, 2) + .HasColumnType("numeric(7,2)") + .HasColumnName("weight"); + + b.Property("WheelSize") + .HasPrecision(5, 2) + .HasColumnType("numeric(5,2)") + .HasColumnName("wheel_size"); + + b.HasKey("Id"); + + b.ToTable("bike_models"); + }); + + modelBuilder.Entity("Bikes.Domain.Models.Rent", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasColumnName("id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("BikeId") + .HasColumnType("integer") + .HasColumnName("bike_id"); + + b.Property("DurationHours") + .HasColumnType("integer") + .HasColumnName("duration_hours"); + + b.Property("RenterId") + .HasColumnType("integer") + .HasColumnName("renter_id"); + + b.Property("StartTime") + .HasColumnType("timestamp with time zone") + .HasColumnName("start_time"); + + b.HasKey("Id"); + + b.HasIndex("BikeId"); + + b.HasIndex("RenterId"); + + b.ToTable("rents"); + }); + + modelBuilder.Entity("Bikes.Domain.Models.Renter", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasColumnName("id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("FullName") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("full_name"); + + b.Property("Phone") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)") + .HasColumnName("phone"); + + b.HasKey("Id"); + + b.ToTable("renters"); + }); + + modelBuilder.Entity("Bikes.Domain.Models.Bike", b => + { + b.HasOne("Bikes.Domain.Models.BikeModel", "Model") + .WithMany("Bikes") + .HasForeignKey("ModelId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Model"); + }); + + modelBuilder.Entity("Bikes.Domain.Models.Rent", b => + { + b.HasOne("Bikes.Domain.Models.Bike", "Bike") + .WithMany("Rents") + .HasForeignKey("BikeId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("Bikes.Domain.Models.Renter", "Renter") + .WithMany("Rents") + .HasForeignKey("RenterId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Bike"); + + b.Navigation("Renter"); + }); + + modelBuilder.Entity("Bikes.Domain.Models.Bike", b => + { + b.Navigation("Rents"); + }); + + modelBuilder.Entity("Bikes.Domain.Models.BikeModel", b => + { + b.Navigation("Bikes"); + }); + + modelBuilder.Entity("Bikes.Domain.Models.Renter", b => + { + b.Navigation("Rents"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/Bikes.Infrastructure.EfCore/Properties/launchSettings.json b/Bikes.Infrastructure.EfCore/Properties/launchSettings.json new file mode 100644 index 000000000..1cac07450 --- /dev/null +++ b/Bikes.Infrastructure.EfCore/Properties/launchSettings.json @@ -0,0 +1,12 @@ +{ + "profiles": { + "Bikes.Infrastructure.EfCore": { + "commandName": "Project", + "launchBrowser": true, + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + }, + "applicationUrl": "https://localhost:50514;http://localhost:50515" + } + } +} \ No newline at end of file diff --git a/Bikes.Infrastructure.InMemory/Bikes.Infrastructure.InMemory.csproj b/Bikes.Infrastructure.InMemory/Bikes.Infrastructure.InMemory.csproj new file mode 100644 index 000000000..f02574c1f --- /dev/null +++ b/Bikes.Infrastructure.InMemory/Bikes.Infrastructure.InMemory.csproj @@ -0,0 +1,14 @@ + + + + net8.0 + enable + enable + + + + + + + + \ No newline at end of file diff --git a/Bikes.Infrastructure.InMemory/Repositories/InMemoryBikeRepository.cs b/Bikes.Infrastructure.InMemory/Repositories/InMemoryBikeRepository.cs new file mode 100644 index 000000000..5a191334c --- /dev/null +++ b/Bikes.Infrastructure.InMemory/Repositories/InMemoryBikeRepository.cs @@ -0,0 +1,112 @@ +using Bikes.Domain.Models; +using Bikes.Domain.Repositories; +using Bikes.Tests; + +namespace Bikes.Infrastructure.InMemory.Repositories; + +/// +/// In-memory implementation of bike repository +/// +public class InMemoryBikeRepository() : IBikeRepository +{ + private readonly List _bikes = [.. new BikesFixture().Bikes]; + private readonly List _models = [.. new BikesFixture().Models]; + private readonly List _rents = [.. new BikesFixture().Rents]; + private readonly List _renters = [.. new BikesFixture().Renters]; + + // Bike methods + public List GetAllBikes() => [.. _bikes]; + + public Bike? GetBikeById(int id) => _bikes.FirstOrDefault(b => b.Id == id); + + public void AddBike(Bike bike) + { + ArgumentNullException.ThrowIfNull(bike); + _bikes.Add(bike); + } + + public void UpdateBike(Bike bike) + { + ArgumentNullException.ThrowIfNull(bike); + var existingBike = _bikes.FirstOrDefault(b => b.Id == bike.Id); + if (existingBike != null) + { + _bikes.Remove(existingBike); + _bikes.Add(bike); + } + } + + public void DeleteBike(int id) => _bikes.RemoveAll(b => b.Id == id); + + // BikeModel methods + public List GetAllModels() => [.. _models]; + + public BikeModel? GetModelById(int id) => _models.FirstOrDefault(m => m.Id == id); + + public void AddModel(BikeModel model) + { + ArgumentNullException.ThrowIfNull(model); + _models.Add(model); + } + + public void UpdateModel(BikeModel model) + { + ArgumentNullException.ThrowIfNull(model); + var existingModel = _models.FirstOrDefault(m => m.Id == model.Id); + if (existingModel != null) + { + _models.Remove(existingModel); + _models.Add(model); + } + } + + public void DeleteModel(int id) => _models.RemoveAll(m => m.Id == id); + + // Renter methods + public List GetAllRenters() => [.. _renters]; + + public Renter? GetRenterById(int id) => _renters.FirstOrDefault(r => r.Id == id); + + public void AddRenter(Renter renter) + { + ArgumentNullException.ThrowIfNull(renter); + _renters.Add(renter); + } + + public void UpdateRenter(Renter renter) + { + ArgumentNullException.ThrowIfNull(renter); + var existingRenter = _renters.FirstOrDefault(r => r.Id == renter.Id); + if (existingRenter != null) + { + _renters.Remove(existingRenter); + _renters.Add(renter); + } + } + + public void DeleteRenter(int id) => _renters.RemoveAll(r => r.Id == id); + + // Rent methods + public List GetAllRents() => [.. _rents]; + + public Rent? GetRentById(int id) => _rents.FirstOrDefault(r => r.Id == id); + + public void AddRent(Rent rent) + { + ArgumentNullException.ThrowIfNull(rent); + _rents.Add(rent); + } + + public void UpdateRent(Rent rent) + { + ArgumentNullException.ThrowIfNull(rent); + var existingRent = _rents.FirstOrDefault(r => r.Id == rent.Id); + if (existingRent != null) + { + _rents.Remove(existingRent); + _rents.Add(rent); + } + } + + public void DeleteRent(int id) => _rents.RemoveAll(r => r.Id == id); +} \ No newline at end of file diff --git a/Bikes.Infrastructure.Kafka/Bikes.Infrastructure.Kafka.csproj b/Bikes.Infrastructure.Kafka/Bikes.Infrastructure.Kafka.csproj new file mode 100644 index 000000000..ae5c34526 --- /dev/null +++ b/Bikes.Infrastructure.Kafka/Bikes.Infrastructure.Kafka.csproj @@ -0,0 +1,28 @@ + + + + net8.0 + enable + enable + dotnet-Bikes.Infrastructure.Kafka-33d6809d-7458-4668-a600-1d82cb896a7d + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/Bikes.Infrastructure.Kafka/Deserializers/KeyDeserializer.cs b/Bikes.Infrastructure.Kafka/Deserializers/KeyDeserializer.cs new file mode 100644 index 000000000..495bcb582 --- /dev/null +++ b/Bikes.Infrastructure.Kafka/Deserializers/KeyDeserializer.cs @@ -0,0 +1,16 @@ +using Confluent.Kafka; +using System.Text; + +namespace Bikes.Infrastructure.Kafka.Deserializers; + +public class KeyDeserializer : IDeserializer +{ + public Guid Deserialize(ReadOnlySpan data, bool isNull, SerializationContext context) + { + if (isNull || data.Length == 0) + return Guid.Empty; + + var guidString = Encoding.UTF8.GetString(data); + return Guid.Parse(guidString); + } +} \ No newline at end of file diff --git a/Bikes.Infrastructure.Kafka/Deserializers/ValueDeserializer.cs b/Bikes.Infrastructure.Kafka/Deserializers/ValueDeserializer.cs new file mode 100644 index 000000000..fa13da0e6 --- /dev/null +++ b/Bikes.Infrastructure.Kafka/Deserializers/ValueDeserializer.cs @@ -0,0 +1,27 @@ +using Confluent.Kafka; +using System.Text; +using System.Text.Json; +using Bikes.Application.Contracts.Rents; + +namespace Bikes.Infrastructure.Kafka.Deserializers; + +public class ValueDeserializer : IDeserializer> +{ + public IList Deserialize(ReadOnlySpan data, bool isNull, SerializationContext context) + { + if (isNull || data.Length == 0) + return []; + + var json = Encoding.UTF8.GetString(data); + + try + { + var result = JsonSerializer.Deserialize>(json); + return result ?? []; + } + catch (JsonException ex) + { + throw new InvalidOperationException($"Failed to deserialize JSON: {json}", ex); + } + } +} \ No newline at end of file diff --git a/Bikes.Infrastructure.Kafka/KafkaConsumer.cs b/Bikes.Infrastructure.Kafka/KafkaConsumer.cs new file mode 100644 index 000000000..854a22828 --- /dev/null +++ b/Bikes.Infrastructure.Kafka/KafkaConsumer.cs @@ -0,0 +1,203 @@ +using Confluent.Kafka; +using Bikes.Application.Contracts.Rents; +using Bikes.Infrastructure.Kafka.Deserializers; + +namespace Bikes.Infrastructure.Kafka; + +/// +/// Background service that consumes rent contract messages from Kafka topics +/// +public class KafkaConsumer : BackgroundService +{ + private readonly IConsumer> _consumer; + private readonly ILogger _logger; + private readonly IServiceScopeFactory _scopeFactory; + private readonly string _topic; + private readonly string _bootstrapServers; + + public KafkaConsumer( + IConfiguration configuration, + ILogger logger, + KeyDeserializer keyDeserializer, + ValueDeserializer valueDeserializer, + IServiceScopeFactory scopeFactory) + { + _logger = logger; + _scopeFactory = scopeFactory; + + var kafkaConfig = configuration.GetSection("Kafka"); + _topic = kafkaConfig["Topic"] ?? "bike-rents"; + + _bootstrapServers = configuration.GetConnectionString("bikes-kafka") + ?? configuration["Kafka:BootstrapServers"] + ?? "localhost:9092"; + + var groupId = kafkaConfig["GroupId"] ?? "bikes-consumer-group"; + + var consumerConfig = new ConsumerConfig + { + BootstrapServers = _bootstrapServers, + GroupId = groupId, + AutoOffsetReset = AutoOffsetReset.Earliest, + EnableAutoCommit = true, + AllowAutoCreateTopics = true, + EnablePartitionEof = true, + MaxPollIntervalMs = 300000, + SessionTimeoutMs = 45000 + }; + + _consumer = new ConsumerBuilder>(consumerConfig) + .SetKeyDeserializer(keyDeserializer) + .SetValueDeserializer(valueDeserializer) + .SetErrorHandler((_, e) => + { + if (e.IsFatal) + _logger.LogError("Kafka Fatal Error: {Reason} (Code: {Code})", e.Reason, e.Code); + else + _logger.LogWarning("Kafka Error: {Reason} (Code: {Code})", e.Reason, e.Code); + }) + .SetLogHandler((_, logMessage) => + { + _logger.LogDebug("Kafka Log: {Facility} - {Message}", logMessage.Facility, logMessage.Message); + }) + .Build(); + + _logger.LogInformation("Kafka Consumer configured for: {Servers}", _bootstrapServers); + } + + protected override async Task ExecuteAsync(CancellationToken stoppingToken) + { + _logger.LogInformation("Starting Kafka consumer for topic: {Topic}", _topic); + + await WaitForKafkaAvailableAsync(stoppingToken); + + while (!stoppingToken.IsCancellationRequested) + { + try + { + _consumer.Subscribe(_topic); + _logger.LogInformation("Subscribed to Kafka topic: {Topic}", _topic); + + while (!stoppingToken.IsCancellationRequested) + { + try + { + var result = _consumer.Consume(stoppingToken); + + if (result == null) + continue; + + if (result.IsPartitionEOF) + { + _logger.LogDebug("Reached end of partition {Partition}", result.Partition); + continue; + } + + _logger.LogInformation( + "Received message {Key} with {Count} rents (Partition: {Partition}, Offset: {Offset})", + result.Message.Key, + result.Message.Value?.Count ?? 0, + result.Partition, + result.Offset + ); + + if (result.Message.Value != null) + { + await ProcessMessageAsync(result.Message.Key, result.Message.Value); + } + } + catch (ConsumeException ex) when (ex.Error.Code == ErrorCode.UnknownTopicOrPart) + { + _logger.LogWarning("Topic {Topic} not available yet, retrying in 5 seconds...", _topic); + await Task.Delay(5000, stoppingToken); + break; + } + catch (ConsumeException ex) + { + _logger.LogError(ex, "Consume error: {Reason} (Code: {Code})", ex.Error.Reason, ex.Error.Code); + await Task.Delay(2000, stoppingToken); + } + } + } + catch (OperationCanceledException) + { + _logger.LogInformation("Kafka consumer cancelled"); + break; + } + catch (Exception ex) + { + _logger.LogError(ex, "Unexpected error in Kafka consumer"); + await Task.Delay(5000, stoppingToken); + } + } + + _logger.LogInformation("Kafka consumer stopped"); + } + + private async Task WaitForKafkaAvailableAsync(CancellationToken stoppingToken) + { + _logger.LogInformation("Waiting for Kafka to become available..."); + + using var adminClient = new AdminClientBuilder(new AdminClientConfig + { + BootstrapServers = _bootstrapServers + }).Build(); + + for (var i = 0; i < 30; i++) + { + try + { + var metadata = adminClient.GetMetadata(TimeSpan.FromSeconds(5)); + _logger.LogInformation("Kafka is available. Brokers: {BrokerCount}", metadata.Brokers.Count); + return; + } + catch (Exception ex) + { + _logger.LogDebug("Kafka not available yet (attempt {Attempt}/30): {Message}", i + 1, ex.Message); + await Task.Delay(5000, stoppingToken); + } + } + + _logger.LogWarning("Kafka still not available after 150 seconds, continuing anyway..."); + } + + private async Task ProcessMessageAsync(Guid key, IList rents) + { + if (rents == null || rents.Count == 0) + { + _logger.LogWarning("Empty rents list for message {Key}", key); + return; + } + + try + { + using var scope = _scopeFactory.CreateScope(); + var rentService = scope.ServiceProvider.GetRequiredService(); + + await rentService.ReceiveContract(rents); + + _logger.LogInformation("Processed message {Key}: {Count} rent contracts saved", + key, rents.Count); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error processing message {Key}", key); + } + } + + public override void Dispose() + { + try + { + _consumer?.Close(); + _consumer?.Dispose(); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error disposing Kafka consumer"); + } + + base.Dispose(); + GC.SuppressFinalize(this); + } +} \ No newline at end of file diff --git a/Bikes.Infrastructure.Kafka/Program.cs b/Bikes.Infrastructure.Kafka/Program.cs new file mode 100644 index 000000000..4bcf2bc87 --- /dev/null +++ b/Bikes.Infrastructure.Kafka/Program.cs @@ -0,0 +1,31 @@ +using Bikes.Infrastructure.Kafka; +using Bikes.Infrastructure.Kafka.Deserializers; +using Bikes.ServiceDefaults; +using Microsoft.EntityFrameworkCore; + +var builder = Host.CreateApplicationBuilder(args); + +builder.AddServiceDefaults(); + +builder.Services.AddSingleton(); +builder.Services.AddSingleton(); + +builder.Services.AddDbContext(options => +{ + var connectionString = builder.Configuration.GetConnectionString("bikes-db") + ?? "Host=localhost;Port=5432;Database=bikes;Username=postgres;Password=postgres"; + options.UseNpgsql(connectionString); +}); + +builder.Services.AddScoped(); +builder.Services.AddScoped(); +builder.Services.AddScoped(); + +builder.Services.AddScoped(); +builder.Services.AddScoped(sp => + sp.GetRequiredService()); + +builder.Services.AddHostedService(); + +var host = builder.Build(); +await host.RunAsync(); \ No newline at end of file diff --git a/Bikes.Infrastructure.Kafka/Properties/launchSettings.json b/Bikes.Infrastructure.Kafka/Properties/launchSettings.json new file mode 100644 index 000000000..f3b7eff27 --- /dev/null +++ b/Bikes.Infrastructure.Kafka/Properties/launchSettings.json @@ -0,0 +1,11 @@ +{ + "profiles": { + "Bikes.Infrastructure.Kafka": { + "commandName": "Project", + "dotnetRunMessages": true, + "environmentVariables": { + "DOTNET_ENVIRONMENT": "Development" + } + } + } +} \ No newline at end of file diff --git a/Bikes.Infrastructure.Kafka/appsettings.Development.json b/Bikes.Infrastructure.Kafka/appsettings.Development.json new file mode 100644 index 000000000..b2dcdb674 --- /dev/null +++ b/Bikes.Infrastructure.Kafka/appsettings.Development.json @@ -0,0 +1,8 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.Hosting.Lifetime": "Information" + } + } +} diff --git a/Bikes.Infrastructure.Kafka/appsettings.json b/Bikes.Infrastructure.Kafka/appsettings.json new file mode 100644 index 000000000..d9d2d78ce --- /dev/null +++ b/Bikes.Infrastructure.Kafka/appsettings.json @@ -0,0 +1,12 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.Hosting.Lifetime": "Information" + } + }, + "Kafka": { + "Topic": "bike-rents", + "GroupId": "bikes-consumer-group" + } +} \ No newline at end of file diff --git a/Bikes.ServiceDefaults/Bikes.ServiceDefaults.csproj b/Bikes.ServiceDefaults/Bikes.ServiceDefaults.csproj new file mode 100644 index 000000000..2ce061c99 --- /dev/null +++ b/Bikes.ServiceDefaults/Bikes.ServiceDefaults.csproj @@ -0,0 +1,19 @@ + + + net8.0 + enable + enable + true + + + + + + + + + + + + + \ No newline at end of file diff --git a/Bikes.ServiceDefaults/ServiceDefaultsExtensions.cs b/Bikes.ServiceDefaults/ServiceDefaultsExtensions.cs new file mode 100644 index 000000000..267a490fd --- /dev/null +++ b/Bikes.ServiceDefaults/ServiceDefaultsExtensions.cs @@ -0,0 +1,123 @@ +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 Bikes.ServiceDefaults; + +/// +/// Extensions for configuring default .NET Aspire services. +/// +public static class ServiceDefaultsExtensions +{ + /// + /// Adds default .NET Aspire services: OpenTelemetry, health checks, service discovery, and HTTP client resilience. + /// + /// The host application builder type. + /// The host builder. + /// The host builder for chaining. + 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; + } + + /// + /// Configures OpenTelemetry for metrics, tracing, and logging collection. + /// + /// The host application builder type. + /// The host builder. + /// The host builder for chaining. + 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() + .AddHttpClientInstrumentation(); + }); + + builder.AddOpenTelemetryExporters(); + + return builder; + } + + /// + /// Adds OpenTelemetry exporters if OTLP endpoint is configured. + /// + /// The host application builder type. + /// The host builder. + /// The host builder for chaining. + private static TBuilder AddOpenTelemetryExporters(this TBuilder builder) where TBuilder : IHostApplicationBuilder + { + var useOtlpExporter = !string.IsNullOrWhiteSpace(builder.Configuration["OTEL_EXPORTER_OTLP_ENDPOINT"]); + + if (useOtlpExporter) + { + builder.Services.AddOpenTelemetry().UseOtlpExporter(); + } + + return builder; + } + + /// + /// Adds default health checks for the application. + /// + /// The host application builder type. + /// The host builder. + /// The host builder for chaining. + public static TBuilder AddDefaultHealthChecks(this TBuilder builder) where TBuilder : IHostApplicationBuilder + { + builder.Services.AddHealthChecks() + .AddCheck("self", () => HealthCheckResult.Healthy(), ["live"]); + + return builder; + } + + /// + /// Maps default health check endpoints for the web application. + /// + /// The web application. + /// The web application for chaining. + public static WebApplication MapDefaultEndpoints(this WebApplication app) + { + if (app.Environment.IsDevelopment()) + { + app.MapHealthChecks("/alive", new HealthCheckOptions + { + Predicate = r => r.Tags.Contains("live") + }); + + app.MapHealthChecks("/health"); + } + + return app; + } +} \ No newline at end of file diff --git a/Bikes.Tests/Bikes.Tests.csproj b/Bikes.Tests/Bikes.Tests.csproj new file mode 100644 index 000000000..3a1bf7982 --- /dev/null +++ b/Bikes.Tests/Bikes.Tests.csproj @@ -0,0 +1,27 @@ + + + + net8.0 + enable + enable + + false + true + + + + + + + + + + + + + + + + + + diff --git a/Bikes.Tests/BikesFixture.cs b/Bikes.Tests/BikesFixture.cs new file mode 100644 index 000000000..d94d5934e --- /dev/null +++ b/Bikes.Tests/BikesFixture.cs @@ -0,0 +1,183 @@ +using Bikes.Domain.Models; + +namespace Bikes.Tests; + +/// +/// Fixture for bike rental tests providing test data +/// +public class BikesFixture +{ + /// + /// Gets the list of bike models + /// + public List Models { get; } + + /// + /// Gets the list of bikes + /// + public List Bikes { get; } + + /// + /// Gets the list of renters + /// + public List Renters { get; } + + /// + /// Gets the list of rental records + /// + public List Rents { get; } + + /// + /// Initializes a new instance of the BikesFixture class + /// + public BikesFixture() + { + Models = InitializeModels(); + Bikes = InitializeBikes(Models); + Renters = InitializeRenters(); + Rents = InitializeRents(Bikes, Renters); + } + + /// + /// Initializes the list of bike models + /// + /// List of bike models + private static List InitializeModels() + { + return + [ + new() { Id = 1, Name = "Sport Pro 1000", Type = BikeType.Sport, WheelSize = 28, MaxWeight = 120, Weight = 10, BrakeType = "Disc", ModelYear = 2023, PricePerHour = 15 }, + new() { Id = 2, Name = "Mountain Extreme", Type = BikeType.Mountain, WheelSize = 29, MaxWeight = 130, Weight = 12, BrakeType = "Hydraulic", ModelYear = 2023, PricePerHour = 12 }, + new() { Id = 3, Name = "Road Racer", Type = BikeType.Road, WheelSize = 26, MaxWeight = 110, Weight = 8, BrakeType = "Rim", ModelYear = 2023, PricePerHour = 10 }, + new() { Id = 4, Name = "Sport Elite", Type = BikeType.Sport, WheelSize = 27.5m, MaxWeight = 125, Weight = 11, BrakeType = "Disc", ModelYear = 2023, PricePerHour = 14 }, + new() { Id = 5, Name = "Hybrid Comfort", Type = BikeType.Hybrid, WheelSize = 28, MaxWeight = 135, Weight = 13, BrakeType = "Rim", ModelYear = 2023, PricePerHour = 8 }, + new() { Id = 6, Name = "Electric City", Type = BikeType.Electric, WheelSize = 26, MaxWeight = 140, Weight = 20, BrakeType = "Disc", ModelYear = 2023, PricePerHour = 20 }, + new() { Id = 7, Name = "Sport Lightning", Type = BikeType.Sport, WheelSize = 29, MaxWeight = 115, Weight = 9.5m, BrakeType = "Hydraulic", ModelYear = 2023, PricePerHour = 16 }, + new() { Id = 8, Name = "Mountain King", Type = BikeType.Mountain, WheelSize = 27.5m, MaxWeight = 128, Weight = 11.5m, BrakeType = "Disc", ModelYear = 2023, PricePerHour = 13 }, + new() { Id = 9, Name = "Road Speed", Type = BikeType.Road, WheelSize = 28, MaxWeight = 105, Weight = 7.5m, BrakeType = "Rim", ModelYear = 2023, PricePerHour = 11 }, + new() { Id = 10, Name = "Sport Thunder", Type = BikeType.Sport, WheelSize = 26, MaxWeight = 122, Weight = 10.5m, BrakeType = "Disc", ModelYear = 2023, PricePerHour = 15.5m }, + new() { Id = 11, Name = "Electric Mountain", Type = BikeType.Electric, WheelSize = 29, MaxWeight = 145, Weight = 22, BrakeType = "Hydraulic", ModelYear = 2023, PricePerHour = 25 } + ]; + } + + /// + /// Initializes the list of bikes + /// + /// List of bike models + /// List of bikes + private static List InitializeBikes(List models) + { + var bikes = new List(); + var colors = new[] { "Red", "Blue", "Green", "Black", "White", "Yellow" }; + + var bikeConfigurations = new[] + { + new { ModelIndex = 0, ColorIndex = 0 }, + new { ModelIndex = 1, ColorIndex = 1 }, + new { ModelIndex = 2, ColorIndex = 2 }, + new { ModelIndex = 3, ColorIndex = 3 }, + new { ModelIndex = 4, ColorIndex = 4 }, + new { ModelIndex = 5, ColorIndex = 5 }, + new { ModelIndex = 6, ColorIndex = 0 }, + new { ModelIndex = 7, ColorIndex = 1 }, + new { ModelIndex = 8, ColorIndex = 2 }, + new { ModelIndex = 9, ColorIndex = 3 }, + new { ModelIndex = 10, ColorIndex = 4 }, + new { ModelIndex = 0, ColorIndex = 5 }, + new { ModelIndex = 1, ColorIndex = 0 }, + new { ModelIndex = 2, ColorIndex = 1 }, + new { ModelIndex = 3, ColorIndex = 2 } + }; + + for (var i = 0; i < bikeConfigurations.Length; i++) + { + var config = bikeConfigurations[i]; + var model = models[config.ModelIndex]; + var color = colors[config.ColorIndex]; + + bikes.Add(new Bike + { + Id = i + 1, + SerialNumber = $"SN{(i + 1):D6}", + Model = model, + Color = color, + IsAvailable = i % 2 == 0 + }); + } + + return bikes; + } + + /// + /// Initializes the list of renters + /// + /// List of renters + private static List InitializeRenters() + { + return + [ + new() { Id = 1, FullName = "Ivanov Ivan", Phone = "+79111111111" }, + new() { Id = 2, FullName = "Petrov Petr", Phone = "+79112222222" }, + new() { Id = 3, FullName = "Sidorov Alexey", Phone = "+79113333333" }, + new() { Id = 4, FullName = "Kuznetsova Maria", Phone = "+79114444444" }, + new() { Id = 5, FullName = "Smirnov Dmitry", Phone = "+79115555555" }, + new() { Id = 6, FullName = "Vasilyeva Ekaterina", Phone = "+79116666666" }, + new() { Id = 7, FullName = "Popov Artem", Phone = "+79117777777" }, + new() { Id = 8, FullName = "Lebedeva Olga", Phone = "+79118888888" }, + new() { Id = 9, FullName = "Novikov Sergey", Phone = "+79119999999" }, + new() { Id = 10, FullName = "Morozova Anna", Phone = "+79110000000" }, + new() { Id = 11, FullName = "Volkov Pavel", Phone = "+79121111111" }, + new() { Id = 12, FullName = "Sokolova Irina", Phone = "+79122222222" } + ]; + } + + /// + /// Initializes the list of rental records + /// + private static List InitializeRents(List bikes, List renters) + { + var rents = new List(); + var rentId = 1; + + var rentalData = new[] + { + new { BikeIndex = 0, RenterIndex = 0, Duration = 5, DaysAgo = 2 }, + new { BikeIndex = 1, RenterIndex = 1, Duration = 3, DaysAgo = 5 }, + new { BikeIndex = 2, RenterIndex = 2, Duration = 8, DaysAgo = 1 }, + new { BikeIndex = 0, RenterIndex = 3, Duration = 2, DaysAgo = 7 }, + new { BikeIndex = 3, RenterIndex = 4, Duration = 12, DaysAgo = 3 }, + new { BikeIndex = 1, RenterIndex = 5, Duration = 6, DaysAgo = 4 }, + new { BikeIndex = 4, RenterIndex = 6, Duration = 4, DaysAgo = 6 }, + new { BikeIndex = 2, RenterIndex = 7, Duration = 10, DaysAgo = 2 }, + new { BikeIndex = 5, RenterIndex = 8, Duration = 7, DaysAgo = 1 }, + new { BikeIndex = 3, RenterIndex = 9, Duration = 3, DaysAgo = 8 }, + new { BikeIndex = 6, RenterIndex = 10, Duration = 15, DaysAgo = 2 }, + new { BikeIndex = 4, RenterIndex = 11, Duration = 9, DaysAgo = 5 }, + new { BikeIndex = 7, RenterIndex = 0, Duration = 2, DaysAgo = 3 }, + new { BikeIndex = 5, RenterIndex = 1, Duration = 6, DaysAgo = 4 }, + new { BikeIndex = 8, RenterIndex = 2, Duration = 11, DaysAgo = 1 }, + new { BikeIndex = 6, RenterIndex = 3, Duration = 4, DaysAgo = 7 }, + new { BikeIndex = 9, RenterIndex = 4, Duration = 8, DaysAgo = 2 }, + new { BikeIndex = 7, RenterIndex = 5, Duration = 5, DaysAgo = 5 }, + new { BikeIndex = 10, RenterIndex = 6, Duration = 13, DaysAgo = 3 }, + new { BikeIndex = 8, RenterIndex = 7, Duration = 7, DaysAgo = 4 } + }; + + foreach (var data in rentalData) + { + var bike = bikes[data.BikeIndex]; + var renter = renters[data.RenterIndex]; + + rents.Add(new Rent + { + Id = rentId++, + Bike = bike, + Renter = renter, + StartTime = DateTime.Now.AddDays(-data.DaysAgo), + DurationHours = data.Duration + }); + } + + return rents; + } +} \ No newline at end of file diff --git a/Bikes.Tests/BikesTests.cs b/Bikes.Tests/BikesTests.cs new file mode 100644 index 000000000..1710ddb8d --- /dev/null +++ b/Bikes.Tests/BikesTests.cs @@ -0,0 +1,156 @@ +using Bikes.Domain.Models; + +namespace Bikes.Tests; + +public class BikesTests(BikesFixture fixture) : IClassFixture +{ + /// + /// Test for retrieving all sport bikes information + /// + [Fact] + public void GetAllSportBikes() + { + var sportBikes = fixture.Bikes + .Where(b => b.Model.Type == BikeType.Sport) + .ToList(); + + var expectedSportBikeNames = new[] + { + "Sport Pro 1000", "Sport Elite", "Sport Lightning", "Sport Thunder" + }; + var expectedCount = 6; + + Assert.NotNull(sportBikes); + Assert.Equal(expectedCount, sportBikes.Count); + Assert.All(sportBikes, bike => Assert.Equal(BikeType.Sport, bike.Model.Type)); + Assert.All(sportBikes, bike => Assert.Contains(bike.Model.Name, expectedSportBikeNames)); + } + + /// + /// Test for retrieving top 5 bike models by rental profit + /// + [Fact] + public void Top5ModelsByProfit() + { + var topModelsByProfit = fixture.Rents + .GroupBy(r => r.Bike.Model) + .Select(g => new + { + Model = g.Key, + TotalProfit = g.Sum(r => r.TotalCost) + }) + .OrderByDescending(x => x.TotalProfit) + .Take(5) + .ToList(); + + var expectedCount = 5; + var expectedTopModel = "Electric Mountain"; + + Assert.NotNull(topModelsByProfit); + Assert.True(topModelsByProfit.Count <= expectedCount); + Assert.Equal(expectedCount, topModelsByProfit.Count); + Assert.Equal(expectedTopModel, topModelsByProfit.First().Model.Name); + } + + /// + /// Test for retrieving top 5 bike models by rental duration + /// + [Fact] + public void Top5ModelsByRentalDuration() + { + var topModelsByDuration = fixture.Rents + .GroupBy(r => r.Bike.Model) + .Select(g => new + { + Model = g.Key, + TotalDuration = g.Sum(r => r.DurationHours) + }) + .OrderByDescending(x => x.TotalDuration) + .Take(5) + .ToList(); + + var expectedCount = 5; + var expectedTopModel = "Sport Lightning"; + var expectedTopDuration = 19; + + Assert.NotNull(topModelsByDuration); + Assert.True(topModelsByDuration.Count <= expectedCount); + Assert.Equal(expectedCount, topModelsByDuration.Count); + Assert.Equal(expectedTopModel, topModelsByDuration.First().Model.Name); + Assert.Equal(expectedTopDuration, topModelsByDuration.First().TotalDuration); + } + + /// + /// Test for rental statistics - min, max and average rental duration + /// + [Fact] + public void RentalStatistics() + { + var durations = fixture.Rents.Select(r => r.DurationHours).ToList(); + + var minDuration = durations.Min(); + var maxDuration = durations.Max(); + var avgDuration = durations.Average(); + + + var expectedMinDuration = 2; + var expectedMaxDuration = 15; + var expectedAvgDuration = 7; + + Assert.Equal(expectedMinDuration, minDuration); + Assert.Equal(expectedMaxDuration, maxDuration); + Assert.Equal(expectedAvgDuration, avgDuration); + } + + /// + /// Test for total rental time by bike type + /// + [Theory] + [InlineData(BikeType.Sport, 49)] + [InlineData(BikeType.Mountain, 16)] + [InlineData(BikeType.Road, 36)] + [InlineData(BikeType.Hybrid, 13)] + [InlineData(BikeType.Electric, 26)] + public void TotalRentalTimeByBikeType(BikeType bikeType, int expectedTotalTime) + { + var actualTotalTime = fixture.Rents + .Where(r => r.Bike.Model.Type == bikeType) + .Sum(r => r.DurationHours); + + Assert.Equal(expectedTotalTime, actualTotalTime); + } + + /// + /// Test for top renters by rental count + /// + [Fact] + public void TopRentersByRentalCount() + { + var topRenters = fixture.Rents + .GroupBy(r => r.Renter) + .Select(g => new + { + Renter = g.Key, + RentalCount = g.Count() + }) + .OrderByDescending(x => x.RentalCount) + .Take(5) + .ToList(); + + var expectedCount = 5; + var expectedTopRenterName = "Ivanov Ivan"; + var expectedMaxRentalCount = 2; + + Assert.NotNull(topRenters); + Assert.True(topRenters.Count <= expectedCount); + Assert.Equal(expectedCount, topRenters.Count); + Assert.Equal(expectedTopRenterName, topRenters[0].Renter.FullName); + Assert.Equal(expectedMaxRentalCount, topRenters[0].RentalCount); + + if (topRenters.Count > 0) + { + var maxRentalCount = topRenters.Max(x => x.RentalCount); + Assert.True(topRenters[0].RentalCount == maxRentalCount); + } + } +} \ No newline at end of file diff --git a/Bikes.sln b/Bikes.sln new file mode 100644 index 000000000..03e741a5a --- /dev/null +++ b/Bikes.sln @@ -0,0 +1,177 @@ + +Microsoft Visual Studio Solution File, Format Version 12.00 +# Visual Studio Version 17 +VisualStudioVersion = 17.0.31903.59 +MinimumVisualStudioVersion = 10.0.40219.1 +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Bikes.Domain", "Bikes.Domain\Bikes.Domain.csproj", "{A1B2C3D4-E5F6-7890-ABCD-EF1234567890}" +EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Bikes.Tests", "Bikes.Tests\Bikes.Tests.csproj", "{B2C3D4E5-F678-9012-BCDE-F12345678901}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Bikes.Application.Contracts", "Bikes.Application.Contracts\Bikes.Application.Contracts.csproj", "{375C2BDE-6F0B-40BD-B6EC-E99F74ACB3C7}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Bikes.Application", "Bikes.Application\Bikes.Application.csproj", "{221D85D1-A79D-4C32-BA01-E781961721A3}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Bikes.Api.Host", "Bikes.Api.Host\Bikes.Api.Host.csproj", "{26BE026D-95E7-4C62-A832-7A7A9B6A7D48}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Bikes.Infrastructure.InMemory", "Bikes.Infrastructure.InMemory\Bikes.Infrastructure.InMemory.csproj", "{AF5C88C9-4078-48B6-814A-F64C690D97FF}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Bikes.Infrastructure.EfCore", "Bikes.Infrastructure.EfCore\Bikes.Infrastructure.EfCore.csproj", "{AB546D05-D5C3-4C68-8682-4DDA037568C1}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Bikes.AppHost", "Bikes.AppHost\Bikes.AppHost.csproj", "{D5DCAAC2-A630-4C25-8A30-A9F465633F0E}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Bikes.ServiceDefaults", "Bikes.ServiceDefaults\Bikes.ServiceDefaults.csproj", "{2AD23FB4-44F5-4B2E-9AC9-0E8266A6BE1E}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Bikes.Generator.Kafka", "Bikes.Generator.Kafka\Bikes.Generator.Kafka.csproj", "{72ACBC0F-E8D4-4D02-A82B-C9A0A2D5E6DE}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Bikes.Infrastructure.Kafka", "Bikes.Infrastructure.Kafka\Bikes.Infrastructure.Kafka.csproj", "{8F0E4A35-35F3-41B4-B096-989EED9592BC}" +EndProject +Global + GlobalSection(SolutionConfigurationPlatforms) = preSolution + Debug|Any CPU = Debug|Any CPU + Debug|x64 = Debug|x64 + Debug|x86 = Debug|x86 + Release|Any CPU = Release|Any CPU + Release|x64 = Release|x64 + Release|x86 = Release|x86 + EndGlobalSection + GlobalSection(ProjectConfigurationPlatforms) = postSolution + {A1B2C3D4-E5F6-7890-ABCD-EF1234567890}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {A1B2C3D4-E5F6-7890-ABCD-EF1234567890}.Debug|Any CPU.Build.0 = Debug|Any CPU + {A1B2C3D4-E5F6-7890-ABCD-EF1234567890}.Debug|x64.ActiveCfg = Debug|Any CPU + {A1B2C3D4-E5F6-7890-ABCD-EF1234567890}.Debug|x64.Build.0 = Debug|Any CPU + {A1B2C3D4-E5F6-7890-ABCD-EF1234567890}.Debug|x86.ActiveCfg = Debug|Any CPU + {A1B2C3D4-E5F6-7890-ABCD-EF1234567890}.Debug|x86.Build.0 = Debug|Any CPU + {A1B2C3D4-E5F6-7890-ABCD-EF1234567890}.Release|Any CPU.ActiveCfg = Release|Any CPU + {A1B2C3D4-E5F6-7890-ABCD-EF1234567890}.Release|Any CPU.Build.0 = Release|Any CPU + {A1B2C3D4-E5F6-7890-ABCD-EF1234567890}.Release|x64.ActiveCfg = Release|Any CPU + {A1B2C3D4-E5F6-7890-ABCD-EF1234567890}.Release|x64.Build.0 = Release|Any CPU + {A1B2C3D4-E5F6-7890-ABCD-EF1234567890}.Release|x86.ActiveCfg = Release|Any CPU + {A1B2C3D4-E5F6-7890-ABCD-EF1234567890}.Release|x86.Build.0 = Release|Any CPU + {B2C3D4E5-F678-9012-BCDE-F12345678901}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {B2C3D4E5-F678-9012-BCDE-F12345678901}.Debug|Any CPU.Build.0 = Debug|Any CPU + {B2C3D4E5-F678-9012-BCDE-F12345678901}.Debug|x64.ActiveCfg = Debug|Any CPU + {B2C3D4E5-F678-9012-BCDE-F12345678901}.Debug|x64.Build.0 = Debug|Any CPU + {B2C3D4E5-F678-9012-BCDE-F12345678901}.Debug|x86.ActiveCfg = Debug|Any CPU + {B2C3D4E5-F678-9012-BCDE-F12345678901}.Debug|x86.Build.0 = Debug|Any CPU + {B2C3D4E5-F678-9012-BCDE-F12345678901}.Release|Any CPU.ActiveCfg = Release|Any CPU + {B2C3D4E5-F678-9012-BCDE-F12345678901}.Release|Any CPU.Build.0 = Release|Any CPU + {B2C3D4E5-F678-9012-BCDE-F12345678901}.Release|x64.ActiveCfg = Release|Any CPU + {B2C3D4E5-F678-9012-BCDE-F12345678901}.Release|x64.Build.0 = Release|Any CPU + {B2C3D4E5-F678-9012-BCDE-F12345678901}.Release|x86.ActiveCfg = Release|Any CPU + {B2C3D4E5-F678-9012-BCDE-F12345678901}.Release|x86.Build.0 = Release|Any CPU + {375C2BDE-6F0B-40BD-B6EC-E99F74ACB3C7}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {375C2BDE-6F0B-40BD-B6EC-E99F74ACB3C7}.Debug|Any CPU.Build.0 = Debug|Any CPU + {375C2BDE-6F0B-40BD-B6EC-E99F74ACB3C7}.Debug|x64.ActiveCfg = Debug|Any CPU + {375C2BDE-6F0B-40BD-B6EC-E99F74ACB3C7}.Debug|x64.Build.0 = Debug|Any CPU + {375C2BDE-6F0B-40BD-B6EC-E99F74ACB3C7}.Debug|x86.ActiveCfg = Debug|Any CPU + {375C2BDE-6F0B-40BD-B6EC-E99F74ACB3C7}.Debug|x86.Build.0 = Debug|Any CPU + {375C2BDE-6F0B-40BD-B6EC-E99F74ACB3C7}.Release|Any CPU.ActiveCfg = Release|Any CPU + {375C2BDE-6F0B-40BD-B6EC-E99F74ACB3C7}.Release|Any CPU.Build.0 = Release|Any CPU + {375C2BDE-6F0B-40BD-B6EC-E99F74ACB3C7}.Release|x64.ActiveCfg = Release|Any CPU + {375C2BDE-6F0B-40BD-B6EC-E99F74ACB3C7}.Release|x64.Build.0 = Release|Any CPU + {375C2BDE-6F0B-40BD-B6EC-E99F74ACB3C7}.Release|x86.ActiveCfg = Release|Any CPU + {375C2BDE-6F0B-40BD-B6EC-E99F74ACB3C7}.Release|x86.Build.0 = Release|Any CPU + {221D85D1-A79D-4C32-BA01-E781961721A3}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {221D85D1-A79D-4C32-BA01-E781961721A3}.Debug|Any CPU.Build.0 = Debug|Any CPU + {221D85D1-A79D-4C32-BA01-E781961721A3}.Debug|x64.ActiveCfg = Debug|Any CPU + {221D85D1-A79D-4C32-BA01-E781961721A3}.Debug|x64.Build.0 = Debug|Any CPU + {221D85D1-A79D-4C32-BA01-E781961721A3}.Debug|x86.ActiveCfg = Debug|Any CPU + {221D85D1-A79D-4C32-BA01-E781961721A3}.Debug|x86.Build.0 = Debug|Any CPU + {221D85D1-A79D-4C32-BA01-E781961721A3}.Release|Any CPU.ActiveCfg = Release|Any CPU + {221D85D1-A79D-4C32-BA01-E781961721A3}.Release|Any CPU.Build.0 = Release|Any CPU + {221D85D1-A79D-4C32-BA01-E781961721A3}.Release|x64.ActiveCfg = Release|Any CPU + {221D85D1-A79D-4C32-BA01-E781961721A3}.Release|x64.Build.0 = Release|Any CPU + {221D85D1-A79D-4C32-BA01-E781961721A3}.Release|x86.ActiveCfg = Release|Any CPU + {221D85D1-A79D-4C32-BA01-E781961721A3}.Release|x86.Build.0 = Release|Any CPU + {26BE026D-95E7-4C62-A832-7A7A9B6A7D48}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {26BE026D-95E7-4C62-A832-7A7A9B6A7D48}.Debug|Any CPU.Build.0 = Debug|Any CPU + {26BE026D-95E7-4C62-A832-7A7A9B6A7D48}.Debug|x64.ActiveCfg = Debug|Any CPU + {26BE026D-95E7-4C62-A832-7A7A9B6A7D48}.Debug|x64.Build.0 = Debug|Any CPU + {26BE026D-95E7-4C62-A832-7A7A9B6A7D48}.Debug|x86.ActiveCfg = Debug|Any CPU + {26BE026D-95E7-4C62-A832-7A7A9B6A7D48}.Debug|x86.Build.0 = Debug|Any CPU + {26BE026D-95E7-4C62-A832-7A7A9B6A7D48}.Release|Any CPU.ActiveCfg = Release|Any CPU + {26BE026D-95E7-4C62-A832-7A7A9B6A7D48}.Release|Any CPU.Build.0 = Release|Any CPU + {26BE026D-95E7-4C62-A832-7A7A9B6A7D48}.Release|x64.ActiveCfg = Release|Any CPU + {26BE026D-95E7-4C62-A832-7A7A9B6A7D48}.Release|x64.Build.0 = Release|Any CPU + {26BE026D-95E7-4C62-A832-7A7A9B6A7D48}.Release|x86.ActiveCfg = Release|Any CPU + {26BE026D-95E7-4C62-A832-7A7A9B6A7D48}.Release|x86.Build.0 = Release|Any CPU + {AF5C88C9-4078-48B6-814A-F64C690D97FF}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {AF5C88C9-4078-48B6-814A-F64C690D97FF}.Debug|Any CPU.Build.0 = Debug|Any CPU + {AF5C88C9-4078-48B6-814A-F64C690D97FF}.Debug|x64.ActiveCfg = Debug|Any CPU + {AF5C88C9-4078-48B6-814A-F64C690D97FF}.Debug|x64.Build.0 = Debug|Any CPU + {AF5C88C9-4078-48B6-814A-F64C690D97FF}.Debug|x86.ActiveCfg = Debug|Any CPU + {AF5C88C9-4078-48B6-814A-F64C690D97FF}.Debug|x86.Build.0 = Debug|Any CPU + {AF5C88C9-4078-48B6-814A-F64C690D97FF}.Release|Any CPU.ActiveCfg = Release|Any CPU + {AF5C88C9-4078-48B6-814A-F64C690D97FF}.Release|Any CPU.Build.0 = Release|Any CPU + {AF5C88C9-4078-48B6-814A-F64C690D97FF}.Release|x64.ActiveCfg = Release|Any CPU + {AF5C88C9-4078-48B6-814A-F64C690D97FF}.Release|x64.Build.0 = Release|Any CPU + {AF5C88C9-4078-48B6-814A-F64C690D97FF}.Release|x86.ActiveCfg = Release|Any CPU + {AF5C88C9-4078-48B6-814A-F64C690D97FF}.Release|x86.Build.0 = Release|Any CPU + {AB546D05-D5C3-4C68-8682-4DDA037568C1}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {AB546D05-D5C3-4C68-8682-4DDA037568C1}.Debug|Any CPU.Build.0 = Debug|Any CPU + {AB546D05-D5C3-4C68-8682-4DDA037568C1}.Debug|x64.ActiveCfg = Debug|Any CPU + {AB546D05-D5C3-4C68-8682-4DDA037568C1}.Debug|x64.Build.0 = Debug|Any CPU + {AB546D05-D5C3-4C68-8682-4DDA037568C1}.Debug|x86.ActiveCfg = Debug|Any CPU + {AB546D05-D5C3-4C68-8682-4DDA037568C1}.Debug|x86.Build.0 = Debug|Any CPU + {AB546D05-D5C3-4C68-8682-4DDA037568C1}.Release|Any CPU.ActiveCfg = Release|Any CPU + {AB546D05-D5C3-4C68-8682-4DDA037568C1}.Release|Any CPU.Build.0 = Release|Any CPU + {AB546D05-D5C3-4C68-8682-4DDA037568C1}.Release|x64.ActiveCfg = Release|Any CPU + {AB546D05-D5C3-4C68-8682-4DDA037568C1}.Release|x64.Build.0 = Release|Any CPU + {AB546D05-D5C3-4C68-8682-4DDA037568C1}.Release|x86.ActiveCfg = Release|Any CPU + {AB546D05-D5C3-4C68-8682-4DDA037568C1}.Release|x86.Build.0 = Release|Any CPU + {D5DCAAC2-A630-4C25-8A30-A9F465633F0E}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {D5DCAAC2-A630-4C25-8A30-A9F465633F0E}.Debug|Any CPU.Build.0 = Debug|Any CPU + {D5DCAAC2-A630-4C25-8A30-A9F465633F0E}.Debug|x64.ActiveCfg = Debug|Any CPU + {D5DCAAC2-A630-4C25-8A30-A9F465633F0E}.Debug|x64.Build.0 = Debug|Any CPU + {D5DCAAC2-A630-4C25-8A30-A9F465633F0E}.Debug|x86.ActiveCfg = Debug|Any CPU + {D5DCAAC2-A630-4C25-8A30-A9F465633F0E}.Debug|x86.Build.0 = Debug|Any CPU + {D5DCAAC2-A630-4C25-8A30-A9F465633F0E}.Release|Any CPU.ActiveCfg = Release|Any CPU + {D5DCAAC2-A630-4C25-8A30-A9F465633F0E}.Release|Any CPU.Build.0 = Release|Any CPU + {D5DCAAC2-A630-4C25-8A30-A9F465633F0E}.Release|x64.ActiveCfg = Release|Any CPU + {D5DCAAC2-A630-4C25-8A30-A9F465633F0E}.Release|x64.Build.0 = Release|Any CPU + {D5DCAAC2-A630-4C25-8A30-A9F465633F0E}.Release|x86.ActiveCfg = Release|Any CPU + {D5DCAAC2-A630-4C25-8A30-A9F465633F0E}.Release|x86.Build.0 = Release|Any CPU + {2AD23FB4-44F5-4B2E-9AC9-0E8266A6BE1E}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {2AD23FB4-44F5-4B2E-9AC9-0E8266A6BE1E}.Debug|Any CPU.Build.0 = Debug|Any CPU + {2AD23FB4-44F5-4B2E-9AC9-0E8266A6BE1E}.Debug|x64.ActiveCfg = Debug|Any CPU + {2AD23FB4-44F5-4B2E-9AC9-0E8266A6BE1E}.Debug|x64.Build.0 = Debug|Any CPU + {2AD23FB4-44F5-4B2E-9AC9-0E8266A6BE1E}.Debug|x86.ActiveCfg = Debug|Any CPU + {2AD23FB4-44F5-4B2E-9AC9-0E8266A6BE1E}.Debug|x86.Build.0 = Debug|Any CPU + {2AD23FB4-44F5-4B2E-9AC9-0E8266A6BE1E}.Release|Any CPU.ActiveCfg = Release|Any CPU + {2AD23FB4-44F5-4B2E-9AC9-0E8266A6BE1E}.Release|Any CPU.Build.0 = Release|Any CPU + {2AD23FB4-44F5-4B2E-9AC9-0E8266A6BE1E}.Release|x64.ActiveCfg = Release|Any CPU + {2AD23FB4-44F5-4B2E-9AC9-0E8266A6BE1E}.Release|x64.Build.0 = Release|Any CPU + {2AD23FB4-44F5-4B2E-9AC9-0E8266A6BE1E}.Release|x86.ActiveCfg = Release|Any CPU + {2AD23FB4-44F5-4B2E-9AC9-0E8266A6BE1E}.Release|x86.Build.0 = Release|Any CPU + {72ACBC0F-E8D4-4D02-A82B-C9A0A2D5E6DE}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {72ACBC0F-E8D4-4D02-A82B-C9A0A2D5E6DE}.Debug|Any CPU.Build.0 = Debug|Any CPU + {72ACBC0F-E8D4-4D02-A82B-C9A0A2D5E6DE}.Debug|x64.ActiveCfg = Debug|Any CPU + {72ACBC0F-E8D4-4D02-A82B-C9A0A2D5E6DE}.Debug|x64.Build.0 = Debug|Any CPU + {72ACBC0F-E8D4-4D02-A82B-C9A0A2D5E6DE}.Debug|x86.ActiveCfg = Debug|Any CPU + {72ACBC0F-E8D4-4D02-A82B-C9A0A2D5E6DE}.Debug|x86.Build.0 = Debug|Any CPU + {72ACBC0F-E8D4-4D02-A82B-C9A0A2D5E6DE}.Release|Any CPU.ActiveCfg = Release|Any CPU + {72ACBC0F-E8D4-4D02-A82B-C9A0A2D5E6DE}.Release|Any CPU.Build.0 = Release|Any CPU + {72ACBC0F-E8D4-4D02-A82B-C9A0A2D5E6DE}.Release|x64.ActiveCfg = Release|Any CPU + {72ACBC0F-E8D4-4D02-A82B-C9A0A2D5E6DE}.Release|x64.Build.0 = Release|Any CPU + {72ACBC0F-E8D4-4D02-A82B-C9A0A2D5E6DE}.Release|x86.ActiveCfg = Release|Any CPU + {72ACBC0F-E8D4-4D02-A82B-C9A0A2D5E6DE}.Release|x86.Build.0 = Release|Any CPU + {8F0E4A35-35F3-41B4-B096-989EED9592BC}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {8F0E4A35-35F3-41B4-B096-989EED9592BC}.Debug|Any CPU.Build.0 = Debug|Any CPU + {8F0E4A35-35F3-41B4-B096-989EED9592BC}.Debug|x64.ActiveCfg = Debug|Any CPU + {8F0E4A35-35F3-41B4-B096-989EED9592BC}.Debug|x64.Build.0 = Debug|Any CPU + {8F0E4A35-35F3-41B4-B096-989EED9592BC}.Debug|x86.ActiveCfg = Debug|Any CPU + {8F0E4A35-35F3-41B4-B096-989EED9592BC}.Debug|x86.Build.0 = Debug|Any CPU + {8F0E4A35-35F3-41B4-B096-989EED9592BC}.Release|Any CPU.ActiveCfg = Release|Any CPU + {8F0E4A35-35F3-41B4-B096-989EED9592BC}.Release|Any CPU.Build.0 = Release|Any CPU + {8F0E4A35-35F3-41B4-B096-989EED9592BC}.Release|x64.ActiveCfg = Release|Any CPU + {8F0E4A35-35F3-41B4-B096-989EED9592BC}.Release|x64.Build.0 = Release|Any CPU + {8F0E4A35-35F3-41B4-B096-989EED9592BC}.Release|x86.ActiveCfg = Release|Any CPU + {8F0E4A35-35F3-41B4-B096-989EED9592BC}.Release|x86.Build.0 = Release|Any CPU + EndGlobalSection + GlobalSection(SolutionProperties) = preSolution + HideSolutionNode = FALSE + EndGlobalSection + GlobalSection(ExtensibilityGlobals) = postSolution + SolutionGuid = {5ABC640F-2193-41DE-939D-657478D0E14B} + EndGlobalSection +EndGlobal diff --git a/README.md b/README.md index 39c9a8443..5749def8c 100644 --- a/README.md +++ b/README.md @@ -1,136 +1,36 @@ # Разработка корпоративных приложений -[Таблица с успеваемостью](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) - -## Схема сдачи - -На каждую из лабораторных работ необходимо сделать отдельный [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). +# Задание "Пункт велопроката" +# Лабораторная работа 1 - "Классы" +В рамках первой лабораторной работы была добавлена доменная модель с основными сущностями пункта велопроката и реализованы юнит-тесты. +В базе данных пункта проката хранятся сведения о велосипедах, их арендаторах и выданных в аренду транспортных средствах. +Каждый велосипед характеризуется серийным номером, моделью, цветом. +Модель велосипеда является справочником, содержащим сведения о типе велосипеда, размере колес, предельно допустимом весе пассажира, весе велосипеда, типе тормозов, модельном годе. Для каждой модели велосипеда указывается цена часа аренды. +Тип велосипеда является перечислением. +Арендатор характеризуется ФИО, телефоном. +При выдаче велосипеда арендатору фиксируется время начала аренды и отмечается ее продолжительность в часах. + +### Классы +Bike - характеризует велосипед, содержит идентификатор, серийный номер, модель, цвет и статус доступности для аренды +BikeModel - информация о модели велосипеда, содержит название, тип, размер колес, максимальный вес, вес велосипеда, тип тормозов, модельный год и цену за час аренды +BikeType - перечисление типов велосипедов (шоссейный, горный, спортивный, гибридный, электрический) +Renter - информация об арендаторе, содержит идентификатор, ФИО и контактный телефон +Rent - информация об аренде велосипеда, содержит ссылки на велосипед и арендатора, время начала аренды, продолжительность и автоматически вычисляемую общую стоимость + +### Тесты +BikesTests - юнит-тесты с использованием fixture для подготовки тестовых данных: +GetAllSportBikes - Вывести информацию обо всех спортивных велосипедах +Top5ModelsByProfit - Вывести топ 5 моделей велосипедов по прибыли от аренды +Top5ModelsByRentalDuration - Вывести топ 5 моделей велосипедов по длительности аренды +RentalStatistics - Вывести информацию о минимальном, максимальном и среднем времени аренды велосипедов +TotalRentalTimeByBikeType - Вывести суммарное время аренды велосипедов каждого типа +TopRentersByRentalCount - Вывести информацию о клиентах, бравших велосипеды на прокат больше всего раз + +### Тестовые данные +BikesFixture - предоставляет предопределенные тестовые данные: +11 моделей велосипедов различных типов +15 велосипедов с фиксированным распределением моделей и цветов +12 арендаторов с тестовыми данными +20 записей об арендах с различной продолжительностью \ No newline at end of file