diff --git a/.github/workflows/dotnet_tests.yml b/.github/workflows/dotnet_tests.yml
new file mode 100644
index 000000000..ec55587ad
--- /dev/null
+++ b/.github/workflows/dotnet_tests.yml
@@ -0,0 +1,29 @@
+name: .NET Tests
+
+on:
+ push:
+ branches: ["main"]
+ pull_request:
+ branches: ["main"]
+
+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/Bikes.sln
+
+ - name: Build
+ run: dotnet build --no-restore --configuration Release Bikes/Bikes.sln
+
+ - name: Run tests
+ run: dotnet test Bikes/Bikes.Tests/Bikes.Tests.csproj --no-build --configuration Release
\ No newline at end of file
diff --git a/Bikes/Bikes.Api.Host/Bikes.Api.Host.csproj b/Bikes/Bikes.Api.Host/Bikes.Api.Host.csproj
new file mode 100644
index 000000000..4e02a2e6c
--- /dev/null
+++ b/Bikes/Bikes.Api.Host/Bikes.Api.Host.csproj
@@ -0,0 +1,22 @@
+
+
+
+ net8.0
+ enable
+ enable
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/Bikes/Bikes.Api.Host/Controllers/AnalyticsController.cs b/Bikes/Bikes.Api.Host/Controllers/AnalyticsController.cs
new file mode 100644
index 000000000..6c276b62a
--- /dev/null
+++ b/Bikes/Bikes.Api.Host/Controllers/AnalyticsController.cs
@@ -0,0 +1,171 @@
+using Bikes.Application.Interfaces;
+using Bikes.Contracts.Dto;
+using Bikes.Domain.Models;
+using Microsoft.AspNetCore.Mvc;
+
+namespace Bikes.Api.Host.Controllers;
+
+///
+/// A class that implements a controller for processing HTTP requests for the AnalyticsService class
+///
+///
+///
+[ApiController]
+[Route("api/[controller]")]
+[Produces("application/json")]
+public class AnalyticsController(
+ IAnalyticsService service,
+ ILogger logger) : ControllerBase
+{
+ ///
+ /// A method that returns information about all sports bikes
+ ///
+ [HttpGet("sport-bikes")]
+ [ProducesResponseType(typeof(List), StatusCodes.Status200OK)]
+ [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status500InternalServerError)]
+ public ActionResult> GetSportBikes()
+ {
+ try
+ {
+ logger.LogInformation("Getting sport bikes");
+ var bikes = service.GetSportBikes();
+ logger.LogInformation("Retrieved {Count} sport bikes", bikes.Count);
+ return Ok(bikes);
+ }
+ catch (Exception ex)
+ {
+ logger.LogError(ex, "Error getting sport bikes");
+ return Problem(
+ title: "Internal Server Error",
+ detail: "An error occurred while retrieving sport bikes.",
+ statusCode: StatusCodes.Status500InternalServerError);
+ }
+ }
+
+ ///
+ /// A method that returns the top 5 bike models by rental duration
+ ///
+ [HttpGet("top-models/duration")]
+ [ProducesResponseType(typeof(List), StatusCodes.Status200OK)]
+ [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status500InternalServerError)]
+ public ActionResult> GetTopModelsByDuration()
+ {
+ try
+ {
+ logger.LogInformation("Getting top models by rent duration");
+ var models = service.GetTopFiveModelsByRentDuration();
+ logger.LogInformation("Retrieved top {Count} models by duration", models.Count);
+ return Ok(models);
+ }
+ catch (Exception ex)
+ {
+ logger.LogError(ex, "Error getting top models by duration");
+ return Problem(
+ title: "Internal Server Error",
+ detail: "An error occurred while retrieving top models by duration.",
+ statusCode: StatusCodes.Status500InternalServerError);
+ }
+ }
+
+ ///
+ /// A method that returns the top 5 bike models in terms of rental income
+ ///
+ [HttpGet("top-models/profit")]
+ [ProducesResponseType(typeof(List), StatusCodes.Status200OK)]
+ [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status500InternalServerError)]
+ public ActionResult> GetTopModelsByProfit()
+ {
+ try
+ {
+ logger.LogInformation("Getting top models by profit");
+ var models = service.GetTopFiveModelsByProfit();
+ logger.LogInformation("Retrieved top {Count} models by profit", models.Count);
+ return Ok(models);
+ }
+ catch (Exception ex)
+ {
+ logger.LogError(ex, "Error getting top models by profit");
+ return Problem(
+ title: "Internal Server Error",
+ detail: "An error occurred while retrieving top models by profit.",
+ statusCode: StatusCodes.Status500InternalServerError);
+ }
+ }
+
+ ///
+ /// A method that returns information about the minimum, maximum, and average bike rental time.
+ ///
+ [HttpGet("stats/duration")]
+ [ProducesResponseType(typeof(RentalDurationStatsDto), StatusCodes.Status200OK)]
+ [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status500InternalServerError)]
+ public ActionResult GetRentalDurationStats()
+ {
+ try
+ {
+ logger.LogInformation("Getting rental duration statistics");
+ var stats = service.GetRentalDurationStats();
+ logger.LogInformation("Retrieved rental duration stats: Min={Min}, Max={Max}, Avg={Avg}",
+ stats.Min, stats.Max, stats.Average);
+
+ return Ok(stats);
+ }
+ catch (Exception ex)
+ {
+ logger.LogError(ex, "Error getting rental duration statistics");
+ return Problem(
+ title: "Internal Server Error",
+ detail: "An error occurred while retrieving rental duration statistics.",
+ statusCode: StatusCodes.Status500InternalServerError);
+ }
+ }
+
+ ///
+ /// A method that returns the total rental time of each type of bike
+ ///
+ [HttpGet("stats/rental-time-by-type")]
+ [ProducesResponseType(typeof(Dictionary), StatusCodes.Status200OK)]
+ [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status500InternalServerError)]
+ public ActionResult> GetTotalRentalTimeByType()
+ {
+ try
+ {
+ logger.LogInformation("Getting total rental time by bike type");
+ var stats = service.GetTotalRentalTimeByType();
+ logger.LogInformation("Retrieved rental time by type for {Count} bike types", stats.Count);
+ return Ok(stats);
+ }
+ catch (Exception ex)
+ {
+ logger.LogError(ex, "Error getting total rental time by type");
+ return Problem(
+ title: "Internal Server Error",
+ detail: "An error occurred while retrieving total rental time by type.",
+ statusCode: StatusCodes.Status500InternalServerError);
+ }
+ }
+
+ ///
+ /// A method that returns information about the customers who have rented bicycles the most times.
+ ///
+ [HttpGet("top-renters")]
+ [ProducesResponseType(typeof(List), StatusCodes.Status200OK)]
+ [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status500InternalServerError)]
+ public ActionResult> GetTopRenters()
+ {
+ try
+ {
+ logger.LogInformation("Getting top renters");
+ var renters = service.GetTopThreeRenters();
+ logger.LogInformation("Retrieved top {Count} renters", renters.Count);
+ return Ok(renters);
+ }
+ catch (Exception ex)
+ {
+ logger.LogError(ex, "Error getting top renters");
+ return Problem(
+ title: "Internal Server Error",
+ detail: "An error occurred while retrieving top renters.",
+ statusCode: StatusCodes.Status500InternalServerError);
+ }
+ }
+}
\ No newline at end of file
diff --git a/Bikes/Bikes.Api.Host/Controllers/BikeModelsController.cs b/Bikes/Bikes.Api.Host/Controllers/BikeModelsController.cs
new file mode 100644
index 000000000..cc38b20dc
--- /dev/null
+++ b/Bikes/Bikes.Api.Host/Controllers/BikeModelsController.cs
@@ -0,0 +1,214 @@
+using Bikes.Contracts.Dto;
+using Microsoft.AspNetCore.Mvc;
+using Bikes.Application.Interfaces;
+
+namespace Bikes.Api.Host.Controllers;
+
+///
+/// A class that implements a controller for processing HTTP requests for the BikeModels class
+///
+///
+///
+[ApiController]
+[Route("api/[controller]")]
+[Produces("application/json")]
+public class BikeModelsController(IBikeModelService service, ILogger logger) : ControllerBase
+{
+ ///
+ /// Returns all existing objects
+ ///
+ [HttpGet]
+ [ProducesResponseType(typeof(List), StatusCodes.Status200OK)]
+ [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status500InternalServerError)]
+ public ActionResult> GetAllBikeModels()
+ {
+ try
+ {
+ logger.LogInformation("Getting all bike models");
+ var models = service.GetAllBikeModels();
+ logger.LogInformation("Retrieved {Count} bike models", models.Count);
+ return Ok(models);
+ }
+ catch (Exception ex)
+ {
+ logger.LogError(ex, "Error getting all bike models");
+ return Problem(
+ title: "Internal Server Error",
+ detail: "An error occurred while retrieving bike models.",
+ statusCode: StatusCodes.Status500InternalServerError);
+ }
+ }
+
+ ///
+ /// Returns object by id
+ ///
+ ///
+ [HttpGet("{id:int}")]
+ [ProducesResponseType(typeof(BikeModelGetDto), StatusCodes.Status200OK)]
+ [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status404NotFound)]
+ [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status500InternalServerError)]
+ public ActionResult GetBikeModel(int id)
+ {
+ try
+ {
+ logger.LogInformation("Getting bike model with ID {ModelId}", id);
+ var model = service.GetBikeModelById(id);
+
+ if (model == null)
+ {
+ logger.LogWarning("Bike model with ID {ModelId} not found", id);
+ return Problem(
+ title: "Not Found",
+ detail: $"Bike model with ID {id} not found.",
+ statusCode: StatusCodes.Status404NotFound);
+ }
+
+ return Ok(model);
+ }
+ catch (Exception ex)
+ {
+ logger.LogError(ex, "Error getting bike model with ID {ModelId}", id);
+ return Problem(
+ title: "Internal Server Error",
+ detail: "An error occurred while retrieving the bike model.",
+ statusCode: StatusCodes.Status500InternalServerError);
+ }
+ }
+
+ ///
+ /// Creates a new object
+ ///
+ ///
+ [HttpPost]
+ [ProducesResponseType(typeof(BikeModelGetDto), StatusCodes.Status201Created)]
+ [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status400BadRequest)]
+ [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status500InternalServerError)]
+ public ActionResult CreateBikeModel([FromBody] BikeModelCreateUpdateDto bikeModelDto)
+ {
+ try
+ {
+ logger.LogInformation("Creating new bike model of type {BikeType}", bikeModelDto.Type);
+
+ if (!ModelState.IsValid)
+ {
+ logger.LogWarning("Invalid bike model data: {ModelErrors}",
+ ModelState.Values.SelectMany(v => v.Errors.Select(e => e.ErrorMessage)));
+
+ return ValidationProblem(
+ title: "Validation Error",
+ detail: "One or more validation errors occurred.",
+ modelStateDictionary: ModelState);
+ }
+
+ var createdModel = service.CreateBikeModel(bikeModelDto);
+
+ if (createdModel == null)
+ {
+ logger.LogError("Failed to create bike model");
+ return Problem(
+ title: "Internal Server Error",
+ detail: "Failed to create bike model.",
+ statusCode: StatusCodes.Status500InternalServerError);
+ }
+
+ logger.LogInformation("Created bike model with ID {ModelId}", createdModel.Id);
+
+ return CreatedAtAction(
+ nameof(GetBikeModel),
+ new { id = createdModel.Id },
+ createdModel);
+ }
+ catch (Exception ex)
+ {
+ logger.LogError(ex, "Error creating bike model");
+ return Problem(
+ title: "Internal Server Error",
+ detail: "An error occurred while creating the bike model.",
+ statusCode: StatusCodes.Status500InternalServerError);
+ }
+ }
+
+ ///
+ /// Updates an existing object
+ ///
+ ///
+ ///
+ [HttpPut("{id:int}")]
+ [ProducesResponseType(typeof(BikeModelGetDto), StatusCodes.Status200OK)]
+ [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status400BadRequest)]
+ [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status404NotFound)]
+ [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status500InternalServerError)]
+ public ActionResult UpdateBikeModel(int id, [FromBody] BikeModelCreateUpdateDto bikeModelDto)
+ {
+ try
+ {
+ logger.LogInformation("Updating bike model with ID {ModelId}", id);
+
+ if (!ModelState.IsValid)
+ {
+ return ValidationProblem(
+ title: "Validation Error",
+ detail: "One or more validation errors occurred.",
+ modelStateDictionary: ModelState);
+ }
+
+ var model = service.UpdateBikeModel(id, bikeModelDto);
+ if (model == null)
+ {
+ logger.LogWarning("Bike model with ID {ModelId} not found for update", id);
+ return Problem(
+ title: "Not Found",
+ detail: $"Bike model with ID {id} not found.",
+ statusCode: StatusCodes.Status404NotFound);
+ }
+
+ logger.LogInformation("Updated bike model with ID {ModelId}", id);
+ return Ok(model);
+ }
+ catch (Exception ex)
+ {
+ logger.LogError(ex, "Error updating bike model with ID {ModelId}", id);
+ return Problem(
+ title: "Internal Server Error",
+ detail: "An error occurred while updating the bike model.",
+ statusCode: StatusCodes.Status500InternalServerError);
+ }
+ }
+
+ ///
+ /// Deletes an existing object by id
+ ///
+ ///
+ [HttpDelete("{id:int}")]
+ [ProducesResponseType(StatusCodes.Status204NoContent)]
+ [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status404NotFound)]
+ [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status500InternalServerError)]
+ public ActionResult DeleteBikeModel(int id)
+ {
+ try
+ {
+ logger.LogInformation("Deleting bike model with ID {ModelId}", id);
+ var result = service.DeleteBikeModel(id);
+
+ if (!result)
+ {
+ logger.LogWarning("Bike model with ID {ModelId} not found for deletion", id);
+ return Problem(
+ title: "Not Found",
+ detail: $"Bike model with ID {id} not found.",
+ statusCode: StatusCodes.Status404NotFound);
+ }
+
+ logger.LogInformation("Deleted bike model with ID {ModelId}", id);
+ return NoContent();
+ }
+ catch (Exception ex)
+ {
+ logger.LogError(ex, "Error deleting bike model with ID {ModelId}", id);
+ return Problem(
+ title: "Internal Server Error",
+ detail: "An error occurred while deleting the bike model.",
+ statusCode: StatusCodes.Status500InternalServerError);
+ }
+ }
+}
\ No newline at end of file
diff --git a/Bikes/Bikes.Api.Host/Controllers/BikesController.cs b/Bikes/Bikes.Api.Host/Controllers/BikesController.cs
new file mode 100644
index 000000000..72ef6038a
--- /dev/null
+++ b/Bikes/Bikes.Api.Host/Controllers/BikesController.cs
@@ -0,0 +1,234 @@
+using Bikes.Contracts.Dto;
+using Microsoft.AspNetCore.Mvc;
+using Bikes.Application.Interfaces;
+
+namespace Bikes.Api.Host.Controllers;
+
+///
+/// A class that implements a controller for processing HTTP requests for the BikeService class
+///
+///
+///
+[ApiController]
+[Route("api/[controller]")]
+[Produces("application/json")]
+public class BikesController(IBikeService service, ILogger logger) : ControllerBase
+{
+ ///
+ /// Returns all existing objects
+ ///
+ [HttpGet]
+ [ProducesResponseType(typeof(List), StatusCodes.Status200OK)]
+ [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status500InternalServerError)]
+ public ActionResult> GetAllBikes()
+ {
+ try
+ {
+ logger.LogInformation("Getting all bikes");
+ var bikes = service.GetAllBikes();
+ logger.LogInformation("Retrieved {Count} bikes", bikes.Count);
+ return Ok(bikes);
+ }
+ catch (Exception ex)
+ {
+ logger.LogError(ex, "Error getting all bikes");
+ return Problem(
+ title: "Internal Server Error",
+ detail: "An error occurred while retrieving bikes.",
+ statusCode: StatusCodes.Status500InternalServerError);
+ }
+ }
+
+ ///
+ /// Returns object by id
+ ///
+ ///
+ [HttpGet("{id:int}")]
+ [ProducesResponseType(typeof(BikeGetDto), StatusCodes.Status200OK)]
+ [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status404NotFound)]
+ [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status500InternalServerError)]
+ public ActionResult GetBike(int id)
+ {
+ try
+ {
+ logger.LogInformation("Getting bike with ID {BikeId}", id);
+ var bike = service.GetBikeById(id);
+
+ if (bike == null)
+ {
+ logger.LogWarning("Bike with ID {BikeId} not found", id);
+ return Problem(
+ title: "Not Found",
+ detail: $"Bike with ID {id} not found.",
+ statusCode: StatusCodes.Status404NotFound);
+ }
+
+ logger.LogInformation("Retrieved bike with ID {BikeId}", id);
+ return Ok(bike);
+ }
+ catch (Exception ex)
+ {
+ logger.LogError(ex, "Error getting bike with ID {BikeId}", id);
+ return Problem(
+ title: "Internal Server Error",
+ detail: "An error occurred while retrieving the bike.",
+ statusCode: StatusCodes.Status500InternalServerError);
+ }
+ }
+
+ ///
+ /// Creates a new object
+ ///
+ ///
+ [HttpPost]
+ [ProducesResponseType(typeof(BikeGetDto), StatusCodes.Status201Created)]
+ [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status400BadRequest)]
+ [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status500InternalServerError)]
+ public ActionResult CreateBike([FromBody] BikeCreateUpdateDto bikeDto)
+ {
+ try
+ {
+ logger.LogInformation("Creating new bike with serial number {SerialNumber}", bikeDto.SerialNumber);
+
+ if (!ModelState.IsValid)
+ {
+ logger.LogWarning("Invalid bike data: {ModelErrors}",
+ ModelState.Values.SelectMany(v => v.Errors.Select(e => e.ErrorMessage)));
+
+ return ValidationProblem(
+ title: "Validation Error",
+ detail: "One or more validation errors occurred.",
+ modelStateDictionary: ModelState);
+ }
+
+ var createdBike = service.CreateBike(bikeDto);
+
+ if (createdBike == null)
+ {
+ logger.LogError("Failed to create bike");
+ return Problem(
+ title: "Internal Server Error",
+ detail: "Failed to create bike.",
+ statusCode: StatusCodes.Status500InternalServerError);
+ }
+
+ logger.LogInformation("Created bike with ID {BikeId}", createdBike.Id);
+
+ return CreatedAtAction(
+ nameof(GetBike),
+ new { id = createdBike.Id },
+ createdBike);
+ }
+ catch (ArgumentException ex)
+ {
+ logger.LogWarning(ex, "Error creating bike: {ErrorMessage}", ex.Message);
+ return Problem(
+ title: "Bad Request",
+ detail: ex.Message,
+ statusCode: StatusCodes.Status400BadRequest);
+ }
+ catch (Exception ex)
+ {
+ logger.LogError(ex, "Error creating bike");
+ return Problem(
+ title: "Internal Server Error",
+ detail: "An error occurred while creating the bike.",
+ statusCode: StatusCodes.Status500InternalServerError);
+ }
+ }
+
+ ///
+ /// Updates an existing object
+ ///
+ ///
+ ///
+ [HttpPut("{id:int}")]
+ [ProducesResponseType(typeof(BikeGetDto), StatusCodes.Status200OK)]
+ [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status400BadRequest)]
+ [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status404NotFound)]
+ [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status500InternalServerError)]
+ public ActionResult UpdateBike(int id, [FromBody] BikeCreateUpdateDto bikeDto)
+ {
+ try
+ {
+ logger.LogInformation("Updating bike with ID {BikeId}", id);
+
+ if (!ModelState.IsValid)
+ {
+ logger.LogWarning("Invalid bike data for update: {ModelErrors}",
+ ModelState.Values.SelectMany(v => v.Errors.Select(e => e.ErrorMessage)));
+
+ return ValidationProblem(
+ title: "Validation Error",
+ detail: "One or more validation errors occurred.",
+ modelStateDictionary: ModelState);
+ }
+
+ var bike = service.UpdateBike(id, bikeDto);
+ if (bike == null)
+ {
+ logger.LogWarning("Bike with ID {BikeId} not found for update", id);
+ return Problem(
+ title: "Not Found",
+ detail: $"Bike with ID {id} not found.",
+ statusCode: StatusCodes.Status404NotFound);
+ }
+
+ logger.LogInformation("Updated bike with ID {BikeId}", id);
+ return Ok(bike);
+ }
+ catch (ArgumentException ex)
+ {
+ logger.LogWarning(ex, "Error updating bike: {ErrorMessage}", ex.Message);
+ return Problem(
+ title: "Bad Request",
+ detail: ex.Message,
+ statusCode: StatusCodes.Status400BadRequest);
+ }
+ catch (Exception ex)
+ {
+ logger.LogError(ex, "Error updating bike with ID {BikeId}", id);
+ return Problem(
+ title: "Internal Server Error",
+ detail: "An error occurred while updating the bike.",
+ statusCode: StatusCodes.Status500InternalServerError);
+ }
+ }
+
+ ///
+ /// Deletes an existing object by id
+ ///
+ ///
+ [HttpDelete("{id:int}")]
+ [ProducesResponseType(StatusCodes.Status204NoContent)]
+ [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status404NotFound)]
+ [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status500InternalServerError)]
+ public ActionResult DeleteBike(int id)
+ {
+ try
+ {
+ logger.LogInformation("Deleting bike with ID {BikeId}", id);
+ var result = service.DeleteBike(id);
+
+ if (!result)
+ {
+ logger.LogWarning("Bike with ID {BikeId} not found for deletion", id);
+ return Problem(
+ title: "Not Found",
+ detail: $"Bike with ID {id} not found.",
+ statusCode: StatusCodes.Status404NotFound);
+ }
+
+ logger.LogInformation("Deleted bike with ID {BikeId}", id);
+ return NoContent();
+ }
+ catch (Exception ex)
+ {
+ logger.LogError(ex, "Error deleting bike with ID {BikeId}", id);
+ return Problem(
+ title: "Internal Server Error",
+ detail: "An error occurred while deleting the bike.",
+ statusCode: StatusCodes.Status500InternalServerError);
+ }
+ }
+}
\ No newline at end of file
diff --git a/Bikes/Bikes.Api.Host/Controllers/RentersController.cs b/Bikes/Bikes.Api.Host/Controllers/RentersController.cs
new file mode 100644
index 000000000..224f9d6b5
--- /dev/null
+++ b/Bikes/Bikes.Api.Host/Controllers/RentersController.cs
@@ -0,0 +1,233 @@
+using Bikes.Contracts.Dto;
+using Microsoft.AspNetCore.Mvc;
+using Bikes.Application.Interfaces;
+
+namespace Bikes.Api.Host.Controllers;
+
+///
+/// A class that implements a controller for processing HTTP requests for the RenterService class
+///
+///
+///
+[ApiController]
+[Route("api/[controller]")]
+[Produces("application/json")]
+public class RentersController(IRenterService service, ILogger logger) : ControllerBase
+{
+ ///
+ /// Returns all existing objects
+ ///
+ [HttpGet]
+ [ProducesResponseType(typeof(List), StatusCodes.Status200OK)]
+ [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status500InternalServerError)]
+ public ActionResult> GetAllRenters()
+ {
+ try
+ {
+ logger.LogInformation("Getting all renters");
+ var renters = service.GetAllRenters();
+ logger.LogInformation("Retrieved {Count} renters", renters.Count);
+ return Ok(renters);
+ }
+ catch (Exception ex)
+ {
+ logger.LogError(ex, "Error getting all renters");
+ return Problem(
+ title: "Internal Server Error",
+ detail: "An error occurred while retrieving renters.",
+ statusCode: StatusCodes.Status500InternalServerError);
+ }
+ }
+
+ ///
+ /// Returns object by id
+ ///
+ ///
+ [HttpGet("{id:int}")]
+ [ProducesResponseType(typeof(RenterGetDto), StatusCodes.Status200OK)]
+ [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status404NotFound)]
+ [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status500InternalServerError)]
+ public ActionResult GetRenter(int id)
+ {
+ try
+ {
+ logger.LogInformation("Getting renter with ID {RenterId}", id);
+ var renter = service.GetRenterById(id);
+
+ if (renter == null)
+ {
+ logger.LogWarning("Renter with ID {RenterId} not found", id);
+ return Problem(
+ title: "Not Found",
+ detail: $"Renter with ID {id} not found.",
+ statusCode: StatusCodes.Status404NotFound);
+ }
+
+ return Ok(renter);
+ }
+ catch (Exception ex)
+ {
+ logger.LogError(ex, "Error getting renter with ID {RenterId}", id);
+ return Problem(
+ title: "Internal Server Error",
+ detail: "An error occurred while retrieving the renter.",
+ statusCode: StatusCodes.Status500InternalServerError);
+ }
+ }
+
+ ///
+ /// Creates a new object
+ ///
+ ///
+ [HttpPost]
+ [ProducesResponseType(typeof(RenterGetDto), StatusCodes.Status201Created)]
+ [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status400BadRequest)]
+ [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status500InternalServerError)]
+ public ActionResult CreateRenter([FromBody] RenterCreateUpdateDto renterDto)
+ {
+ try
+ {
+ logger.LogInformation("Creating new renter: {FullName}", renterDto.FullName);
+
+ if (!ModelState.IsValid)
+ {
+ logger.LogWarning("Invalid renter data: {ModelErrors}",
+ ModelState.Values.SelectMany(v => v.Errors.Select(e => e.ErrorMessage)));
+
+ return ValidationProblem(
+ title: "Validation Error",
+ detail: "One or more validation errors occurred.",
+ modelStateDictionary: ModelState);
+ }
+
+ var createdRenter = service.CreateRenter(renterDto);
+
+ if (createdRenter == null)
+ {
+ logger.LogError("Failed to create renter");
+ return Problem(
+ title: "Internal Server Error",
+ detail: "Failed to create renter.",
+ statusCode: StatusCodes.Status500InternalServerError);
+ }
+
+ logger.LogInformation("Created renter with ID {RenterId}", createdRenter.Id);
+
+ return CreatedAtAction(
+ nameof(GetRenter),
+ new { id = createdRenter.Id },
+ createdRenter);
+ }
+ catch (ArgumentException ex)
+ {
+ logger.LogWarning(ex, "Error creating renter: {ErrorMessage}", ex.Message);
+ return Problem(
+ title: "Bad Request",
+ detail: ex.Message,
+ statusCode: StatusCodes.Status400BadRequest);
+ }
+ catch (Exception ex)
+ {
+ logger.LogError(ex, "Error creating renter");
+ return Problem(
+ title: "Internal Server Error",
+ detail: "An error occurred while creating the renter.",
+ statusCode: StatusCodes.Status500InternalServerError);
+ }
+ }
+
+ ///
+ /// Updates an existing object
+ ///
+ ///
+ ///
+ [HttpPut("{id:int}")]
+ [ProducesResponseType(typeof(RenterGetDto), StatusCodes.Status200OK)]
+ [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status400BadRequest)]
+ [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status404NotFound)]
+ [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status500InternalServerError)]
+ public ActionResult UpdateRenter(int id, [FromBody] RenterCreateUpdateDto renterDto)
+ {
+ try
+ {
+ logger.LogInformation("Updating renter with ID {RenterId}", id);
+
+ if (!ModelState.IsValid)
+ {
+ logger.LogWarning("Invalid renter data for update: {ModelErrors}",
+ ModelState.Values.SelectMany(v => v.Errors.Select(e => e.ErrorMessage)));
+
+ return ValidationProblem(
+ title: "Validation Error",
+ detail: "One or more validation errors occurred.",
+ modelStateDictionary: ModelState);
+ }
+
+ var renter = service.UpdateRenter(id, renterDto);
+ if (renter == null)
+ {
+ logger.LogWarning("Renter with ID {RenterId} not found for update", id);
+ return Problem(
+ title: "Not Found",
+ detail: $"Renter with ID {id} not found.",
+ statusCode: StatusCodes.Status404NotFound);
+ }
+
+ logger.LogInformation("Updated renter with ID {RenterId}", id);
+ return Ok(renter);
+ }
+ catch (ArgumentException ex)
+ {
+ logger.LogWarning(ex, "Error updating renter: {ErrorMessage}", ex.Message);
+ return Problem(
+ title: "Bad Request",
+ detail: ex.Message,
+ statusCode: StatusCodes.Status400BadRequest);
+ }
+ catch (Exception ex)
+ {
+ logger.LogError(ex, "Error updating renter with ID {RenterId}", id);
+ return Problem(
+ title: "Internal Server Error",
+ detail: "An error occurred while updating the renter.",
+ statusCode: StatusCodes.Status500InternalServerError);
+ }
+ }
+
+ ///
+ /// Deletes an existing object by id
+ ///
+ ///
+ [HttpDelete("{id:int}")]
+ [ProducesResponseType(StatusCodes.Status204NoContent)]
+ [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status404NotFound)]
+ [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status500InternalServerError)]
+ public ActionResult DeleteRenter(int id)
+ {
+ try
+ {
+ logger.LogInformation("Deleting renter with ID {RenterId}", id);
+ var result = service.DeleteRenter(id);
+
+ if (!result)
+ {
+ logger.LogWarning("Renter with ID {RenterId} not found for deletion", id);
+ return Problem(
+ title: "Not Found",
+ detail: $"Renter with ID {id} not found.",
+ statusCode: StatusCodes.Status404NotFound);
+ }
+
+ logger.LogInformation("Deleted renter with ID {RenterId}", id);
+ return NoContent();
+ }
+ catch (Exception ex)
+ {
+ logger.LogError(ex, "Error deleting renter with ID {RenterId}", id);
+ return Problem(
+ title: "Internal Server Error",
+ detail: "An error occurred while deleting the renter.",
+ statusCode: StatusCodes.Status500InternalServerError);
+ }
+ }
+}
\ No newline at end of file
diff --git a/Bikes/Bikes.Api.Host/Controllers/RentsController.cs b/Bikes/Bikes.Api.Host/Controllers/RentsController.cs
new file mode 100644
index 000000000..555e038b8
--- /dev/null
+++ b/Bikes/Bikes.Api.Host/Controllers/RentsController.cs
@@ -0,0 +1,234 @@
+using Bikes.Contracts.Dto;
+using Microsoft.AspNetCore.Mvc;
+using Bikes.Application.Interfaces;
+
+namespace Bikes.Api.Host.Controllers;
+
+///
+/// A class that implements a controller for processing HTTP requests for the RentService class
+///
+///
+///
+[ApiController]
+[Route("api/[controller]")]
+[Produces("application/json")]
+public class RentsController(IRentService service, ILogger logger) : ControllerBase
+{
+ ///
+ /// Returns all existing objects
+ ///
+ [HttpGet]
+ [ProducesResponseType(typeof(List), StatusCodes.Status200OK)]
+ [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status500InternalServerError)]
+ public ActionResult> GetAllRents()
+ {
+ try
+ {
+ logger.LogInformation("Getting all rents");
+ var rents = service.GetAllRents();
+ logger.LogInformation("Retrieved {Count} rents", rents.Count);
+ return Ok(rents);
+ }
+ catch (Exception ex)
+ {
+ logger.LogError(ex, "Error getting all rents");
+ return Problem(
+ title: "Internal Server Error",
+ detail: "An error occurred while retrieving rents.",
+ statusCode: StatusCodes.Status500InternalServerError);
+ }
+ }
+
+ ///
+ /// Returns object by id
+ ///
+ ///
+ [HttpGet("{id:int}")]
+ [ProducesResponseType(typeof(RentGetDto), StatusCodes.Status200OK)]
+ [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status404NotFound)]
+ [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status500InternalServerError)]
+ public ActionResult GetRent(int id)
+ {
+ try
+ {
+ logger.LogInformation("Getting rent with ID {RentId}", id);
+ var rent = service.GetRentById(id);
+
+ if (rent == null)
+ {
+ logger.LogWarning("Rent with ID {RentId} not found", id);
+ return Problem(
+ title: "Not Found",
+ detail: $"Rent with ID {id} not found.",
+ statusCode: StatusCodes.Status404NotFound);
+ }
+
+ return Ok(rent);
+ }
+ catch (Exception ex)
+ {
+ logger.LogError(ex, "Error getting rent with ID {RentId}", id);
+ return Problem(
+ title: "Internal Server Error",
+ detail: "An error occurred while retrieving the rent.",
+ statusCode: StatusCodes.Status500InternalServerError);
+ }
+ }
+
+ ///
+ /// Creates a new object
+ ///
+ ///
+ [HttpPost]
+ [ProducesResponseType(typeof(RentGetDto), StatusCodes.Status201Created)]
+ [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status400BadRequest)]
+ [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status500InternalServerError)]
+ public ActionResult CreateRent([FromBody] RentCreateUpdateDto rentDto)
+ {
+ try
+ {
+ logger.LogInformation("Creating new rent for bike {BikeId} by renter {RenterId}",
+ rentDto.BikeId, rentDto.RenterId);
+
+ if (!ModelState.IsValid)
+ {
+ logger.LogWarning("Invalid rent data: {ModelErrors}",
+ ModelState.Values.SelectMany(v => v.Errors.Select(e => e.ErrorMessage)));
+
+ return ValidationProblem(
+ title: "Validation Error",
+ detail: "One or more validation errors occurred.",
+ modelStateDictionary: ModelState);
+ }
+
+ var createdRent = service.CreateRent(rentDto);
+
+ if (createdRent == null)
+ {
+ logger.LogError("Failed to create rent");
+ return Problem(
+ title: "Internal Server Error",
+ detail: "Failed to create rent.",
+ statusCode: StatusCodes.Status500InternalServerError);
+ }
+
+ logger.LogInformation("Created rent with ID {RentId}", createdRent.Id);
+
+ return CreatedAtAction(
+ nameof(GetRent),
+ new { id = createdRent.Id },
+ createdRent);
+ }
+ catch (ArgumentException ex)
+ {
+ logger.LogWarning(ex, "Error creating rent: {ErrorMessage}", ex.Message);
+ return Problem(
+ title: "Bad Request",
+ detail: ex.Message,
+ statusCode: StatusCodes.Status400BadRequest);
+ }
+ catch (Exception ex)
+ {
+ logger.LogError(ex, "Error creating rent");
+ return Problem(
+ title: "Internal Server Error",
+ detail: "An error occurred while creating the rent.",
+ statusCode: StatusCodes.Status500InternalServerError);
+ }
+ }
+
+ ///
+ /// Updates an existing object
+ ///
+ ///
+ ///
+ [HttpPut("{id:int}")]
+ [ProducesResponseType(typeof(RentGetDto), StatusCodes.Status200OK)]
+ [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status400BadRequest)]
+ [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status404NotFound)]
+ [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status500InternalServerError)]
+ public ActionResult UpdateRent(int id, [FromBody] RentCreateUpdateDto rentDto)
+ {
+ try
+ {
+ logger.LogInformation("Updating rent with ID {RentId}", id);
+
+ if (!ModelState.IsValid)
+ {
+ logger.LogWarning("Invalid rent data for update: {ModelErrors}",
+ ModelState.Values.SelectMany(v => v.Errors.Select(e => e.ErrorMessage)));
+
+ return ValidationProblem(
+ title: "Validation Error",
+ detail: "One or more validation errors occurred.",
+ modelStateDictionary: ModelState);
+ }
+
+ var rent = service.UpdateRent(id, rentDto);
+ if (rent == null)
+ {
+ logger.LogWarning("Rent with ID {RentId} not found for update", id);
+ return Problem(
+ title: "Not Found",
+ detail: $"Rent with ID {id} not found.",
+ statusCode: StatusCodes.Status404NotFound);
+ }
+
+ logger.LogInformation("Updated rent with ID {RentId}", id);
+ return Ok(rent);
+ }
+ catch (ArgumentException ex)
+ {
+ logger.LogWarning(ex, "Error updating rent: {ErrorMessage}", ex.Message);
+ return Problem(
+ title: "Bad Request",
+ detail: ex.Message,
+ statusCode: StatusCodes.Status400BadRequest);
+ }
+ catch (Exception ex)
+ {
+ logger.LogError(ex, "Error updating rent with ID {RentId}", id);
+ return Problem(
+ title: "Internal Server Error",
+ detail: "An error occurred while updating the rent.",
+ statusCode: StatusCodes.Status500InternalServerError);
+ }
+ }
+
+ ///
+ /// Deletes an existing object by id
+ ///
+ ///
+ [HttpDelete("{id:int}")]
+ [ProducesResponseType(StatusCodes.Status204NoContent)]
+ [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status404NotFound)]
+ [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status500InternalServerError)]
+ public ActionResult DeleteRent(int id)
+ {
+ try
+ {
+ logger.LogInformation("Deleting rent with ID {RentId}", id);
+ var result = service.DeleteRent(id);
+
+ if (!result)
+ {
+ logger.LogWarning("Rent with ID {RentId} not found for deletion", id);
+ return Problem(
+ title: "Not Found",
+ detail: $"Rent with ID {id} not found.",
+ statusCode: StatusCodes.Status404NotFound);
+ }
+
+ logger.LogInformation("Deleted rent with ID {RentId}", id);
+ return NoContent();
+ }
+ catch (Exception ex)
+ {
+ logger.LogError(ex, "Error deleting rent with ID {RentId}", id);
+ return Problem(
+ title: "Internal Server Error",
+ detail: "An error occurred while deleting the rent.",
+ statusCode: StatusCodes.Status500InternalServerError);
+ }
+ }
+}
\ No newline at end of file
diff --git a/Bikes/Bikes.Api.Host/Kafka/KafkaConsumer.cs b/Bikes/Bikes.Api.Host/Kafka/KafkaConsumer.cs
new file mode 100644
index 000000000..99779ea24
--- /dev/null
+++ b/Bikes/Bikes.Api.Host/Kafka/KafkaConsumer.cs
@@ -0,0 +1,264 @@
+using Bikes.Application.Interfaces;
+using Bikes.Contracts.Dto;
+using Confluent.Kafka;
+using Microsoft.Extensions.Options;
+using System.Text.Json;
+
+namespace Bikes.Api.Host.Kafka;
+
+///
+/// Background service for consuming Kafka messages and processing contract DTOs
+///
+public class KafkaConsumer(
+ IConsumer consumer,
+ IServiceScopeFactory scopeFactory,
+ IOptions options,
+ ILogger logger) : BackgroundService
+{
+ private readonly KafkaConsumerOptions _options = options.Value;
+ private readonly JsonSerializerOptions _jsonOptions = new()
+ {
+ PropertyNameCaseInsensitive = true,
+ PropertyNamingPolicy = JsonNamingPolicy.CamelCase
+ };
+
+ ///
+ /// Main execution method that consumes and processes Kafka messages
+ ///
+ protected override async Task ExecuteAsync(CancellationToken stoppingToken)
+ {
+ _ = Task.Run(async () =>
+ {
+ await Task.Delay(5000, stoppingToken);
+
+ logger.LogInformation("Starting KafkaConsumer in background thread...");
+
+ try
+ {
+ consumer.Subscribe(_options.Topic);
+ logger.LogInformation("KafkaConsumer subscribed to topic: {Topic}", _options.Topic);
+ }
+ catch (Exception ex)
+ {
+ logger.LogError(ex, "Failed to subscribe to Kafka topic");
+ return;
+ }
+
+ while (!stoppingToken.IsCancellationRequested)
+ {
+ try
+ {
+ var consumeResult = consumer.Consume(TimeSpan.FromSeconds(5));
+
+ if (consumeResult == null)
+ {
+ continue;
+ }
+
+ if (string.IsNullOrEmpty(consumeResult.Message?.Value))
+ {
+ logger.LogWarning("Received empty message");
+ continue;
+ }
+
+ logger.LogDebug("Processing message at offset {Offset}",
+ consumeResult.TopicPartitionOffset);
+
+ var contractType = DetermineContractType(consumeResult.Message.Headers);
+ ProcessMessage(consumeResult.Message.Value, contractType);
+
+ consumer.Commit(consumeResult);
+ }
+ catch (ConsumeException ex)
+ {
+ logger.LogError(ex, "Kafka consumption error: {Error}. Waiting 10 seconds...", ex.Error.Reason);
+ await Task.Delay(10000, stoppingToken);
+ }
+ catch (OperationCanceledException)
+ {
+ logger.LogInformation("KafkaConsumer is stopping...");
+ break;
+ }
+ catch (Exception ex)
+ {
+ logger.LogError(ex, "Unexpected error in KafkaConsumer. Waiting 15 seconds...");
+ await Task.Delay(15000, stoppingToken);
+ }
+ }
+
+ consumer.Close();
+ logger.LogInformation("KafkaConsumer stopped");
+ }, stoppingToken);
+
+ await Task.CompletedTask;
+ }
+
+ ///
+ /// Determines the contract type from Kafka message headers
+ ///
+ /// Kafka message headers
+ /// Contract type name or null if not found
+ private static string? DetermineContractType(Headers headers)
+ {
+ if (headers == null) return null;
+
+ var contractTypeHeader = headers.FirstOrDefault(h => h.Key == "contract-type");
+ if (contractTypeHeader != null)
+ {
+ return System.Text.Encoding.UTF8.GetString(contractTypeHeader.GetValueBytes());
+ }
+
+ return null;
+ }
+
+ ///
+ /// Processes a Kafka message based on its contract type
+ ///
+ /// JSON message content
+ /// Type of contract to process
+ private void ProcessMessage(string messageJson, string? contractType)
+ {
+ using var scope = scopeFactory.CreateScope();
+
+ try
+ {
+ switch (contractType)
+ {
+ case "BikeCreateUpdateDto":
+ var bikeDto = JsonSerializer.Deserialize(messageJson, _jsonOptions);
+ if (bikeDto != null)
+ {
+ var bikeService = scope.ServiceProvider.GetRequiredService();
+ var result = bikeService.CreateBike(bikeDto);
+ logger.LogInformation("Created bike with ID: {BikeId}", result?.Id);
+ }
+ break;
+
+ case "BikeModelCreateUpdateDto":
+ var bikeModelDto = JsonSerializer.Deserialize(messageJson, _jsonOptions);
+ if (bikeModelDto != null)
+ {
+ var bikeModelService = scope.ServiceProvider.GetRequiredService();
+ var result = bikeModelService.CreateBikeModel(bikeModelDto);
+ logger.LogInformation("Created bike model with ID: {ModelId}", result?.Id);
+ }
+ break;
+
+ case "RenterCreateUpdateDto":
+ var renterDto = JsonSerializer.Deserialize(messageJson, _jsonOptions);
+ if (renterDto != null)
+ {
+ var renterService = scope.ServiceProvider.GetRequiredService();
+ var result = renterService.CreateRenter(renterDto);
+ logger.LogInformation("Created renter with ID: {RenterId}", result?.Id);
+ }
+ break;
+
+ case "RentCreateUpdateDto":
+ var rentDto = JsonSerializer.Deserialize(messageJson, _jsonOptions);
+ if (rentDto != null)
+ {
+ var rentService = scope.ServiceProvider.GetRequiredService();
+ var result = rentService.CreateRent(rentDto);
+ logger.LogInformation("Created rent with ID: {RentId}", result?.Id);
+ }
+ break;
+
+ default:
+ TryAutoDetectAndProcess(messageJson, scope);
+ break;
+ }
+ }
+ catch (JsonException ex)
+ {
+ logger.LogError(ex, "Failed to deserialize message: {Message}", messageJson);
+ }
+ catch (ArgumentException ex)
+ {
+ logger.LogWarning("Validation error: {ErrorMessage}", ex.Message);
+ }
+ catch (Exception ex)
+ {
+ logger.LogError(ex, "Error processing message");
+ }
+ }
+
+ ///
+ /// Attempts to auto-detect contract type from JSON structure and process accordingly
+ ///
+ private void TryAutoDetectAndProcess(string messageJson, IServiceScope scope)
+ {
+ try
+ {
+ if (messageJson.Contains("SerialNumber") && messageJson.Contains("Color"))
+ {
+ var dto = JsonSerializer.Deserialize(messageJson, _jsonOptions);
+ if (dto != null)
+ {
+ var service = scope.ServiceProvider.GetRequiredService();
+ service.CreateBike(dto);
+ }
+ }
+ else if (messageJson.Contains("Type") && messageJson.Contains("WheelSize"))
+ {
+ var dto = JsonSerializer.Deserialize(messageJson, _jsonOptions);
+ if (dto != null)
+ {
+ var service = scope.ServiceProvider.GetRequiredService();
+ service.CreateBikeModel(dto);
+ }
+ }
+ else if (messageJson.Contains("FullName") && messageJson.Contains("Number"))
+ {
+ var dto = JsonSerializer.Deserialize(messageJson, _jsonOptions);
+ if (dto != null)
+ {
+ var service = scope.ServiceProvider.GetRequiredService();
+ service.CreateRenter(dto);
+ }
+ }
+ else if (messageJson.Contains("RentalStartTime") && messageJson.Contains("RentalDuration"))
+ {
+ var dto = JsonSerializer.Deserialize(messageJson, _jsonOptions);
+ if (dto != null)
+ {
+ var service = scope.ServiceProvider.GetRequiredService();
+ service.CreateRent(dto);
+ }
+ }
+ else
+ {
+ logger.LogWarning("Could not determine contract type for message: {Message}", messageJson);
+ }
+ }
+ catch (Exception ex)
+ {
+ logger.LogError(ex, "Failed to auto-detect and process message");
+ }
+ }
+
+ ///
+ /// Disposes the Kafka consumer instance
+ ///
+ public override void Dispose()
+ {
+ try
+ {
+ if (consumer != null)
+ {
+ consumer.Close();
+ consumer.Dispose();
+ }
+ }
+ catch (Exception ex)
+ {
+ logger?.LogError(ex, "Error while disposing Kafka consumer");
+ }
+ finally
+ {
+ base.Dispose();
+
+ GC.SuppressFinalize(this);
+ }
+ }
+}
\ No newline at end of file
diff --git a/Bikes/Bikes.Api.Host/Kafka/KafkaConsumerExtensions.cs b/Bikes/Bikes.Api.Host/Kafka/KafkaConsumerExtensions.cs
new file mode 100644
index 000000000..0bd65cbec
--- /dev/null
+++ b/Bikes/Bikes.Api.Host/Kafka/KafkaConsumerExtensions.cs
@@ -0,0 +1,105 @@
+using Confluent.Kafka;
+using Microsoft.Extensions.Options;
+
+namespace Bikes.Api.Host.Kafka;
+
+///
+/// Provides extension methods for configuring Kafka consumer services
+///
+public static class KafkaConsumerExtensions
+{
+ ///
+ /// Adds and configures Kafka consumer services to the service collection
+ ///
+ /// The service collection to configure
+ /// The configured service collection
+ public static IServiceCollection AddKafkaConsumer(this IServiceCollection services)
+ {
+ services.AddOptions()
+ .Configure((options, configuration) =>
+ {
+ configuration.GetSection("Kafka").Bind(options);
+
+ var aspireKafkaConnection = configuration.GetConnectionString("kafka");
+
+ if (!string.IsNullOrEmpty(aspireKafkaConnection))
+ {
+ options.BootstrapServers = aspireKafkaConnection;
+ }
+
+ else if (string.IsNullOrEmpty(options.BootstrapServers))
+ {
+ options.BootstrapServers = "localhost:9092";
+ }
+ });
+
+ services.AddSingleton(provider =>
+ {
+ var options = provider.GetRequiredService>().Value;
+ var logger = provider.GetRequiredService>();
+
+ var config = new ConsumerConfig
+ {
+ BootstrapServers = options.BootstrapServers,
+ GroupId = options.GroupId,
+ EnableAutoCommit = options.EnableAutoCommit,
+ AutoOffsetReset = (AutoOffsetReset)options.AutoOffsetReset,
+
+ ApiVersionRequest = false,
+ BrokerVersionFallback = "0.10.0.0",
+ ApiVersionFallbackMs = 0,
+ SecurityProtocol = SecurityProtocol.Plaintext,
+
+ SocketTimeoutMs = 30000,
+ SessionTimeoutMs = 30000,
+ MetadataMaxAgeMs = 300000,
+
+ AllowAutoCreateTopics = false,
+ EnablePartitionEof = true
+ };
+
+ var retryCount = 0;
+ while (retryCount < options.MaxRetryAttempts)
+ {
+ try
+ {
+ var consumer = new ConsumerBuilder(config)
+ .SetErrorHandler((_, error) =>
+ {
+ if (error.IsFatal)
+ logger.LogError("Kafka Fatal Error: {Reason} (Code: {Code})",
+ error.Reason, error.Code);
+ else if (error.Code != ErrorCode.Local_Transport)
+ logger.LogWarning("Kafka Warning: {Reason} (Code: {Code})",
+ error.Reason, error.Code);
+ })
+ .Build();
+
+ logger.LogInformation("Kafka consumer created successfully for bootstrap servers: {BootstrapServers}",
+ options.BootstrapServers);
+ return consumer;
+ }
+ catch (Exception ex)
+ {
+ retryCount++;
+ logger.LogWarning(ex,
+ "Failed to create Kafka consumer (attempt {RetryCount}/{MaxRetries})",
+ retryCount, options.MaxRetryAttempts);
+
+ if (retryCount >= options.MaxRetryAttempts)
+ {
+ logger.LogError(ex, "Max retry attempts reached for Kafka consumer");
+ throw;
+ }
+
+ Thread.Sleep(options.RetryDelayMs);
+ }
+ }
+
+ throw new InvalidOperationException("Failed to create Kafka consumer");
+ });
+
+ services.AddHostedService();
+ return services;
+ }
+}
\ No newline at end of file
diff --git a/Bikes/Bikes.Api.Host/Kafka/KafkaConsumerOptions.cs b/Bikes/Bikes.Api.Host/Kafka/KafkaConsumerOptions.cs
new file mode 100644
index 000000000..801519755
--- /dev/null
+++ b/Bikes/Bikes.Api.Host/Kafka/KafkaConsumerOptions.cs
@@ -0,0 +1,67 @@
+namespace Bikes.Api.Host.Kafka;
+
+///
+/// Configuration options for Kafka consumer
+///
+public class KafkaConsumerOptions
+{
+ ///
+ /// Gets or sets the Kafka bootstrap servers address
+ ///
+ public string BootstrapServers { get; set; } = "localhost:9092";
+
+ ///
+ /// Gets or sets the topic name to consume from
+ ///
+ public string Topic { get; set; } = "bikes-contracts";
+
+ ///
+ /// Gets or sets the consumer group identifier
+ ///
+ public string GroupId { get; set; } = "bikes-api-consumer-group";
+
+ ///
+ /// Gets or sets a value indicating whether to enable automatic offset committing
+ ///
+ public bool EnableAutoCommit { get; set; } = false;
+
+ ///
+ /// Gets or sets the auto offset reset behavior (0: earliest, 1: latest, 2: error)
+ ///
+ public int AutoOffsetReset { get; set; } = 0;
+
+ ///
+ /// Gets or sets the maximum number of retry attempts for connection
+ ///
+ public int MaxRetryAttempts { get; set; } = 3;
+
+ ///
+ /// Gets or sets the delay between retry attempts in milliseconds
+ ///
+ public int RetryDelayMs { get; set; } = 1000;
+
+ ///
+ /// Gets or sets a value indicating whether to request API version from broker
+ ///
+ public bool ApiVersionRequest { get; set; } = false;
+
+ ///
+ /// Gets or sets the API version fallback timeout in milliseconds
+ ///
+ public int ApiVersionFallbackMs { get; set; } = 0;
+
+ ///
+ /// Gets or sets the broker version fallback string
+ ///
+ public string BrokerVersionFallback { get; set; } = "0.10.0.0";
+
+ ///
+ /// Gets or sets the security protocol for Kafka connection
+ ///
+ public string SecurityProtocol { get; set; } = "Plaintext";
+
+ ///
+ /// Gets or sets a value indicating whether to allow automatic topic creation
+ ///
+ public bool AllowAutoCreateTopics { get; set; } = false;
+}
\ No newline at end of file
diff --git a/Bikes/Bikes.Api.Host/Program.cs b/Bikes/Bikes.Api.Host/Program.cs
new file mode 100644
index 000000000..6c3d60639
--- /dev/null
+++ b/Bikes/Bikes.Api.Host/Program.cs
@@ -0,0 +1,51 @@
+using Bikes.Api.Host.Kafka;
+using Bikes.Application.Extensions;
+using Bikes.Infrastructure.MongoDb;
+using Bikes.Infrastructure.MongoDb.Extensions;
+using Bikes.ServiceDefaults;
+
+var builder = WebApplication.CreateBuilder(args);
+
+builder.AddServiceDefaults();
+
+builder.Services.AddControllers();
+builder.Services.AddEndpointsApiExplorer();
+builder.Services.AddSwaggerGen();
+
+builder.Services.AddMongoDbInfrastructure(builder.Configuration);
+builder.Services.AddBikeRentalServices();
+builder.Services.AddKafkaConsumer();
+
+var app = builder.Build();
+
+app.MapDefaultEndpoints();
+
+using (var scope = app.Services.CreateScope())
+{
+ try
+ {
+ var seeder = scope.ServiceProvider.GetRequiredService();
+ await seeder.SeedAsync();
+
+ var logger = scope.ServiceProvider.GetRequiredService>();
+ logger.LogInformation("Database seeded successfully!");
+ }
+ catch (Exception ex)
+ {
+ var logger = scope.ServiceProvider.GetRequiredService>();
+ logger.LogError(ex, "An error occurred while seeding the database");
+ }
+}
+
+if (app.Environment.IsDevelopment())
+{
+ app.UseSwagger();
+ app.UseSwaggerUI();
+}
+
+app.UseHttpsRedirection();
+app.UseAuthorization();
+
+app.MapControllers();
+
+app.Run();
\ No newline at end of file
diff --git a/Bikes/Bikes.Api.Host/Properties/launchSettings.json b/Bikes/Bikes.Api.Host/Properties/launchSettings.json
new file mode 100644
index 000000000..4afeec7d7
--- /dev/null
+++ b/Bikes/Bikes.Api.Host/Properties/launchSettings.json
@@ -0,0 +1,41 @@
+{
+ "$schema": "http://json.schemastore.org/launchsettings.json",
+ "iisSettings": {
+ "windowsAuthentication": false,
+ "anonymousAuthentication": true,
+ "iisExpress": {
+ "applicationUrl": "http://localhost:22424",
+ "sslPort": 44322
+ }
+ },
+ "profiles": {
+ "http": {
+ "commandName": "Project",
+ "dotnetRunMessages": true,
+ "launchBrowser": true,
+ "launchUrl": "swagger",
+ "applicationUrl": "http://localhost:5094",
+ "environmentVariables": {
+ "ASPNETCORE_ENVIRONMENT": "Development"
+ }
+ },
+ "https": {
+ "commandName": "Project",
+ "dotnetRunMessages": true,
+ "launchBrowser": true,
+ "launchUrl": "swagger",
+ "applicationUrl": "https://localhost:7262;http://localhost:5094",
+ "environmentVariables": {
+ "ASPNETCORE_ENVIRONMENT": "Development"
+ }
+ },
+ "IIS Express": {
+ "commandName": "IISExpress",
+ "launchBrowser": true,
+ "launchUrl": "swagger",
+ "environmentVariables": {
+ "ASPNETCORE_ENVIRONMENT": "Development"
+ }
+ }
+ }
+}
diff --git a/Bikes/Bikes.Api.Host/appsettings.Development.json b/Bikes/Bikes.Api.Host/appsettings.Development.json
new file mode 100644
index 000000000..40085cdca
--- /dev/null
+++ b/Bikes/Bikes.Api.Host/appsettings.Development.json
@@ -0,0 +1,14 @@
+{
+ "Logging": {
+ "LogLevel": {
+ "Default": "Information",
+ "Microsoft.AspNetCore": "Warning"
+ }
+ },
+ "MongoDb": {
+ "DatabaseName": "BikesDb_v2"
+ },
+ "ConnectionStrings": {
+ "MongoDB": ""
+ }
+}
\ No newline at end of file
diff --git a/Bikes/Bikes.Api.Host/appsettings.json b/Bikes/Bikes.Api.Host/appsettings.json
new file mode 100644
index 000000000..266b026e2
--- /dev/null
+++ b/Bikes/Bikes.Api.Host/appsettings.json
@@ -0,0 +1,27 @@
+{
+ "Logging": {
+ "LogLevel": {
+ "Default": "Information",
+ "Microsoft.AspNetCore": "Warning"
+ }
+ },
+ "Kafka": {
+ "Topic": "bikes-contracts",
+ "GroupId": "bikes-api-consumer-group",
+ "EnableAutoCommit": false,
+ "AutoOffsetReset": 0,
+ "MaxRetryAttempts": 10,
+ "RetryDelayMs": 2000,
+
+ "ApiVersionRequest": false,
+ "ApiVersionFallbackMs": 0,
+ "BrokerVersionFallback": "0.10.0.0",
+ "SecurityProtocol": "Plaintext",
+ "AllowAutoCreateTopics": false
+ },
+ "MongoDb": {
+ "ConnectionString": "mongodb://localhost:27017",
+ "DatabaseName": "BikesDb_v2"
+ },
+ "AllowedHosts": "*"
+}
\ No newline at end of file
diff --git a/Bikes/Bikes.AppHost/Bikes.AppHost.csproj b/Bikes/Bikes.AppHost/Bikes.AppHost.csproj
new file mode 100644
index 000000000..61d6bb71d
--- /dev/null
+++ b/Bikes/Bikes.AppHost/Bikes.AppHost.csproj
@@ -0,0 +1,28 @@
+
+
+
+
+
+ Exe
+ net8.0
+ enable
+ enable
+ c5698091-b4a9-4bd7-a5f8-050a0249db80
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/Bikes/Bikes.AppHost/Program.cs b/Bikes/Bikes.AppHost/Program.cs
new file mode 100644
index 000000000..155e4f64d
--- /dev/null
+++ b/Bikes/Bikes.AppHost/Program.cs
@@ -0,0 +1,20 @@
+var builder = DistributedApplication.CreateBuilder(args);
+
+var kafka = builder.AddKafka("kafka")
+ .WithKafkaUI()
+ .WithDataVolume();
+
+var mongodb = builder.AddMongoDB("mongodb")
+ .WithDataVolume();
+
+var _ = builder.AddProject("bikes-api")
+ .WithReference(mongodb)
+ .WaitFor(mongodb)
+ .WithReference(kafka)
+ .WaitFor(kafka);
+
+var _2 = builder.AddProject("bikes-generator")
+ .WithReference(kafka)
+ .WaitFor(kafka);
+
+builder.Build().Run();
\ No newline at end of file
diff --git a/Bikes/Bikes.AppHost/Properties/launchSettings.json b/Bikes/Bikes.AppHost/Properties/launchSettings.json
new file mode 100644
index 000000000..9244fd45f
--- /dev/null
+++ b/Bikes/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:17205;http://localhost:15251",
+ "environmentVariables": {
+ "ASPNETCORE_ENVIRONMENT": "Development",
+ "DOTNET_ENVIRONMENT": "Development",
+ "DOTNET_DASHBOARD_OTLP_ENDPOINT_URL": "https://localhost:21105",
+ "DOTNET_RESOURCE_SERVICE_ENDPOINT_URL": "https://localhost:22097"
+ }
+ },
+ "http": {
+ "commandName": "Project",
+ "dotnetRunMessages": true,
+ "launchBrowser": true,
+ "applicationUrl": "http://localhost:15251",
+ "environmentVariables": {
+ "ASPNETCORE_ENVIRONMENT": "Development",
+ "DOTNET_ENVIRONMENT": "Development",
+ "DOTNET_DASHBOARD_OTLP_ENDPOINT_URL": "http://localhost:19246",
+ "DOTNET_RESOURCE_SERVICE_ENDPOINT_URL": "http://localhost:20112"
+ }
+ }
+ }
+}
\ No newline at end of file
diff --git a/Bikes/Bikes.AppHost/appsettings.Development.json b/Bikes/Bikes.AppHost/appsettings.Development.json
new file mode 100644
index 000000000..0c208ae91
--- /dev/null
+++ b/Bikes/Bikes.AppHost/appsettings.Development.json
@@ -0,0 +1,8 @@
+{
+ "Logging": {
+ "LogLevel": {
+ "Default": "Information",
+ "Microsoft.AspNetCore": "Warning"
+ }
+ }
+}
diff --git a/Bikes/Bikes.AppHost/appsettings.json b/Bikes/Bikes.AppHost/appsettings.json
new file mode 100644
index 000000000..31c092aa4
--- /dev/null
+++ b/Bikes/Bikes.AppHost/appsettings.json
@@ -0,0 +1,9 @@
+{
+ "Logging": {
+ "LogLevel": {
+ "Default": "Information",
+ "Microsoft.AspNetCore": "Warning",
+ "Aspire.Hosting.Dcp": "Warning"
+ }
+ }
+}
diff --git a/Bikes/Bikes.Application/Bikes.Application.csproj b/Bikes/Bikes.Application/Bikes.Application.csproj
new file mode 100644
index 000000000..f8fdd9a05
--- /dev/null
+++ b/Bikes/Bikes.Application/Bikes.Application.csproj
@@ -0,0 +1,19 @@
+
+
+
+ net8.0
+ enable
+ enable
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/Bikes/Bikes.Application/Extensions/ServiceCollectionExtensions.cs b/Bikes/Bikes.Application/Extensions/ServiceCollectionExtensions.cs
new file mode 100644
index 000000000..b3e75a36c
--- /dev/null
+++ b/Bikes/Bikes.Application/Extensions/ServiceCollectionExtensions.cs
@@ -0,0 +1,29 @@
+using Bikes.Application.Interfaces;
+using Bikes.Application.Services;
+using Microsoft.Extensions.DependencyInjection;
+
+namespace Bikes.Application.Extensions;
+
+///
+/// A class for hidden registration of services
+///
+public static class ServiceCollectionExtensions
+{
+ ///
+ /// The method that registers services
+ ///
+ ///
+ ///
+ public static IServiceCollection AddBikeRentalServices(this IServiceCollection services)
+ {
+ services.AddAutoMapper(typeof(BikeService).Assembly);
+
+ services.AddScoped();
+ services.AddScoped();
+ services.AddScoped();
+ services.AddScoped();
+ services.AddScoped();
+
+ return services;
+ }
+}
\ No newline at end of file
diff --git a/Bikes/Bikes.Application/Interfaces/IAnalyticsService.cs b/Bikes/Bikes.Application/Interfaces/IAnalyticsService.cs
new file mode 100644
index 000000000..9c6425406
--- /dev/null
+++ b/Bikes/Bikes.Application/Interfaces/IAnalyticsService.cs
@@ -0,0 +1,40 @@
+using Bikes.Contracts.Dto;
+using Bikes.Domain.Models;
+
+namespace Bikes.Application.Interfaces;
+
+///
+/// Interface for the Analytics service class
+///
+public interface IAnalyticsService
+{
+ ///
+ /// A method that returns information about all sports bikes
+ ///
+ public List GetSportBikes();
+
+ ///
+ /// A method that returns the top 5 bike models by rental duration
+ ///
+ public List GetTopFiveModelsByRentDuration();
+
+ ///
+ /// A method that returns the top 5 bike models in terms of rental income
+ ///
+ public List GetTopFiveModelsByProfit();
+
+ ///
+ /// A method that returns information about the minimum, maximum, and average bike rental time.
+ ///
+ public RentalDurationStatsDto GetRentalDurationStats();
+
+ ///
+ /// A method that returns the total rental time of each type of bike
+ ///
+ public Dictionary GetTotalRentalTimeByType();
+
+ ///
+ /// A method that returns information about the customers who have rented bicycles the most times.
+ ///
+ public List GetTopThreeRenters();
+}
\ No newline at end of file
diff --git a/Bikes/Bikes.Application/Interfaces/IBikeModelService.cs b/Bikes/Bikes.Application/Interfaces/IBikeModelService.cs
new file mode 100644
index 000000000..f1ffb2423
--- /dev/null
+++ b/Bikes/Bikes.Application/Interfaces/IBikeModelService.cs
@@ -0,0 +1,44 @@
+using Bikes.Contracts.Dto;
+
+namespace Bikes.Application.Interfaces;
+
+///
+/// Interface for the BikeModel service class
+///
+public interface IBikeModelService
+{
+ ///
+ /// Creates a new object
+ ///
+ /// DTO object
+ /// Created object
+ public BikeModelGetDto CreateBikeModel(BikeModelCreateUpdateDto bikeModelDto);
+
+ ///
+ /// Returns all existing objects
+ ///
+ /// List of existing objects
+ public List GetAllBikeModels();
+
+ ///
+ /// Returns object by id
+ ///
+ ///
+ ///
+ public BikeModelGetDto? GetBikeModelById(int id);
+
+ ///
+ /// Updates an existing object
+ ///
+ /// Id
+ /// DTO object
+ /// Object if exist
+ public BikeModelGetDto? UpdateBikeModel(int id, BikeModelCreateUpdateDto bikeModelDto);
+
+ ///
+ /// Deletes an existing object by id
+ ///
+ ///
+ /// True or false? result of deleting
+ public bool DeleteBikeModel(int id);
+}
\ No newline at end of file
diff --git a/Bikes/Bikes.Application/Interfaces/IBikeService.cs b/Bikes/Bikes.Application/Interfaces/IBikeService.cs
new file mode 100644
index 000000000..7f697e9e9
--- /dev/null
+++ b/Bikes/Bikes.Application/Interfaces/IBikeService.cs
@@ -0,0 +1,44 @@
+using Bikes.Contracts.Dto;
+
+namespace Bikes.Application.Interfaces;
+
+///
+/// Interface for the Bike service class
+///
+public interface IBikeService
+{
+ ///
+ /// Creates a new object
+ ///
+ /// DTO object
+ /// ID of the created object
+ public BikeGetDto CreateBike(BikeCreateUpdateDto bikeDto);
+
+ ///
+ /// Returns all existing objects
+ ///
+ /// List of existing objects
+ public List GetAllBikes();
+
+ ///
+ /// Returns object by id
+ ///
+ ///
+ ///
+ public BikeGetDto? GetBikeById(int id);
+
+ ///
+ /// Updates an existing object
+ ///
+ /// Id
+ /// DTO object
+ /// Object if exist
+ public BikeGetDto? UpdateBike(int id, BikeCreateUpdateDto bikeDto);
+
+ ///
+ /// Deletes an existing object by id
+ ///
+ ///
+ /// True or false? result of deleting
+ public bool DeleteBike(int id);
+}
\ No newline at end of file
diff --git a/Bikes/Bikes.Application/Interfaces/IRentService.cs b/Bikes/Bikes.Application/Interfaces/IRentService.cs
new file mode 100644
index 000000000..1785f458f
--- /dev/null
+++ b/Bikes/Bikes.Application/Interfaces/IRentService.cs
@@ -0,0 +1,44 @@
+using Bikes.Contracts.Dto;
+
+namespace Bikes.Application.Interfaces;
+
+///
+/// Interface for the Rent service class
+///
+public interface IRentService
+{
+ ///
+ /// Creates a new object
+ ///
+ /// DTO object
+ /// ID of the created object
+ public RentGetDto CreateRent(RentCreateUpdateDto rentDto);
+
+ ///
+ /// Returns all existing objects
+ ///
+ /// List of existing objects
+ public List GetAllRents();
+
+ ///
+ /// Returns object by id
+ ///
+ ///
+ ///
+ public RentGetDto? GetRentById(int id);
+
+ ///
+ /// Updates an existing object
+ ///
+ /// Id
+ /// DTO object
+ /// Object if exist
+ public RentGetDto? UpdateRent(int id, RentCreateUpdateDto rentDto);
+
+ ///
+ /// Deletes an existing object by id
+ ///
+ ///
+ /// True or false? result of deleting
+ public bool DeleteRent(int id);
+}
\ No newline at end of file
diff --git a/Bikes/Bikes.Application/Interfaces/IRenterService.cs b/Bikes/Bikes.Application/Interfaces/IRenterService.cs
new file mode 100644
index 000000000..0b04b475b
--- /dev/null
+++ b/Bikes/Bikes.Application/Interfaces/IRenterService.cs
@@ -0,0 +1,44 @@
+using Bikes.Contracts.Dto;
+
+namespace Bikes.Application.Interfaces;
+
+///
+/// Interface for the Renter service class
+///
+public interface IRenterService
+{
+ ///
+ /// Creates a new object
+ ///
+ /// DTO object
+ /// ID of the created object
+ public RenterGetDto CreateRenter(RenterCreateUpdateDto renterDto);
+
+ ///
+ /// Returns all existing objects
+ ///
+ /// List of existing objects
+ public List GetAllRenters();
+
+ ///
+ /// Returns object by id
+ ///
+ ///
+ ///
+ public RenterGetDto? GetRenterById(int id);
+
+ ///
+ /// Updates an existing object
+ ///
+ /// Id
+ /// DTO object
+ /// Object if exist
+ public RenterGetDto? UpdateRenter(int id, RenterCreateUpdateDto renterDto);
+
+ ///
+ /// Deletes an existing object by id
+ ///
+ ///
+ /// True or false? result of deleting
+ public bool DeleteRenter(int id);
+}
\ No newline at end of file
diff --git a/Bikes/Bikes.Application/Mapping/MappingProfile.cs b/Bikes/Bikes.Application/Mapping/MappingProfile.cs
new file mode 100644
index 000000000..d70684d83
--- /dev/null
+++ b/Bikes/Bikes.Application/Mapping/MappingProfile.cs
@@ -0,0 +1,31 @@
+using AutoMapper;
+using Bikes.Contracts.Dto;
+using Bikes.Domain.Models;
+
+namespace Bikes.Application.Mapping;
+
+public class MappingProfile : Profile
+{
+ public MappingProfile()
+ {
+ CreateMap()
+ .ForMember(dest => dest.ModelId, opt => opt.MapFrom(src => src.ModelId));
+
+ CreateMap()
+ .ForMember(dest => dest.Model, opt => opt.Ignore());
+
+ CreateMap();
+ CreateMap();
+
+ CreateMap();
+ CreateMap();
+
+ CreateMap()
+ .ForMember(dest => dest.RenterId, opt => opt.MapFrom(src => src.RenterId))
+ .ForMember(dest => dest.BikeId, opt => opt.MapFrom(src => src.BikeId));
+
+ CreateMap()
+ .ForMember(dest => dest.Renter, opt => opt.Ignore())
+ .ForMember(dest => dest.Bike, opt => opt.Ignore());
+ }
+}
\ No newline at end of file
diff --git a/Bikes/Bikes.Application/Services/AnalyticsService.cs b/Bikes/Bikes.Application/Services/AnalyticsService.cs
new file mode 100644
index 000000000..85e009c84
--- /dev/null
+++ b/Bikes/Bikes.Application/Services/AnalyticsService.cs
@@ -0,0 +1,203 @@
+using AutoMapper;
+using Bikes.Application.Interfaces;
+using Bikes.Contracts.Dto;
+using Bikes.Domain.Models;
+using Bikes.Domain.Repositories;
+
+namespace Bikes.Application.Services;
+
+///
+/// A class that implements the interface of the AnalyticsService class
+///
+public class AnalyticsService(
+ IRepository bikeRepository,
+ IRepository rentRepository,
+ IRepository renterRepository,
+ IRepository bikeModelRepository,
+ IMapper mapper) : IAnalyticsService
+{
+ ///
+ /// A method that returns information about all sports bikes
+ ///
+ public List GetSportBikes()
+ {
+ var allBikes = bikeRepository.ReadAll();
+ var allModels = bikeModelRepository.ReadAll();
+ var modelDict = allModels.ToDictionary(m => m.Id);
+ foreach (var bike in allBikes)
+ {
+ if (modelDict.TryGetValue(bike.ModelId, out var model))
+ bike.Model = model;
+ }
+
+ var sportBikes = allBikes
+ .Where(bike => bike.Model != null && bike.Model.Type == BikeType.Sport)
+ .ToList();
+
+ return mapper.Map>(sportBikes);
+ }
+
+ ///
+ /// A method that returns the top 5 bike models by rental duration
+ ///
+ public List GetTopFiveModelsByRentDuration()
+ {
+ var allRents = rentRepository.ReadAll();
+ var allBikes = bikeRepository.ReadAll();
+ var allModels = bikeModelRepository.ReadAll();
+
+ var bikeDict = allBikes.ToDictionary(b => b.Id);
+ var modelDict = allModels.ToDictionary(m => m.Id);
+
+ foreach (var bike in allBikes)
+ {
+ if (modelDict.TryGetValue(bike.ModelId, out var model))
+ bike.Model = model;
+ }
+
+ foreach (var rent in allRents)
+ {
+ if (bikeDict.TryGetValue(rent.BikeId, out var bike))
+ rent.Bike = bike;
+ }
+
+ var topModels = allRents
+ .Where(rent => rent.Bike != null && rent.Bike.Model != null)
+ .GroupBy(rent => rent.Bike!.Model)
+ .Select(group => new
+ {
+ Model = group.Key,
+ TotalDuration = group.Sum(rent => rent.RentalDuration)
+ })
+ .OrderByDescending(x => x.TotalDuration)
+ .Take(5)
+ .Select(x => x.Model)
+ .ToList();
+
+ return mapper.Map>(topModels);
+ }
+
+ ///
+ /// A method that returns the top 5 bike models in terms of rental income
+ ///
+ public List GetTopFiveModelsByProfit()
+ {
+ var allRents = rentRepository.ReadAll();
+ var allBikes = bikeRepository.ReadAll();
+ var allModels = bikeModelRepository.ReadAll();
+
+ var bikeDict = allBikes.ToDictionary(b => b.Id);
+ var modelDict = allModels.ToDictionary(m => m.Id);
+
+ foreach (var bike in allBikes)
+ {
+ if (modelDict.TryGetValue(bike.ModelId, out var model))
+ bike.Model = model;
+ }
+
+ foreach (var rent in allRents)
+ {
+ if (bikeDict.TryGetValue(rent.BikeId, out var bike))
+ rent.Bike = bike;
+ }
+
+ var topModels = allRents
+ .Where(rent => rent.Bike != null && rent.Bike.Model != null)
+ .GroupBy(rent => rent.Bike!.Model)
+ .Select(group => new
+ {
+ Model = group.Key,
+ TotalProfit = group.Sum(rent => rent.RentalDuration * rent.Bike!.Model!.RentPrice)
+ })
+ .OrderByDescending(x => x.TotalProfit)
+ .Take(5)
+ .Select(x => x.Model)
+ .ToList();
+
+ return mapper.Map>(topModels);
+ }
+
+ ///
+ /// A method that returns information about the minimum, maximum, and average bike rental time.
+ ///
+ public RentalDurationStatsDto GetRentalDurationStats()
+ {
+ var durations = rentRepository.ReadAll()
+ .Select(rent => rent.RentalDuration)
+ .ToList();
+
+ return new RentalDurationStatsDto
+ {
+ Min = durations.Min(),
+ Max = durations.Max(),
+ Average = durations.Average()
+ };
+ }
+
+ ///
+ /// A method that returns the total rental time of each type of bike
+ ///
+ public Dictionary GetTotalRentalTimeByType()
+ {
+ var allRents = rentRepository.ReadAll();
+ var allBikes = bikeRepository.ReadAll();
+ var allModels = bikeModelRepository.ReadAll();
+
+ var bikeDict = allBikes.ToDictionary(b => b.Id);
+ var modelDict = allModels.ToDictionary(m => m.Id);
+
+ foreach (var bike in allBikes)
+ {
+ if (modelDict.TryGetValue(bike.ModelId, out var model))
+ bike.Model = model;
+ }
+
+ foreach (var rent in allRents)
+ {
+ if (bikeDict.TryGetValue(rent.BikeId, out var bike))
+ rent.Bike = bike;
+ }
+
+ return allRents
+ .Where(rent => rent.Bike != null && rent.Bike.Model != null)
+ .GroupBy(rent => rent.Bike!.Model!.Type)
+ .ToDictionary(
+ group => group.Key,
+ group => group.Sum(rent => rent.RentalDuration)
+ );
+ }
+
+ ///
+ /// A method that returns information about the customers who have rented bicycles the most times.
+ ///
+ public List GetTopThreeRenters()
+ {
+ var allRents = rentRepository.ReadAll();
+ var allRenters = renterRepository.ReadAll();
+
+ var renterDict = allRenters.ToDictionary(r => r.Id);
+ foreach (var rent in allRents)
+ {
+ if (renterDict.TryGetValue(rent.RenterId, out var renter))
+ rent.Renter = renter;
+ }
+
+ var topRenters = allRents
+ .Where(rent => rent.Renter != null)
+ .GroupBy(rent => rent.Renter!.Id)
+ .Select(group => new
+ {
+ RenterId = group.Key,
+ TotalRentals = group.Count()
+ })
+ .OrderByDescending(r => r.TotalRentals)
+ .Take(3)
+ .Join(allRenters,
+ x => x.RenterId,
+ renter => renter.Id,
+ (x, renter) => renter)
+ .ToList();
+
+ return mapper.Map>(topRenters);
+ }
+}
\ No newline at end of file
diff --git a/Bikes/Bikes.Application/Services/BikeModelService.cs b/Bikes/Bikes.Application/Services/BikeModelService.cs
new file mode 100644
index 000000000..b005e4bf4
--- /dev/null
+++ b/Bikes/Bikes.Application/Services/BikeModelService.cs
@@ -0,0 +1,81 @@
+using AutoMapper;
+using Bikes.Application.Interfaces;
+using Bikes.Contracts.Dto;
+using Bikes.Domain.Models;
+using Bikes.Domain.Repositories;
+
+namespace Bikes.Application.Services;
+
+///
+/// A class that implements the interface of the BikeModelService class
+///
+public class BikeModelService(
+ IRepository bikeModelRepository,
+ IMapper mapper) : IBikeModelService
+{
+ ///
+ /// Creates a new object
+ ///
+ /// DTO object
+ /// ID of the created object
+ public BikeModelGetDto CreateBikeModel(BikeModelCreateUpdateDto bikeModelDto)
+ {
+ var bikeModel = mapper.Map(bikeModelDto);
+
+ var id = bikeModelRepository.Create(bikeModel);
+ var createdModel = bikeModelRepository.Read(id);
+
+ return mapper.Map(createdModel);
+ }
+
+ ///
+ /// Returns all existing objects
+ ///
+ /// List of existing objects
+ public List GetAllBikeModels()
+ {
+ var models = bikeModelRepository.ReadAll();
+ return mapper.Map>(models);
+ }
+
+ ///
+ /// Returns object by id
+ ///
+ ///
+ ///
+ public BikeModelGetDto? GetBikeModelById(int id)
+ {
+ var model = bikeModelRepository.Read(id);
+ if (model == null) return null;
+
+ return mapper.Map(model);
+ }
+
+ ///
+ /// Updates an existing object
+ ///
+ /// Id
+ /// DTO object
+ /// Object if exist
+ public BikeModelGetDto? UpdateBikeModel(int id, BikeModelCreateUpdateDto bikeModelDto)
+ {
+ var existingModel = bikeModelRepository.Read(id);
+ if (existingModel == null) return null;
+
+ mapper.Map(bikeModelDto, existingModel);
+
+ existingModel.Id = id;
+
+ var updatedModel = bikeModelRepository.Update(id, existingModel);
+ if (updatedModel == null) return null;
+
+ return mapper.Map(updatedModel);
+ }
+
+ ///
+ /// Deletes an existing object by id
+ ///
+ ///
+ /// True or false? result of deleting
+ public bool DeleteBikeModel(int id) => bikeModelRepository.Delete(id);
+}
\ No newline at end of file
diff --git a/Bikes/Bikes.Application/Services/BikeService.cs b/Bikes/Bikes.Application/Services/BikeService.cs
new file mode 100644
index 000000000..abf14e994
--- /dev/null
+++ b/Bikes/Bikes.Application/Services/BikeService.cs
@@ -0,0 +1,87 @@
+using AutoMapper;
+using Bikes.Application.Interfaces;
+using Bikes.Contracts.Dto;
+using Bikes.Domain.Models;
+using Bikes.Domain.Repositories;
+
+namespace Bikes.Application.Services;
+
+///
+/// A class that implements the interface of the BikeService class
+///
+public class BikeService(
+ IRepository bikeRepository,
+ IRepository bikeModelRepository,
+ IMapper mapper) : IBikeService
+{
+ ///
+ /// Creates a new object
+ ///
+ /// DTO object
+ /// ID of the created object
+ public BikeGetDto CreateBike(BikeCreateUpdateDto bikeDto)
+ {
+ var model = bikeModelRepository.Read(bikeDto.ModelId)
+ ?? throw new ArgumentException($"BikeModel with id {bikeDto.ModelId} not found");
+
+ var bike = mapper.Map(bikeDto);
+ bike.Model = model;
+ bike.ModelId = bikeDto.ModelId;
+
+ var id = bikeRepository.Create(bike);
+
+ var createdBike = bikeRepository.Read(id);
+
+ return mapper.Map(createdBike);
+ }
+
+ ///
+ /// Returns all existing objects
+ ///
+ /// List of existing objects
+ public List GetAllBikes()
+ {
+ var bikes = bikeRepository.ReadAll();
+ return mapper.Map>(bikes);
+ }
+
+ ///
+ /// Returns object by id
+ ///
+ ///
+ ///
+ public BikeGetDto? GetBikeById(int id)
+ {
+ var bike = bikeRepository.Read(id);
+ return bike != null ? mapper.Map(bike) : null;
+ }
+
+ ///
+ /// Updates an existing object
+ ///
+ /// Id
+ /// DTO object
+ /// Object if exist
+ public BikeGetDto? UpdateBike(int id, BikeCreateUpdateDto bikeDto)
+ {
+ var existingBike = bikeRepository.Read(id);
+ if (existingBike == null) return null;
+
+ var model = bikeModelRepository.Read(bikeDto.ModelId);
+ if (model == null) return null;
+
+ mapper.Map(bikeDto, existingBike);
+
+ existingBike.Model = model;
+
+ var updatedBike = bikeRepository.Update(id, existingBike);
+ return updatedBike != null ? mapper.Map(updatedBike) : null;
+ }
+
+ ///
+ /// Deletes an existing object by id
+ ///
+ ///
+ /// True or false? result of deleting
+ public bool DeleteBike(int id) => bikeRepository.Delete(id);
+}
\ No newline at end of file
diff --git a/Bikes/Bikes.Application/Services/RentService.cs b/Bikes/Bikes.Application/Services/RentService.cs
new file mode 100644
index 000000000..9e313fe4e
--- /dev/null
+++ b/Bikes/Bikes.Application/Services/RentService.cs
@@ -0,0 +1,97 @@
+using AutoMapper;
+using Bikes.Application.Interfaces;
+using Bikes.Contracts.Dto;
+using Bikes.Domain.Models;
+using Bikes.Domain.Repositories;
+
+namespace Bikes.Application.Services;
+
+///
+/// A class that implements the interface of the RentService class
+///
+public class RentService(
+ IRepository rentRepository,
+ IRepository bikeRepository,
+ IRepository renterRepository,
+ IMapper mapper) : IRentService
+{
+ ///
+ /// Creates a new object
+ ///
+ /// DTO object
+ /// Created object DTO
+ ///
+ public RentGetDto CreateRent(RentCreateUpdateDto rentDto)
+ {
+ var bike = bikeRepository.Read(rentDto.BikeId)
+ ?? throw new ArgumentException($"Bike with id {rentDto.BikeId} not found");
+
+ var renter = renterRepository.Read(rentDto.RenterId)
+ ?? throw new ArgumentException($"Renter with id {rentDto.RenterId} not found");
+
+ var rent = mapper.Map(rentDto);
+ rent.Bike = bike;
+ rent.Renter = renter;
+ rent.BikeId = rentDto.BikeId;
+ rent.RenterId = rentDto.RenterId;
+
+ var id = rentRepository.Create(rent);
+ var createdRent = rentRepository.Read(id);
+
+ return mapper.Map(createdRent);
+ }
+
+ ///
+ /// Returns all existing objects
+ ///
+ ///
+ public List GetAllRents()
+ {
+ var rents = rentRepository.ReadAll();
+ return mapper.Map>(rents);
+ }
+
+ ///
+ /// Returns object by id
+ ///
+ ///
+ ///
+ public RentGetDto? GetRentById(int id)
+ {
+ var rent = rentRepository.Read(id);
+ return rent != null ? mapper.Map(rent) : null;
+ }
+
+ ///
+ /// Updates an existing object
+ ///
+ ///
+ ///
+ ///
+ public RentGetDto? UpdateRent(int id, RentCreateUpdateDto rentDto)
+ {
+ var existingRent = rentRepository.Read(id);
+ if (existingRent == null) return null;
+
+ var bike = bikeRepository.Read(rentDto.BikeId);
+ if (bike == null) return null;
+
+ var renter = renterRepository.Read(rentDto.RenterId);
+ if (renter == null) return null;
+
+ mapper.Map(rentDto, existingRent);
+
+ existingRent.Bike = bike;
+ existingRent.Renter = renter;
+
+ var updatedRent = rentRepository.Update(id, existingRent);
+ return updatedRent != null ? mapper.Map(updatedRent) : null;
+ }
+
+ ///
+ /// Deletes an existing object by id
+ ///
+ ///
+ ///
+ public bool DeleteRent(int id) => rentRepository.Delete(id);
+}
\ No newline at end of file
diff --git a/Bikes/Bikes.Application/Services/RenterService.cs b/Bikes/Bikes.Application/Services/RenterService.cs
new file mode 100644
index 000000000..557eaf076
--- /dev/null
+++ b/Bikes/Bikes.Application/Services/RenterService.cs
@@ -0,0 +1,75 @@
+using AutoMapper;
+using Bikes.Application.Interfaces;
+using Bikes.Contracts.Dto;
+using Bikes.Domain.Models;
+using Bikes.Domain.Repositories;
+
+namespace Bikes.Application.Services;
+
+///
+/// A class that implements the interface of the RenterService class
+///
+public class RenterService(
+ IRepository renterRepository,
+ IMapper mapper) : IRenterService
+{
+ ///
+ /// Creates a new object
+ ///
+ ///
+ ///
+ public RenterGetDto CreateRenter(RenterCreateUpdateDto renterDto)
+ {
+ var renter = mapper.Map(renterDto);
+
+ var id = renterRepository.Create(renter);
+ var createdRenter = renterRepository.Read(id);
+
+ return mapper.Map(createdRenter);
+ }
+
+ ///
+ /// Returns all existing objects
+ ///
+ ///
+ public List GetAllRenters()
+ {
+ var renters = renterRepository.ReadAll();
+ return mapper.Map>(renters);
+ }
+
+ ///
+ /// Returns object by id
+ ///
+ ///
+ ///
+ public RenterGetDto? GetRenterById(int id)
+ {
+ var renter = renterRepository.Read(id);
+ return renter != null ? mapper.Map(renter) : null;
+ }
+
+ ///
+ /// Updates an existing object
+ ///
+ ///
+ ///
+ ///
+ public RenterGetDto? UpdateRenter(int id, RenterCreateUpdateDto renterDto)
+ {
+ var existingRenter = renterRepository.Read(id);
+ if (existingRenter == null) return null;
+
+ mapper.Map(renterDto, existingRenter);
+
+ var updatedRenter = renterRepository.Update(id, existingRenter);
+ return updatedRenter != null ? mapper.Map(updatedRenter) : null;
+ }
+
+ ///
+ /// Deletes an existing object by id
+ ///
+ ///
+ ///
+ public bool DeleteRenter(int id) => renterRepository.Delete(id);
+}
\ No newline at end of file
diff --git a/Bikes/Bikes.Contracts/Bikes.Contracts.csproj b/Bikes/Bikes.Contracts/Bikes.Contracts.csproj
new file mode 100644
index 000000000..30da6d385
--- /dev/null
+++ b/Bikes/Bikes.Contracts/Bikes.Contracts.csproj
@@ -0,0 +1,13 @@
+
+
+
+ net8.0
+ enable
+ enable
+
+
+
+
+
+
+
diff --git a/Bikes/Bikes.Contracts/Dto/BikeCreateUpdateDto.cs b/Bikes/Bikes.Contracts/Dto/BikeCreateUpdateDto.cs
new file mode 100644
index 000000000..bb02c1cf1
--- /dev/null
+++ b/Bikes/Bikes.Contracts/Dto/BikeCreateUpdateDto.cs
@@ -0,0 +1,30 @@
+using System.ComponentModel.DataAnnotations;
+
+namespace Bikes.Contracts.Dto;
+
+///
+/// DTO create/update class for the Bike class
+///
+public class BikeCreateUpdateDto
+{
+ ///
+ /// Bike's serial number
+ ///
+ [Required(ErrorMessage = "Serial number is required")]
+ [StringLength(50, MinimumLength = 5, ErrorMessage = "Serial number must be between 5 and 50 characters")]
+ [RegularExpression(@"^[A-Z0-9]+$", ErrorMessage = "Serial number can only contain uppercase letters and numbers")]
+ public required string SerialNumber { get; set; }
+
+ ///
+ /// Bike's color
+ ///
+ [Required(ErrorMessage = "Color is required")]
+ [RegularExpression(@"^[a-zA-Zа-яА-ЯёЁ]+$", ErrorMessage = "Color can only contain letters")]
+ public required string Color { get; set; }
+
+ ///
+ /// Bike's model ID
+ ///
+ [Required(ErrorMessage = "Model ID is required")]
+ public required int ModelId { get; set; }
+}
\ No newline at end of file
diff --git a/Bikes/Bikes.Contracts/Dto/BikeGetDto.cs b/Bikes/Bikes.Contracts/Dto/BikeGetDto.cs
new file mode 100644
index 000000000..eb8ed55dd
--- /dev/null
+++ b/Bikes/Bikes.Contracts/Dto/BikeGetDto.cs
@@ -0,0 +1,27 @@
+namespace Bikes.Contracts.Dto;
+
+///
+/// DTO get class for the Bike class
+///
+public class BikeGetDto
+{
+ ///
+ /// Bike's unique id
+ ///
+ public required int Id { get; set; }
+
+ ///
+ /// Bike's serial number
+ ///
+ public required string SerialNumber { get; set; }
+
+ ///
+ /// Bike's color
+ ///
+ public required string Color { get; set; }
+
+ ///
+ /// Bike's model
+ ///
+ public required int ModelId { get; set; }
+}
\ No newline at end of file
diff --git a/Bikes/Bikes.Contracts/Dto/BikeModelCreateUpdateDto.cs b/Bikes/Bikes.Contracts/Dto/BikeModelCreateUpdateDto.cs
new file mode 100644
index 000000000..3c9a300e7
--- /dev/null
+++ b/Bikes/Bikes.Contracts/Dto/BikeModelCreateUpdateDto.cs
@@ -0,0 +1,57 @@
+using Bikes.Domain.Models;
+using System.ComponentModel.DataAnnotations;
+
+namespace Bikes.Contracts.Dto;
+
+///
+/// DTO create/update class for the BikeModel class
+///
+public class BikeModelCreateUpdateDto
+{
+ ///
+ /// Model's type
+ ///
+ [Required(ErrorMessage = "Type is required")]
+ public required BikeType Type { get; set; }
+
+ ///
+ /// Model's size of wheel
+ ///
+ [Required(ErrorMessage = "Wheel size is required")]
+ [Range(12, 36, ErrorMessage = "Wheel size must be between 12 and 36 inches")]
+ public required int WheelSize { get; set; }
+
+ ///
+ /// Maximum allowable passenger weight
+ ///
+ [Required(ErrorMessage = "Maximum passenger weight is required")]
+ [Range(25, 120, ErrorMessage = "Maximum passenger weight must be between 25 and 120 kg")]
+ public required int MaxPassengerWeight { get; set; }
+
+ ///
+ /// Model's weight
+ ///
+ [Required(ErrorMessage = "Weight is required")]
+ [Range(5, 30, ErrorMessage = "Weight must be between 5 and 30 kg")]
+ public required int Weight { get; set; }
+
+ ///
+ /// Model's type of brake
+ ///
+ [Required(ErrorMessage = "Brake type is required")]
+ public required string BrakeType { get; set; }
+
+ ///
+ /// Model's production year
+ ///
+ [Required(ErrorMessage = "Year is required")]
+ [Range(2010, 2025, ErrorMessage = "Year must be between 2010 and current year")]
+ public required int Year { get; set; }
+
+ ///
+ /// The price of an hour of rent
+ ///
+ [Required(ErrorMessage = "Rent price is required")]
+ [Range(1, 1000, ErrorMessage = "Rent price must be between 1 and 1000")]
+ public required int RentPrice { get; set; }
+}
\ No newline at end of file
diff --git a/Bikes/Bikes.Contracts/Dto/BikeModelGetDto.cs b/Bikes/Bikes.Contracts/Dto/BikeModelGetDto.cs
new file mode 100644
index 000000000..00c4c2544
--- /dev/null
+++ b/Bikes/Bikes.Contracts/Dto/BikeModelGetDto.cs
@@ -0,0 +1,49 @@
+using Bikes.Domain.Models;
+
+namespace Bikes.Contracts.Dto;
+
+///
+/// DTO get class for the BikeModel class
+///
+public class BikeModelGetDto
+{
+ ///
+ /// Model's unique id
+ ///
+ public required int Id { get; set; }
+
+ ///
+ /// Model's type
+ ///
+ public required BikeType Type { get; set; }
+
+ ///
+ /// Model's size of wheel
+ ///
+ public required int WheelSize { get; set; }
+
+ ///
+ /// Maximum allowable passenger weight
+ ///
+ public required int MaxPassengerWeight { get; set; }
+
+ ///
+ /// Model's weight
+ ///
+ public required int Weight { get; set; }
+
+ ///
+ /// Model's type of brake
+ ///
+ public required string BrakeType { get; set; }
+
+ ///
+ /// Model's production year
+ ///
+ public required int Year { get; set; }
+
+ ///
+ /// The price of an hour of rent
+ ///
+ public required int RentPrice { get; set; }
+}
\ No newline at end of file
diff --git a/Bikes/Bikes.Contracts/Dto/RentCreateUpdateDto.cs b/Bikes/Bikes.Contracts/Dto/RentCreateUpdateDto.cs
new file mode 100644
index 000000000..32cc4cf13
--- /dev/null
+++ b/Bikes/Bikes.Contracts/Dto/RentCreateUpdateDto.cs
@@ -0,0 +1,35 @@
+using System.ComponentModel.DataAnnotations;
+
+namespace Bikes.Contracts.Dto;
+
+///
+/// DTO create/update class for the Rent class
+///
+public class RentCreateUpdateDto
+{
+ ///
+ /// Rental start time
+ ///
+ [Required(ErrorMessage = "Rental start time is required")]
+ [DataType(DataType.DateTime, ErrorMessage = "Invalid date time format")]
+ public required DateTime RentalStartTime { get; set; }
+
+ ///
+ /// Rental duration (in hours)
+ ///
+ [Required(ErrorMessage = "Rental duration is required")]
+ [Range(1, 24, ErrorMessage = "Rental duration must be between 1 and 24 hours")]
+ public required int RentalDuration { get; set; }
+
+ ///
+ /// Renter's id
+ ///
+ [Required(ErrorMessage = "Renter ID is required")]
+ public required int RenterId { get; set; }
+
+ ///
+ /// Bike's id
+ ///
+ [Required(ErrorMessage = "Bike ID is required")]
+ public required int BikeId { get; set; }
+}
\ No newline at end of file
diff --git a/Bikes/Bikes.Contracts/Dto/RentGetDto.cs b/Bikes/Bikes.Contracts/Dto/RentGetDto.cs
new file mode 100644
index 000000000..31d2d25ee
--- /dev/null
+++ b/Bikes/Bikes.Contracts/Dto/RentGetDto.cs
@@ -0,0 +1,32 @@
+namespace Bikes.Contracts.Dto;
+
+///
+/// DTO get class for the Rent class
+///
+public class RentGetDto
+{
+ ///
+ /// Rent's unique id
+ ///
+ public required int Id { get; set; }
+
+ ///
+ /// Rental start time
+ ///
+ public required DateTime RentalStartTime { get; set; }
+
+ ///
+ /// Rental duration
+ ///
+ public required int RentalDuration { get; set; }
+
+ ///
+ /// Renter
+ ///
+ public required int RenterId { get; set; }
+
+ ///
+ /// Bike
+ ///
+ public required int BikeId { get; set; }
+}
\ No newline at end of file
diff --git a/Bikes/Bikes.Contracts/Dto/RentalDurationStatsDto.cs b/Bikes/Bikes.Contracts/Dto/RentalDurationStatsDto.cs
new file mode 100644
index 000000000..68e9cf8b9
--- /dev/null
+++ b/Bikes/Bikes.Contracts/Dto/RentalDurationStatsDto.cs
@@ -0,0 +1,22 @@
+namespace Bikes.Contracts.Dto;
+
+///
+/// DTO class for rental duration statistics
+///
+public class RentalDurationStatsDto
+{
+ ///
+ /// Minimum rental duration
+ ///
+ public required int Min { get; set; }
+
+ ///
+ /// Maximum rental duration
+ ///
+ public required int Max { get; set; }
+
+ ///
+ /// Average rental duration
+ ///
+ public required double Average { get; set; }
+}
\ No newline at end of file
diff --git a/Bikes/Bikes.Contracts/Dto/RenterCreateUpdateDto.cs b/Bikes/Bikes.Contracts/Dto/RenterCreateUpdateDto.cs
new file mode 100644
index 000000000..e1f76ffff
--- /dev/null
+++ b/Bikes/Bikes.Contracts/Dto/RenterCreateUpdateDto.cs
@@ -0,0 +1,25 @@
+using System.ComponentModel.DataAnnotations;
+
+namespace Bikes.Contracts.Dto;
+
+///
+/// DTO create/update class for the Renter class
+///
+public class RenterCreateUpdateDto
+{
+ ///
+ /// Renter's full name
+ ///
+ [Required(ErrorMessage = "Full name is required")]
+ [StringLength(100, MinimumLength = 5, ErrorMessage = "Full name must be between 5 and 100 characters")]
+ [RegularExpression(@"^[a-zA-Zа-яА-ЯёЁ\s\-]+$", ErrorMessage = "Full name can only contain letters, spaces and hyphens")]
+ public required string FullName { get; set; }
+
+ ///
+ /// Renter's phone number
+ ///
+ [Required(ErrorMessage = "Phone number is required")]
+ [RegularExpression(@"^\+7\s\(\d{3}\)\s\d{3}-\d{2}-\d{2}$",
+ ErrorMessage = "Phone number must be in format: +7 (XXX) XXX-XX-XX")]
+ public required string Number { get; set; }
+}
\ No newline at end of file
diff --git a/Bikes/Bikes.Contracts/Dto/RenterGetDto.cs b/Bikes/Bikes.Contracts/Dto/RenterGetDto.cs
new file mode 100644
index 000000000..62a63d15c
--- /dev/null
+++ b/Bikes/Bikes.Contracts/Dto/RenterGetDto.cs
@@ -0,0 +1,22 @@
+namespace Bikes.Contracts.Dto;
+
+///
+/// DTO get class for the Renter class
+///
+public class RenterGetDto
+{
+ ///
+ /// Renter's unique id
+ ///
+ public required int Id { get; set; }
+
+ ///
+ /// Renter's full name
+ ///
+ public required string FullName { get; set; }
+
+ ///
+ /// Renter's phone number
+ ///
+ public required string Number { get; set; }
+}
\ No newline at end of file
diff --git a/Bikes/Bikes.Domain/Bikes.Domain.csproj b/Bikes/Bikes.Domain/Bikes.Domain.csproj
new file mode 100644
index 000000000..fa71b7ae6
--- /dev/null
+++ b/Bikes/Bikes.Domain/Bikes.Domain.csproj
@@ -0,0 +1,9 @@
+
+
+
+ net8.0
+ enable
+ enable
+
+
+
diff --git a/Bikes/Bikes.Domain/Models/Bike.cs b/Bikes/Bikes.Domain/Models/Bike.cs
new file mode 100644
index 000000000..75e4a0b32
--- /dev/null
+++ b/Bikes/Bikes.Domain/Models/Bike.cs
@@ -0,0 +1,32 @@
+namespace Bikes.Domain.Models;
+
+///
+/// A class describing a bike
+///
+public class Bike
+{
+ ///
+ /// Bike's unique id
+ ///
+ public required int Id { get; set; }
+
+ ///
+ /// Bike's serial number
+ ///
+ public required string SerialNumber { get; set; }
+
+ ///
+ /// Bike's color
+ ///
+ public required string Color { get; set; }
+
+ ///
+ /// Bike's model id for database
+ ///
+ public required int ModelId { get; set; }
+
+ ///
+ /// Bike's model
+ ///
+ public required BikeModel Model { get; set; }
+}
diff --git a/Bikes/Bikes.Domain/Models/BikeModel.cs b/Bikes/Bikes.Domain/Models/BikeModel.cs
new file mode 100644
index 000000000..9d991ccd2
--- /dev/null
+++ b/Bikes/Bikes.Domain/Models/BikeModel.cs
@@ -0,0 +1,47 @@
+namespace Bikes.Domain.Models;
+
+///
+/// A class describing a bike's model
+///
+public class BikeModel
+{
+ ///
+ /// Model's unique id
+ ///
+ public required int Id { get; set; }
+
+ ///
+ /// Model's type
+ ///
+ public required BikeType Type { get; set; }
+
+ ///
+ /// Model's size of wheel
+ ///
+ public required int WheelSize { get; set; }
+
+ ///
+ /// Maximum allowable passenger weight
+ ///
+ public required int MaxPassengerWeight { get; set; }
+
+ ///
+ /// Model's weight
+ ///
+ public required int Weight { get; set; }
+
+ ///
+ /// Model's type of brake
+ ///
+ public required string BrakeType { get; set; }
+
+ ///
+ /// Model's production year
+ ///
+ public required int Year { get; set; }
+
+ ///
+ /// The price of an hour of rent
+ ///
+ public required int RentPrice { get; set; }
+}
diff --git a/Bikes/Bikes.Domain/Models/BikeType.cs b/Bikes/Bikes.Domain/Models/BikeType.cs
new file mode 100644
index 000000000..95f8e45a6
--- /dev/null
+++ b/Bikes/Bikes.Domain/Models/BikeType.cs
@@ -0,0 +1,22 @@
+namespace Bikes.Domain.Models;
+
+///
+/// A enum describing bike's type
+///
+public enum BikeType
+{
+ ///
+ /// Sports bike
+ ///
+ Sport,
+
+ ///
+ /// Mountain bike
+ ///
+ Mountain,
+
+ ///
+ /// City bike
+ ///
+ City
+}
diff --git a/Bikes/Bikes.Domain/Models/Rent.cs b/Bikes/Bikes.Domain/Models/Rent.cs
new file mode 100644
index 000000000..0ebfc2d22
--- /dev/null
+++ b/Bikes/Bikes.Domain/Models/Rent.cs
@@ -0,0 +1,42 @@
+namespace Bikes.Domain.Models;
+
+///
+/// A class describing a rent
+///
+public class Rent
+{
+ ///
+ /// Rent's unique id
+ ///
+ public required int Id { get; set; }
+
+ ///
+ /// Rental start time
+ ///
+ public required DateTime RentalStartTime { get; set; }
+
+ ///
+ /// Rental duration
+ ///
+ public required int RentalDuration { get; set; }
+
+ ///
+ /// Renter's id for database
+ ///
+ public required int RenterId { get; set; }
+
+ ///
+ /// Bike's id for database
+ ///
+ public required int BikeId { get; set; }
+
+ ///
+ /// Renter
+ ///
+ public required Renter Renter { get; set; }
+
+ ///
+ /// Bike
+ ///
+ public required Bike Bike { get; set; }
+}
diff --git a/Bikes/Bikes.Domain/Models/Renter.cs b/Bikes/Bikes.Domain/Models/Renter.cs
new file mode 100644
index 000000000..fb43cba2a
--- /dev/null
+++ b/Bikes/Bikes.Domain/Models/Renter.cs
@@ -0,0 +1,22 @@
+namespace Bikes.Domain.Models;
+
+///
+/// A class describing a renter
+///
+public class Renter
+{
+ ///
+ /// Renter's unique id
+ ///
+ public required int Id { get; set; }
+
+ ///
+ /// Renter's full name
+ ///
+ public required string FullName { get; set; }
+
+ ///
+ /// Renter's phone number
+ ///
+ public required string Number { get; set; }
+}
diff --git a/Bikes/Bikes.Domain/Repositories/IRepository.cs b/Bikes/Bikes.Domain/Repositories/IRepository.cs
new file mode 100644
index 000000000..4f831d8d9
--- /dev/null
+++ b/Bikes/Bikes.Domain/Repositories/IRepository.cs
@@ -0,0 +1,44 @@
+namespace Bikes.Domain.Repositories;
+
+///
+/// Repository interface for CRUD operations with domain objects.
+///
+/// Type of entity
+/// Type of identifier
+public interface IRepository
+{
+ ///
+ /// Creates a new object
+ ///
+ /// Object
+ /// ID of the created object
+ public TKey Create(TEntity entity);
+
+ ///
+ /// Returns all existing objects
+ ///
+ /// List of existing objects
+ public List ReadAll();
+
+ ///
+ /// Returns object by id
+ ///
+ /// Id
+ /// Object if exist
+ public TEntity? Read(TKey id);
+
+ ///
+ /// Updates an existing object
+ ///
+ /// Id
+ /// Object
+ /// Object if exist
+ public TEntity? Update(TKey id, TEntity entity);
+
+ ///
+ /// Deletes an existing object by id
+ ///
+ ///
+ /// True or false? result of deleting
+ public bool Delete(TKey id);
+}
diff --git a/Bikes/Bikes.Generator/Bikes.Generator.csproj b/Bikes/Bikes.Generator/Bikes.Generator.csproj
new file mode 100644
index 000000000..8fc72e53a
--- /dev/null
+++ b/Bikes/Bikes.Generator/Bikes.Generator.csproj
@@ -0,0 +1,30 @@
+
+
+
+ Exe
+ net8.0
+ enable
+ enable
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ PreserveNewest
+
+
+
+
\ No newline at end of file
diff --git a/Bikes/Bikes.Generator/ContractGenerator.cs b/Bikes/Bikes.Generator/ContractGenerator.cs
new file mode 100644
index 000000000..ca294cc80
--- /dev/null
+++ b/Bikes/Bikes.Generator/ContractGenerator.cs
@@ -0,0 +1,107 @@
+using Bikes.Contracts.Dto;
+using Bikes.Domain.Models;
+using Bogus;
+
+namespace Bikes.Generator;
+
+///
+/// Generates fake data for various entities using Bogus library
+///
+public class ContractGenerator
+{
+ private readonly Faker _bikeFaker;
+ private readonly Faker _bikeModelFaker;
+ private readonly Faker _renterFaker;
+ private readonly Faker _rentFaker;
+
+ ///
+ /// Initializes the generator with fake data rules for all entity types
+ ///
+ public ContractGenerator()
+ {
+ _bikeFaker = new Faker()
+ .CustomInstantiator(f => new BikeCreateUpdateDto
+ {
+ SerialNumber = $"BIKE{f.Random.Int(1000, 9999)}",
+ Color = f.PickRandom("Красный", "Синий", "Зеленый", "Черный", "Белый"),
+ ModelId = f.Random.Int(1, 10)
+ });
+
+ _bikeModelFaker = new Faker()
+ .CustomInstantiator(f => new BikeModelCreateUpdateDto
+ {
+ Type = f.PickRandom(),
+ WheelSize = f.Random.Int(20, 29),
+ MaxPassengerWeight = f.Random.Int(70, 120),
+ Weight = f.Random.Int(10, 20),
+ BrakeType = f.PickRandom("Дисковые гидравлические", "Ободные v-brake", "Дисковые механические"),
+ Year = f.Random.Int(2020, 2024),
+ RentPrice = f.Random.Int(300, 1000)
+ });
+
+ _renterFaker = new Faker()
+ .CustomInstantiator(f => new RenterCreateUpdateDto
+ {
+ FullName = f.Name.FullName(),
+ Number = $"+7 ({f.Random.Int(900, 999)}) {f.Random.Int(100, 999)}-{f.Random.Int(10, 99)}-{f.Random.Int(10, 99)}"
+ });
+
+ _rentFaker = new Faker()
+ .CustomInstantiator(f => new RentCreateUpdateDto
+ {
+ RentalStartTime = f.Date.Soon(1),
+ RentalDuration = f.Random.Int(1, 24),
+ RenterId = f.Random.Int(1, 10),
+ BikeId = f.Random.Int(1, 10)
+ });
+ }
+
+ ///
+ /// Generates a fake bike DTO with random data
+ ///
+ /// Generated bike DTO
+ public BikeCreateUpdateDto GenerateBike() => _bikeFaker.Generate();
+
+ ///
+ /// Generates a fake bike model DTO with random data
+ ///
+ /// Generated bike model DTO
+ public BikeModelCreateUpdateDto GenerateBikeModel() => _bikeModelFaker.Generate();
+
+ ///
+ /// Generates a fake renter DTO with random data
+ ///
+ /// Generated renter DTO
+ public RenterCreateUpdateDto GenerateRenter() => _renterFaker.Generate();
+
+ ///
+ /// Generates a fake rent DTO with random data
+ ///
+ /// Generated rent DTO
+ public RentCreateUpdateDto GenerateRent() => _rentFaker.Generate();
+
+ ///
+ /// Generates a batch of random entities
+ ///
+ /// Number of entities to generate
+ /// List of generated entity DTOs
+ public List