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 GenerateBatch(int size) + { + var batch = new List(); + + for (var i = 0; i < size; i++) + { + var entityType = new Random().Next(0, 4); + batch.Add(entityType switch + { + 0 => GenerateBike(), + 1 => GenerateBikeModel(), + 2 => GenerateRenter(), + 3 => GenerateRent(), + _ => GenerateBike() + }); + } + + return batch; + } +} \ No newline at end of file diff --git a/Bikes/Bikes.Generator/IKafkaProducerFactory.cs b/Bikes/Bikes.Generator/IKafkaProducerFactory.cs new file mode 100644 index 000000000..848ac0c40 --- /dev/null +++ b/Bikes/Bikes.Generator/IKafkaProducerFactory.cs @@ -0,0 +1,15 @@ +using Confluent.Kafka; + +namespace Bikes.Generator; + +/// +/// Factory interface for creating Kafka producers +/// +public interface IKafkaProducerFactory +{ + /// + /// Creates and returns a Kafka producer instance + /// + /// Configured Kafka producer + public IProducer CreateProducer(); +} \ No newline at end of file diff --git a/Bikes/Bikes.Generator/KafkaProducerFactory.cs b/Bikes/Bikes.Generator/KafkaProducerFactory.cs new file mode 100644 index 000000000..8f5561120 --- /dev/null +++ b/Bikes/Bikes.Generator/KafkaProducerFactory.cs @@ -0,0 +1,125 @@ +using Bikes.Generator.Options; +using Confluent.Kafka; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; + +namespace Bikes.Generator; + +/// +/// Factory implementation for creating and managing Kafka producer instances +/// +public class KafkaProducerFactory( + IOptions options, + ILogger logger) : IKafkaProducerFactory, IDisposable +{ + private readonly KafkaOptions _options = options.Value; + private IProducer? _producer; + + /// + /// Gets the Kafka bootstrap servers from environment variable or configuration + /// + /// Bootstrap servers connection string + private string GetBootstrapServers() + { + var connectionString = Environment.GetEnvironmentVariable("ConnectionStrings__kafka"); + if (!string.IsNullOrEmpty(connectionString)) + { + return connectionString; + } + + return _options.BootstrapServers; + } + + /// + /// Creates a Kafka producer with retry logic and connection validation + /// + /// Configured Kafka producer instance + public IProducer CreateProducer() + { + if (_producer != null) + return _producer; + + var bootstrapServers = GetBootstrapServers(); + + Console.WriteLine($"Kafka Producer connecting to: {bootstrapServers}"); + + var config = new ProducerConfig + { + BootstrapServers = bootstrapServers, + EnableDeliveryReports = true, + + ApiVersionRequest = false, + ApiVersionFallbackMs = 0, + BrokerVersionFallback = "0.10.0.0", + SecurityProtocol = SecurityProtocol.Plaintext, + SslEndpointIdentificationAlgorithm = SslEndpointIdentificationAlgorithm.None, + + SocketTimeoutMs = 30000, + MessageTimeoutMs = 30000, + RequestTimeoutMs = 30000, + + EnableIdempotence = false, + Acks = Acks.Leader + }; + + var retryCount = 0; + while (retryCount < _options.MaxRetryAttempts) + { + try + { + _producer = new ProducerBuilder(config) + .SetLogHandler((_, message) => + logger.LogInformation("Kafka: {Facility} - {Message}", message.Facility, message.Message)) + .SetErrorHandler((_, error) => + logger.LogError("Kafka Error: {Reason} (Code: {Code})", error.Reason, error.Code)) + .Build(); + + logger.LogInformation("Kafka producer connected successfully to {BootstrapServers}", + bootstrapServers); + + return _producer; + } + catch (Exception ex) + { + retryCount++; + logger.LogWarning(ex, + "Failed to connect to Kafka (attempt {RetryCount}/{MaxRetries}). Retrying in {DelayMs}ms...", + retryCount, _options.MaxRetryAttempts, _options.RetryDelayMs); + + if (retryCount >= _options.MaxRetryAttempts) + { + logger.LogError(ex, "Max retry attempts reached. Failed to connect to Kafka."); + throw; + } + + Thread.Sleep(_options.RetryDelayMs); + } + } + + throw new InvalidOperationException("Failed to create Kafka producer"); + } + + /// + /// Disposes the Kafka producer instance + /// + public void Dispose() + { + try + { + if (_producer != null) + { + _producer.Flush(TimeSpan.FromSeconds(5)); + _producer.Dispose(); + _producer = null; + } + } + catch (Exception ex) + { + logger?.LogError(ex, "Error while disposing Kafka producer"); + } + finally + { + GC.SuppressFinalize(this); + } + } +} \ No newline at end of file diff --git a/Bikes/Bikes.Generator/KafkaProducerService.cs b/Bikes/Bikes.Generator/KafkaProducerService.cs new file mode 100644 index 000000000..c4aab5b8e --- /dev/null +++ b/Bikes/Bikes.Generator/KafkaProducerService.cs @@ -0,0 +1,217 @@ +using Bikes.Generator.Options; +using Confluent.Kafka; +using Confluent.Kafka.Admin; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; +using System.Text.Json; + +namespace Bikes.Generator; + +/// +/// Background service for generating and publishing fake data to Kafka +/// +public class KafkaProducerService( + IOptions generatorOptions, + IOptions kafkaOptions, + ContractGenerator contractGenerator, + IKafkaProducerFactory producerFactory, + ILogger logger) : BackgroundService +{ + private readonly GeneratorOptions _generatorOptions = generatorOptions.Value; + private readonly string _bootstrapServers = kafkaOptions.Value.BootstrapServers; + private readonly JsonSerializerOptions _jsonOptions = new() + { + PropertyNamingPolicy = JsonNamingPolicy.CamelCase, + WriteIndented = false + }; + + /// + /// Gets the Kafka bootstrap servers from environment variable or configuration + /// + /// Bootstrap servers connection string + private string GetBootstrapServers() + { + var connectionString = Environment.GetEnvironmentVariable("ConnectionStrings__kafka"); + if (!string.IsNullOrEmpty(connectionString)) + return connectionString; + + return _bootstrapServers; + } + + /// + /// Main execution method that generates and publishes data to Kafka + /// + protected override async Task ExecuteAsync(CancellationToken stoppingToken) + { + logger.LogInformation("Starting Kafka Producer Service"); + logger.LogInformation("Configuration: Interval={IntervalMs}ms, BatchSize={BatchSize}, Topic={Topic}", + _generatorOptions.IntervalMs, _generatorOptions.BatchSize, _generatorOptions.Topic); + + if (!_generatorOptions.Enabled) + { + logger.LogInformation("Generator is disabled. Service will not produce messages."); + return; + } + + await CreateTopicIfNotExistsAsync(stoppingToken); + + var producer = producerFactory.CreateProducer(); + + await Task.Delay(2000, stoppingToken); + + logger.LogInformation("Starting message generation..."); + + while (!stoppingToken.IsCancellationRequested) + { + try + { + var batch = contractGenerator.GenerateBatch(_generatorOptions.BatchSize); + + foreach (var contract in batch) + { + var message = CreateKafkaMessage(contract); + if (message != null) + { + await ProduceMessageAsync(producer, message, stoppingToken); + } + } + + logger.LogDebug("Generated and sent batch of {Count} messages", batch.Count); + + await Task.Delay(_generatorOptions.IntervalMs, stoppingToken); + } + catch (OperationCanceledException) + { + logger.LogInformation("Producer service is stopping..."); + break; + } + catch (Exception ex) + { + logger.LogError(ex, "Error in producer service"); + await Task.Delay(5000, stoppingToken); + } + } + + producer.Flush(stoppingToken); + logger.LogInformation("Kafka Producer Service stopped"); + } + + /// + /// Creates the Kafka topic if it doesn't already exist + /// + private async Task CreateTopicIfNotExistsAsync(CancellationToken cancellationToken) + { + try + { + logger.LogInformation("Checking if topic '{Topic}' exists...", _generatorOptions.Topic); + + var bootstrapServers = GetBootstrapServers(); + + logger.LogInformation("Using Kafka at: {BootstrapServers}", bootstrapServers); + + using var adminClient = new AdminClientBuilder(new AdminClientConfig + { + BootstrapServers = bootstrapServers, + ApiVersionRequest = false, + BrokerVersionFallback = "0.10.0.0", + SecurityProtocol = SecurityProtocol.Plaintext + }).Build(); + + try + { + var metadata = adminClient.GetMetadata(TimeSpan.FromSeconds(10)); + var topicExists = metadata.Topics.Any(t => t.Topic == _generatorOptions.Topic && !t.Error.IsError); + + if (topicExists) + { + logger.LogInformation("Topic '{Topic}' already exists", _generatorOptions.Topic); + return; + } + } + catch (KafkaException ex) + { + logger.LogWarning(ex, "Failed to get Kafka metadata"); + } + + logger.LogInformation("Creating topic '{Topic}'...", _generatorOptions.Topic); + + try + { + await adminClient.CreateTopicsAsync( + [ + new() + { + Name = _generatorOptions.Topic, + NumPartitions = 1, + ReplicationFactor = 1 + } + ]); + + logger.LogInformation("Topic '{Topic}' created successfully", _generatorOptions.Topic); + + await Task.Delay(3000, cancellationToken); + } + catch (CreateTopicsException ex) when (ex.Results[0].Error.Code == ErrorCode.TopicAlreadyExists) + { + logger.LogInformation("Topic '{Topic}' already exists", _generatorOptions.Topic); + } + } + catch (Exception ex) + { + logger.LogWarning(ex, "Error checking/creating topic. Will try to continue..."); + } + } + + /// + /// Creates a Kafka message from a contract object + /// + /// The contract object to serialize + /// Kafka message or null if serialization fails + private Message? CreateKafkaMessage(object contract) + { + try + { + var json = JsonSerializer.Serialize(contract, contract.GetType(), _jsonOptions); + return new Message + { + Value = json, + Headers = + [ + new Header("contract-type", System.Text.Encoding.UTF8.GetBytes(contract.GetType().Name)) + ] + }; + } + catch (Exception ex) + { + logger.LogError(ex, "Failed to serialize contract: {ContractType}", contract.GetType().Name); + return null; + } + } + + /// + /// Publishes a message to Kafka topic + /// + private async Task ProduceMessageAsync( + IProducer producer, + Message message, + CancellationToken cancellationToken) + { + try + { + var deliveryResult = await producer.ProduceAsync( + _generatorOptions.Topic, + message, + cancellationToken); + + logger.LogDebug("Message delivered to {Topic} [{Partition}] @ {Offset}", + deliveryResult.Topic, + deliveryResult.Partition, + deliveryResult.Offset); + } + catch (ProduceException ex) + { + logger.LogError(ex, "Failed to deliver message: {Error}", ex.Error.Reason); + } + } +} \ No newline at end of file diff --git a/Bikes/Bikes.Generator/Options/GeneratorOptions.cs b/Bikes/Bikes.Generator/Options/GeneratorOptions.cs new file mode 100644 index 000000000..017ced6e5 --- /dev/null +++ b/Bikes/Bikes.Generator/Options/GeneratorOptions.cs @@ -0,0 +1,27 @@ +namespace Bikes.Generator.Options; + +/// +/// Configuration options for the bikes data generator +/// +public class GeneratorOptions +{ + /// + /// Gets or sets the generation interval in milliseconds + /// + public int IntervalMs { get; set; } = 5000; + + /// + /// Gets or sets the number of bike records to generate per batch + /// + public int BatchSize { get; set; } = 1; + + /// + /// Gets or sets the Kafka topic name for publishing generated data + /// + public string Topic { get; set; } = "bikes-contracts"; + + /// + /// Gets or sets a value indicating whether the generator is enabled + /// + public bool Enabled { get; set; } = true; +} \ No newline at end of file diff --git a/Bikes/Bikes.Generator/Options/KafkaOptions.cs b/Bikes/Bikes.Generator/Options/KafkaOptions.cs new file mode 100644 index 000000000..0c6b21b78 --- /dev/null +++ b/Bikes/Bikes.Generator/Options/KafkaOptions.cs @@ -0,0 +1,27 @@ +namespace Bikes.Generator.Options; + +/// +/// Configuration options for Kafka producer +/// +public class KafkaOptions +{ + /// + /// Gets or sets the Kafka bootstrap servers address + /// + public string BootstrapServers { get; set; } = "localhost:9092"; + + /// + /// Gets or sets the maximum number of retry attempts for failed operations + /// + 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 the message timeout in milliseconds + /// ( + builder.Configuration.GetSection("Generator")); +builder.Services.Configure( + builder.Configuration.GetSection("Kafka")); + +builder.Services.AddSingleton(); +builder.Services.AddSingleton(); +builder.Services.AddSingleton(); + +builder.Services.AddHostedService(provider => + provider.GetRequiredService()); + +var host = builder.Build(); + +await host.RunAsync(); \ No newline at end of file diff --git a/Bikes/Bikes.Generator/appsettings.json b/Bikes/Bikes.Generator/appsettings.json new file mode 100644 index 000000000..af2ee7bae --- /dev/null +++ b/Bikes/Bikes.Generator/appsettings.json @@ -0,0 +1,24 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.Hosting.Lifetime": "Information", + "Bikes.Generator": "Debug" + } + }, + "Generator": { + "IntervalMs": 3000, + "BatchSize": 2, + "Topic": "bikes-contracts", + "Enabled": true + }, + "Kafka": { + "MaxRetryAttempts": 10, + "RetryDelayMs": 2000, + "MessageTimeoutMs": 15000, + "ApiVersionRequest": false, + "ApiVersionFallbackMs": 0, + "BrokerVersionFallback": "0.10.0.0", + "SecurityProtocol": "Plaintext" + } +} \ No newline at end of file diff --git a/Bikes/Bikes.Infrastructure.InMemory/Bikes.Infrastructure.InMemory.csproj b/Bikes/Bikes.Infrastructure.InMemory/Bikes.Infrastructure.InMemory.csproj new file mode 100644 index 000000000..30da6d385 --- /dev/null +++ b/Bikes/Bikes.Infrastructure.InMemory/Bikes.Infrastructure.InMemory.csproj @@ -0,0 +1,13 @@ + + + + net8.0 + enable + enable + + + + + + + diff --git a/Bikes/Bikes.Infrastructure.InMemory/Repositories/InMemoryBikeModelRepository.cs b/Bikes/Bikes.Infrastructure.InMemory/Repositories/InMemoryBikeModelRepository.cs new file mode 100644 index 000000000..7f00d4dd4 --- /dev/null +++ b/Bikes/Bikes.Infrastructure.InMemory/Repositories/InMemoryBikeModelRepository.cs @@ -0,0 +1,91 @@ +using Bikes.Domain.Models; +using Bikes.Domain.Repositories; +using Bikes.Infrastructure.InMemory.Seeders; + +namespace Bikes.Infrastructure.InMemory.Repositories; + +/// +/// Implementing a repository for the BikeModel class +/// +public class InMemoryBikeModelRepository : IRepository +{ + private readonly List _items; + + private int _currentId; + + /// + /// A constructor that uses data from InMemorySeeder + /// + public InMemoryBikeModelRepository() + { + _items = InMemorySeeder.GetBikeModels(); + _currentId = _items.Count > 0 ? _items.Count : 0; + } + + /// + /// Creates a new object + /// + /// Object + /// ID of the created object + public int Create(BikeModel entity) + { + entity.Id = ++_currentId; + _items.Add(entity); + return entity.Id; + } + + /// + /// Returns all existing objects + /// + /// List of existing objects + public List ReadAll() + { + return [.. _items]; + } + + /// + /// Returns object by id + /// + /// Id + /// Object if exist + public BikeModel? Read(int id) + { + return _items.FirstOrDefault(b => b.Id == id); + } + + /// + /// Updates an existing object + /// + /// Id + /// Object + /// Object if exist + public BikeModel? Update(int id, BikeModel entity) + { + var exsitingEntity = Read(id); + if (exsitingEntity == null) return null; + + exsitingEntity.Type = entity.Type; + exsitingEntity.WheelSize = entity.WheelSize; + exsitingEntity.MaxPassengerWeight = entity.MaxPassengerWeight; + exsitingEntity.Weight = entity.Weight; + exsitingEntity.BrakeType = entity.BrakeType; + exsitingEntity.Year = entity.Year; + exsitingEntity.RentPrice = entity.RentPrice; + + return exsitingEntity; + } + + /// + /// Deletes an existing object by id + /// + /// + /// True or false? result of deleting + public bool Delete(int id) + { + var exsitingEntity = Read(id); + if (exsitingEntity == null) return false; + + _items.Remove(exsitingEntity); + return true; + } +} diff --git a/Bikes/Bikes.Infrastructure.InMemory/Repositories/InMemoryBikeRepository.cs b/Bikes/Bikes.Infrastructure.InMemory/Repositories/InMemoryBikeRepository.cs new file mode 100644 index 000000000..92cda4790 --- /dev/null +++ b/Bikes/Bikes.Infrastructure.InMemory/Repositories/InMemoryBikeRepository.cs @@ -0,0 +1,87 @@ +using Bikes.Domain.Models; +using Bikes.Domain.Repositories; +using Bikes.Infrastructure.InMemory.Seeders; + +namespace Bikes.Infrastructure.InMemory.Repositories; + +/// +/// Implementing a repository for the Bike class +/// +public class InMemoryBikeRepository : IRepository +{ + private readonly List _items; + + private int _currentId; + + /// + /// A constructor that uses data from InMemorySeeder + /// + public InMemoryBikeRepository() + { + _items = InMemorySeeder.GetBikes(); + _currentId = _items.Count > 0 ? _items.Count : 0; + } + + /// + /// Creates a new object + /// + /// Object + /// ID of the created object + public int Create(Bike entity) + { + entity.Id = ++_currentId; + _items.Add(entity); + return entity.Id; + } + + /// + /// Returns all existing objects + /// + /// List of existing objects + public List ReadAll() + { + return [.. _items]; + } + + /// + /// Returns object by id + /// + /// Id + /// Object if exist + public Bike? Read(int id) + { + return _items.FirstOrDefault(b => b.Id == id); + } + + /// + /// Updates an existing object + /// + /// Id + /// Object + /// Object if exist + public Bike? Update(int id, Bike entity) + { + var exsitingEntity = Read(id); + if (exsitingEntity == null) return null; + + exsitingEntity.SerialNumber = entity.SerialNumber; + exsitingEntity.Color = entity.Color; + exsitingEntity.Model = entity.Model; + + return exsitingEntity; + } + + /// + /// Deletes an existing object by id + /// + /// + /// True or false? result of deleting + public bool Delete(int id) + { + var exsitingEntity = Read(id); + if (exsitingEntity == null) return false; + + _items.Remove(exsitingEntity); + return true; + } +} diff --git a/Bikes/Bikes.Infrastructure.InMemory/Repositories/InMemoryRentRepository.cs b/Bikes/Bikes.Infrastructure.InMemory/Repositories/InMemoryRentRepository.cs new file mode 100644 index 000000000..e4c101613 --- /dev/null +++ b/Bikes/Bikes.Infrastructure.InMemory/Repositories/InMemoryRentRepository.cs @@ -0,0 +1,88 @@ +using Bikes.Domain.Models; +using Bikes.Domain.Repositories; +using Bikes.Infrastructure.InMemory.Seeders; + +namespace Bikes.Infrastructure.InMemory.Repositories; + +/// +/// Implementing a repository for the Rent class +/// +public class InMemoryRentRepository : IRepository +{ + private readonly List _items; + + private int _currentId; + + /// + /// A constructor that uses data from InMemorySeeder + /// + public InMemoryRentRepository() + { + _items = InMemorySeeder.GetRents(); + _currentId = _items.Count > 0 ? _items.Count : 0; + } + + /// + /// Creates a new object + /// + /// Object + /// ID of the created object + public int Create(Rent entity) + { + entity.Id = ++_currentId; + _items.Add(entity); + return entity.Id; + } + + /// + /// Returns all existing objects + /// + /// List of existing objects + public List ReadAll() + { + return [.. _items]; + } + + /// + /// Returns object by id + /// + /// Id + /// Object if exist + public Rent? Read(int id) + { + return _items.FirstOrDefault(b => b.Id == id); + } + + /// + /// Updates an existing object + /// + /// Id + /// Object + /// Object if exist + public Rent? Update(int id, Rent entity) + { + var exsitingEntity = Read(id); + if (exsitingEntity == null) return null; + + exsitingEntity.RentalStartTime = entity.RentalStartTime; + exsitingEntity.RentalDuration = entity.RentalDuration; + exsitingEntity.Renter = entity.Renter; + exsitingEntity.Bike = entity.Bike; + + return exsitingEntity; + } + + /// + /// Deletes an existing object by id + /// + /// + /// True or false? result of deleting + public bool Delete(int id) + { + var exsitingEntity = Read(id); + if (exsitingEntity == null) return false; + + _items.Remove(exsitingEntity); + return true; + } +} diff --git a/Bikes/Bikes.Infrastructure.InMemory/Repositories/InMemoryRenterRepository.cs b/Bikes/Bikes.Infrastructure.InMemory/Repositories/InMemoryRenterRepository.cs new file mode 100644 index 000000000..dc9310a00 --- /dev/null +++ b/Bikes/Bikes.Infrastructure.InMemory/Repositories/InMemoryRenterRepository.cs @@ -0,0 +1,86 @@ +using Bikes.Domain.Models; +using Bikes.Domain.Repositories; +using Bikes.Infrastructure.InMemory.Seeders; + +namespace Bikes.Infrastructure.InMemory.Repositories; + +/// +/// Implementing a repository for the Renter class +/// +public class InMemoryRenterRepository : IRepository +{ + private readonly List _items; + + private int _currentId; + + /// + /// A constructor that uses data from InMemorySeeder + /// + public InMemoryRenterRepository() + { + _items = InMemorySeeder.GetRenters(); + _currentId = _items.Count > 0 ? _items.Count : 0; + } + + /// + /// Creates a new object + /// + /// Object + /// ID of the created object + public int Create(Renter entity) + { + entity.Id = ++_currentId; + _items.Add(entity); + return entity.Id; + } + + /// + /// Returns all existing objects + /// + /// List of existing objects + public List ReadAll() + { + return [.. _items]; + } + + /// + /// Returns object by id + /// + /// Id + /// Object if exist + public Renter? Read(int id) + { + return _items.FirstOrDefault(b => b.Id == id); + } + + /// + /// Updates an existing object + /// + /// Id + /// Object + /// Object if exist + public Renter? Update(int id, Renter entity) + { + var exsitingEntity = Read(id); + if (exsitingEntity == null) return null; + + exsitingEntity.FullName = entity.FullName; + exsitingEntity.Number = entity.Number; + + return exsitingEntity; + } + + /// + /// Deletes an existing object by id + /// + /// + /// True or false? result of deleting + public bool Delete(int id) + { + var exsitingEntity = Read(id); + if (exsitingEntity == null) return false; + + _items.Remove(exsitingEntity); + return true; + } +} diff --git a/Bikes/Bikes.Infrastructure.InMemory/Seeders/InMemorySeeder.cs b/Bikes/Bikes.Infrastructure.InMemory/Seeders/InMemorySeeder.cs new file mode 100644 index 000000000..a2d465c7d --- /dev/null +++ b/Bikes/Bikes.Infrastructure.InMemory/Seeders/InMemorySeeder.cs @@ -0,0 +1,106 @@ +using Bikes.Domain.Models; + +namespace Bikes.Infrastructure.InMemory.Seeders; + +/// +/// InMemorySeeder for creating the data +/// +public static class InMemorySeeder +{ + /// + /// A static method that creates a list of bike models + /// + public static List GetBikeModels() + { + return + [ + new() { Id = 1, Type = BikeType.Mountain, WheelSize = 29, MaxPassengerWeight = 120, Weight = 14, BrakeType = "Дисковые гидравлические", Year = 2023, RentPrice = 700 }, + new() { Id = 2, Type = BikeType.Sport, WheelSize = 27, MaxPassengerWeight = 110, Weight = 11, BrakeType = "Ободные v-brake", Year = 2024, RentPrice = 850 }, + new() { Id = 3, Type = BikeType.City, WheelSize = 26, MaxPassengerWeight = 130, Weight = 16, BrakeType = "Дисковые механические", Year = 2022, RentPrice = 500 }, + new() { Id = 4, Type = BikeType.Mountain, WheelSize = 26, MaxPassengerWeight = 125, Weight = 15, BrakeType = "Дисковые гидравлические", Year = 2023, RentPrice = 750 }, + new() { Id = 5, Type = BikeType.Sport, WheelSize = 26, MaxPassengerWeight = 115, Weight = 12, BrakeType = "Ободные карбоновые", Year = 2024, RentPrice = 900 }, + new() { Id = 6, Type = BikeType.City, WheelSize = 27, MaxPassengerWeight = 135, Weight = 17, BrakeType = "Дисковые механические", Year = 2023, RentPrice = 550 }, + new() { Id = 7, Type = BikeType.Mountain, WheelSize = 29, MaxPassengerWeight = 120, Weight = 13, BrakeType = "Дисковые гидравлические", Year = 2024, RentPrice = 800 }, + new() { Id = 8, Type = BikeType.Sport, WheelSize = 27, MaxPassengerWeight = 110, Weight = 10, BrakeType = "Ободные v-brake", Year = 2023, RentPrice = 950 }, + new() { Id = 9, Type = BikeType.City, WheelSize = 26, MaxPassengerWeight = 140, Weight = 18, BrakeType = "Дисковые механические", Year = 2022, RentPrice = 600 }, + new() { Id = 10, Type = BikeType.Mountain, WheelSize = 26, MaxPassengerWeight = 130, Weight = 14, BrakeType = "Дисковые гидравлические", Year = 2024, RentPrice = 650 } + ]; + } + + /// + /// A static method that creates a list of bikes + /// + public static List GetBikes() + { + var models = GetBikeModels(); + + return + [ + new() { Id = 1, SerialNumber = "MTB202301001", Color = "Черный", ModelId = models[0].Id, Model = models[0] }, + new() { Id = 2, SerialNumber = "SPT202402001", Color = "Красный", ModelId = models[1].Id, Model = models[1] }, + new() { Id = 3, SerialNumber = "CTY202203001", Color = "Синий", ModelId = models[2].Id, Model = models[2] }, + new() { Id = 4, SerialNumber = "MTB202302001", Color = "Зеленый", ModelId = models[3].Id, Model = models[3] }, + new() { Id = 5, SerialNumber = "SPT202403001", Color = "Желтый", ModelId = models[4].Id, Model = models[4] }, + new() { Id = 6, SerialNumber = "CTY202304001", Color = "Белый", ModelId = models[5].Id, Model = models[5] }, + new() { Id = 7, SerialNumber = "MTB202404001", Color = "Оранжевый", ModelId = models[6].Id, Model = models[6] }, + new() { Id = 8, SerialNumber = "SPT202305001", Color = "Фиолетовый", ModelId = models[7].Id, Model = models[7] }, + new() { Id = 9, SerialNumber = "CTY202205001", Color = "Серый", ModelId = models[8].Id, Model = models[8] }, + new() { Id = 10, SerialNumber = "MTB202405001", Color = "Голубой", ModelId = models[9].Id, Model = models[9] } + ]; + } + + /// + /// A static method that creates a list of renters + /// + public static List GetRenters() + { + return + [ + new() { Id = 1, FullName = "Иванов Иван Иванович", Number = "+7 (912) 345-67-89" }, + new() { Id = 2, FullName = "Петров Петр Сергеевич", Number = "+7 (923) 456-78-90" }, + new() { Id = 3, FullName = "Сидорова Анна Владимировна", Number = "+7 (934) 567-89-01" }, + new() { Id = 4, FullName = "Кузнецов Алексей Дмитриевич", Number = "+7 (945) 678-90-12" }, + new() { Id = 5, FullName = "Смирнова Екатерина Олеговна", Number = "+7 (956) 789-01-23" }, + new() { Id = 6, FullName = "Попов Денис Андреевич", Number = "+7 (967) 890-12-34" }, + new() { Id = 7, FullName = "Васильева Мария Игоревна", Number = "+7 (978) 901-23-45" }, + new() { Id = 8, FullName = "Николаев Сергей Викторович", Number = "+7 (989) 012-34-56" }, + new() { Id = 9, FullName = "Орлова Ольга Павловна", Number = "+7 (990) 123-45-67" }, + new() { Id = 10, FullName = "Федоров Артем Константинович", Number = "+7 (901) 234-56-78" } + ]; + } + + /// + /// A static method that creates a list of rents + /// + public static List GetRents() + { + var bikes = GetBikes(); + var renters = GetRenters(); + + return + [ + new() { Id = 1, RentalStartTime = new DateTime(2025, 6, 10, 9, 0, 0), RentalDuration = 3, RenterId = renters[0].Id, BikeId = bikes[0].Id, Renter = renters[0], Bike = bikes[0] }, + new() { Id = 2, RentalStartTime = new DateTime(2025, 6, 12, 14, 30, 0), RentalDuration = 2, RenterId = renters[1].Id, BikeId = bikes[0].Id, Renter = renters[1], Bike = bikes[0] }, + new() { Id = 3, RentalStartTime = new DateTime(2025, 6, 15, 10, 0, 0), RentalDuration = 4, RenterId = renters[2].Id, BikeId = bikes[0].Id, Renter = renters[2], Bike = bikes[0] }, + new() { Id = 4, RentalStartTime = new DateTime(2025, 6, 18, 16, 0, 0), RentalDuration = 1, RenterId = renters[3].Id, BikeId = bikes[1].Id, Renter = renters[3], Bike = bikes[1] }, + new() { Id = 5, RentalStartTime = new DateTime(2025, 6, 20, 11, 0, 0), RentalDuration = 5, RenterId = renters[4].Id, BikeId = bikes[1].Id, Renter = renters[4], Bike = bikes[1] }, + new() { Id = 6, RentalStartTime = new DateTime(2025, 6, 22, 13, 0, 0), RentalDuration = 2, RenterId = renters[5].Id, BikeId = bikes[1].Id, Renter = renters[5], Bike = bikes[1] }, + new() { Id = 7, RentalStartTime = new DateTime(2025, 6, 25, 15, 30, 0), RentalDuration = 3, RenterId = renters[6].Id, BikeId = bikes[2].Id, Renter = renters[6], Bike = bikes[2] }, + new() { Id = 8, RentalStartTime = new DateTime(2025, 6, 28, 9, 30, 0), RentalDuration = 4, RenterId = renters[7].Id, BikeId = bikes[2].Id, Renter = renters[7], Bike = bikes[2] }, + new() { Id = 9, RentalStartTime = new DateTime(2025, 7, 1, 12, 0, 0), RentalDuration = 1, RenterId = renters[8].Id, BikeId = bikes[3].Id, Renter = renters[8], Bike = bikes[3] }, + new() { Id = 10, RentalStartTime = new DateTime(2025, 7, 3, 17, 0, 0), RentalDuration = 2, RenterId = renters[9].Id, BikeId = bikes[3].Id, Renter = renters[9], Bike = bikes[3] }, + new() { Id = 11, RentalStartTime = new DateTime(2025, 7, 5, 10, 0, 0), RentalDuration = 3, RenterId = renters[0].Id, BikeId = bikes[4].Id, Renter = renters[0], Bike = bikes[4] }, + new() { Id = 12, RentalStartTime = new DateTime(2025, 7, 8, 14, 0, 0), RentalDuration = 5, RenterId = renters[0].Id, BikeId = bikes[4].Id, Renter = renters[0], Bike = bikes[4] }, + new() { Id = 13, RentalStartTime = new DateTime(2025, 7, 10, 16, 30, 0), RentalDuration = 2, RenterId = renters[0].Id, BikeId = bikes[5].Id, Renter = renters[0], Bike = bikes[5] }, + new() { Id = 14, RentalStartTime = new DateTime(2025, 7, 12, 11, 0, 0), RentalDuration = 4, RenterId = renters[0].Id, BikeId = bikes[6].Id, Renter = renters[0], Bike = bikes[6] }, + new() { Id = 15, RentalStartTime = new DateTime(2025, 7, 15, 13, 0, 0), RentalDuration = 1, RenterId = renters[1].Id, BikeId = bikes[7].Id, Renter = renters[1], Bike = bikes[7] }, + new() { Id = 16, RentalStartTime = new DateTime(2025, 7, 18, 15, 0, 0), RentalDuration = 3, RenterId = renters[1].Id, BikeId = bikes[8].Id, Renter = renters[1], Bike = bikes[8] }, + new() { Id = 17, RentalStartTime = new DateTime(2025, 7, 20, 9, 0, 0), RentalDuration = 2, RenterId = renters[1].Id, BikeId = bikes[9].Id, Renter = renters[1], Bike = bikes[9] }, + new() { Id = 18, RentalStartTime = new DateTime(2025, 7, 22, 12, 30, 0), RentalDuration = 5, RenterId = renters[5].Id, BikeId = bikes[9].Id, Renter = renters[5], Bike = bikes[9] }, + new() { Id = 19, RentalStartTime = new DateTime(2025, 7, 25, 14, 0, 0), RentalDuration = 3, RenterId = renters[5].Id, BikeId = bikes[9].Id, Renter = renters[5], Bike = bikes[9] }, + new() { Id = 20, RentalStartTime = new DateTime(2025, 7, 28, 16, 0, 0), RentalDuration = 4, RenterId = renters[2].Id, BikeId = bikes[9].Id, Renter = renters[2], Bike = bikes[9] } + ]; + } + +} + diff --git a/Bikes/Bikes.Infrastructure.MongoDb/Bikes.Infrastructure.MongoDb.csproj b/Bikes/Bikes.Infrastructure.MongoDb/Bikes.Infrastructure.MongoDb.csproj new file mode 100644 index 000000000..d17b0eee6 --- /dev/null +++ b/Bikes/Bikes.Infrastructure.MongoDb/Bikes.Infrastructure.MongoDb.csproj @@ -0,0 +1,23 @@ + + + + net8.0 + enable + enable + + + + + + + + + + + + + + + + + diff --git a/Bikes/Bikes.Infrastructure.MongoDb/BikesDbContext.cs b/Bikes/Bikes.Infrastructure.MongoDb/BikesDbContext.cs new file mode 100644 index 000000000..e8ae42d62 --- /dev/null +++ b/Bikes/Bikes.Infrastructure.MongoDb/BikesDbContext.cs @@ -0,0 +1,41 @@ +using Bikes.Domain.Models; +using Microsoft.EntityFrameworkCore; +using MongoDB.EntityFrameworkCore.Extensions; + +namespace Bikes.Infrastructure.MongoDb; + +/// +/// Database context for working with MongoDB +/// +public class BikesDbContext : DbContext +{ + public BikesDbContext(DbContextOptions options) : base(options) + { + Database.AutoTransactionBehavior = AutoTransactionBehavior.Never; + } + + public DbSet BikeModels => Set(); + public DbSet Bikes => Set(); + public DbSet Renters => Set(); + public DbSet Rents => Set(); + + /// + /// Configuring the database model. + /// + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + base.OnModelCreating(modelBuilder); + + modelBuilder.Entity().ToCollection("bike_models"); + modelBuilder.Entity().ToCollection("bikes"); + modelBuilder.Entity().ToCollection("renters"); + modelBuilder.Entity().ToCollection("rents"); + + modelBuilder.Entity() + .Ignore(b => b.Model); + + modelBuilder.Entity() + .Ignore(r => r.Renter) + .Ignore(r => r.Bike); + } +} \ No newline at end of file diff --git a/Bikes/Bikes.Infrastructure.MongoDb/Configuration/MongoDbSettings.cs b/Bikes/Bikes.Infrastructure.MongoDb/Configuration/MongoDbSettings.cs new file mode 100644 index 000000000..8b18e89f7 --- /dev/null +++ b/Bikes/Bikes.Infrastructure.MongoDb/Configuration/MongoDbSettings.cs @@ -0,0 +1,19 @@ +namespace Bikes.Infrastructure.MongoDb.Configuration; + +/// +/// MongoDB Connection Settings +/// +public class MongoDbSettings +{ + public const string SectionName = "MongoDb"; + + /// + /// Connection string + /// + public string ConnectionString { get; set; } = string.Empty; + + /// + /// Database Name + /// + public string DatabaseName { get; set; } = string.Empty; +} \ No newline at end of file diff --git a/Bikes/Bikes.Infrastructure.MongoDb/Extensions/ServiceCollectionExtensions.cs b/Bikes/Bikes.Infrastructure.MongoDb/Extensions/ServiceCollectionExtensions.cs new file mode 100644 index 000000000..c68ce0e3f --- /dev/null +++ b/Bikes/Bikes.Infrastructure.MongoDb/Extensions/ServiceCollectionExtensions.cs @@ -0,0 +1,49 @@ +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.EntityFrameworkCore; +using Bikes.Domain.Repositories; +using Bikes.Infrastructure.MongoDb.Configuration; +using Bikes.Infrastructure.MongoDb.Repositories; + +namespace Bikes.Infrastructure.MongoDb.Extensions; + +/// +/// A class for hidden registration of services +/// +public static class ServiceCollectionExtensions +{ + /// + /// A method for registering MongoDB services through the Entity Framework Core + /// + /// Collection of services + /// Application Configuration + /// Collection of services + public static IServiceCollection AddMongoDbInfrastructure( + this IServiceCollection services, + IConfiguration configuration) + { + var connectionString = configuration.GetConnectionString("MongoDB"); + var databaseName = configuration["MongoDb:DatabaseName"]!; + + services.Configure(options => + { + options.ConnectionString = connectionString + ?? "mongodb://localhost:27017"; + options.DatabaseName = databaseName; + }); + + services.AddDbContext(options => + { + options.UseMongoDB(connectionString ?? "mongodb://localhost:27017", databaseName); + }); + + services.AddScoped(); + + services.AddScoped, MongoBikeRepository>(); + services.AddScoped, MongoBikeModelRepository>(); + services.AddScoped, MongoRenterRepository>(); + services.AddScoped, MongoRentRepository>(); + + return services; + } +} \ No newline at end of file diff --git a/Bikes/Bikes.Infrastructure.MongoDb/MongoDbSeeder.cs b/Bikes/Bikes.Infrastructure.MongoDb/MongoDbSeeder.cs new file mode 100644 index 000000000..91f21da24 --- /dev/null +++ b/Bikes/Bikes.Infrastructure.MongoDb/MongoDbSeeder.cs @@ -0,0 +1,38 @@ +using Microsoft.EntityFrameworkCore; +using Bikes.Infrastructure.InMemory.Seeders; +using Microsoft.Extensions.Logging; + +namespace Bikes.Infrastructure.MongoDb; + +/// +/// A class for initializing initial data in MongoDB +/// +public class MongoDbSeeder( + BikesDbContext context, + ILogger logger) +{ + public async Task SeedAsync() + { + if (await context.Bikes.AnyAsync()) + { + logger.LogInformation("Database already contains data. Skipping seeding."); + return; + } + + logger.LogInformation("Starting MongoDB database seeding..."); + + var models = InMemorySeeder.GetBikeModels(); + var bikes = InMemorySeeder.GetBikes(); + var renters = InMemorySeeder.GetRenters(); + var rents = InMemorySeeder.GetRents(); + + await context.BikeModels.AddRangeAsync(models); + await context.Bikes.AddRangeAsync(bikes); + await context.Renters.AddRangeAsync(renters); + await context.Rents.AddRangeAsync(rents); + + await context.SaveChangesAsync(); + + logger.LogInformation("Seeding completed."); + } +} \ No newline at end of file diff --git a/Bikes/Bikes.Infrastructure.MongoDb/Repositories/MongoBikeModelRepository.cs b/Bikes/Bikes.Infrastructure.MongoDb/Repositories/MongoBikeModelRepository.cs new file mode 100644 index 000000000..e80e4a475 --- /dev/null +++ b/Bikes/Bikes.Infrastructure.MongoDb/Repositories/MongoBikeModelRepository.cs @@ -0,0 +1,86 @@ +using Bikes.Domain.Models; +using Bikes.Domain.Repositories; + +namespace Bikes.Infrastructure.MongoDb.Repositories; + +/// +/// A repository for working with bike models in MongoDB +/// +public class MongoBikeModelRepository( + BikesDbContext context) : IRepository +{ + /// + /// Creates a new object + /// + /// Object + /// ID of the created object + public int Create(BikeModel entity) + { + if (entity.Id == 0) + { + var lastId = context.BikeModels + .OrderByDescending(b => b.Id) + .Select(b => b.Id) + .FirstOrDefault(); + + entity.Id = lastId + 1; + } + + context.BikeModels.Add(entity); + context.SaveChanges(); + return entity.Id; + } + + /// + /// Returns all existing objects + /// + /// List of existing objects + public List ReadAll() + { + return [.. context.BikeModels]; + } + + /// + /// Returns object by id + /// + /// Id + /// Object if exist + public BikeModel? Read(int id) + { + return context.BikeModels.Find(id); + } + + /// + /// Updates an existing object + /// + /// Id + /// Object + /// Object if exist + public BikeModel? Update(int id, BikeModel entity) + { + var existingModel = context.BikeModels.Find(id); + if (existingModel == null) return null; + + context.Entry(existingModel).CurrentValues.SetValues(entity); + + existingModel.Id = id; + + context.SaveChanges(); + return existingModel; + } + + /// + /// Deletes an existing object by id + /// + /// + /// True or false? result of deleting + public bool Delete(int id) + { + var model = context.BikeModels.Find(id); + if (model == null) return false; + + context.BikeModels.Remove(model); + context.SaveChanges(); + return true; + } +} \ No newline at end of file diff --git a/Bikes/Bikes.Infrastructure.MongoDb/Repositories/MongoBikeRepository.cs b/Bikes/Bikes.Infrastructure.MongoDb/Repositories/MongoBikeRepository.cs new file mode 100644 index 000000000..babbc8847 --- /dev/null +++ b/Bikes/Bikes.Infrastructure.MongoDb/Repositories/MongoBikeRepository.cs @@ -0,0 +1,86 @@ +using Bikes.Domain.Models; +using Bikes.Domain.Repositories; + +namespace Bikes.Infrastructure.MongoDb.Repositories; + +/// +/// A repository for working with bikes in MongoDB +/// +public class MongoBikeRepository( + BikesDbContext context) : IRepository +{ + /// + /// Creates a new object + /// + /// Object + /// ID of the created object + public int Create(Bike entity) + { + if (entity.Id == 0) + { + var lastId = context.Bikes + .OrderByDescending(b => b.Id) + .Select(b => b.Id) + .FirstOrDefault(); + + entity.Id = lastId + 1; + } + + context.Bikes.Add(entity); + context.SaveChanges(); + return entity.Id; + } + + /// + /// Returns all existing objects + /// + /// List of existing objects + public List ReadAll() + { + return [.. context.Bikes]; + } + + /// + /// Returns object by id + /// + /// Id + /// Object if exist + public Bike? Read(int id) + { + return context.Bikes.FirstOrDefault(b => b.Id == id); + } + + /// + /// Updates an existing object + /// + /// Id + /// Object + /// Object if exist + public Bike? Update(int id, Bike entity) + { + var existingBike = context.Bikes.FirstOrDefault(b => b.Id == id); + if (existingBike == null) return null; + + existingBike.SerialNumber = entity.SerialNumber; + existingBike.Color = entity.Color; + existingBike.ModelId = entity.ModelId; + + context.SaveChanges(); + return existingBike; + } + + /// + /// Deletes an existing object by id + /// + /// + /// True or false? result of deleting + public bool Delete(int id) + { + var bike = context.Bikes.Find(id); + if (bike == null) return false; + + context.Bikes.Remove(bike); + context.SaveChanges(); + return true; + } +} \ No newline at end of file diff --git a/Bikes/Bikes.Infrastructure.MongoDb/Repositories/MongoRentRepository.cs b/Bikes/Bikes.Infrastructure.MongoDb/Repositories/MongoRentRepository.cs new file mode 100644 index 000000000..e0c711cc9 --- /dev/null +++ b/Bikes/Bikes.Infrastructure.MongoDb/Repositories/MongoRentRepository.cs @@ -0,0 +1,87 @@ +using Bikes.Domain.Models; +using Bikes.Domain.Repositories; + +namespace Bikes.Infrastructure.MongoDb.Repositories; + +/// +/// A repository for working with rents in MongoDB +/// +public class MongoRentRepository( + BikesDbContext context) : IRepository +{ + /// + /// Creates a new object + /// + /// Object + /// ID of the created object + public int Create(Rent entity) + { + if (entity.Id == 0) + { + var lastId = context.Rents + .OrderByDescending(b => b.Id) + .Select(b => b.Id) + .FirstOrDefault(); + + entity.Id = lastId + 1; + } + + context.Rents.Add(entity); + context.SaveChanges(); + return entity.Id; + } + + /// + /// Returns all existing objects + /// + /// List of existing objects + public List ReadAll() + { + return [.. context.Rents]; + } + + /// + /// Returns object by id + /// + /// Id + /// Object if exist + public Rent? Read(int id) + { + return context.Rents.FirstOrDefault(r => r.Id == id); + } + + /// + /// Updates an existing object + /// + /// Id + /// Object + /// Object if exist + public Rent? Update(int id, Rent entity) + { + var existingRent = context.Rents.FirstOrDefault(r => r.Id == id); + if (existingRent == null) return null; + + existingRent.RentalStartTime = entity.RentalStartTime; + existingRent.RentalDuration = entity.RentalDuration; + existingRent.BikeId = entity.BikeId; + existingRent.RenterId = entity.RenterId; + + context.SaveChanges(); + return existingRent; + } + + /// + /// Deletes an existing object by id + /// + /// + /// True or false? result of deleting + public bool Delete(int id) + { + var rent = context.Rents.Find(id); + if (rent == null) return false; + + context.Rents.Remove(rent); + context.SaveChanges(); + return true; + } +} \ No newline at end of file diff --git a/Bikes/Bikes.Infrastructure.MongoDb/Repositories/MongoRenterRepository.cs b/Bikes/Bikes.Infrastructure.MongoDb/Repositories/MongoRenterRepository.cs new file mode 100644 index 000000000..eef8a4860 --- /dev/null +++ b/Bikes/Bikes.Infrastructure.MongoDb/Repositories/MongoRenterRepository.cs @@ -0,0 +1,85 @@ +using Bikes.Domain.Models; +using Bikes.Domain.Repositories; + +namespace Bikes.Infrastructure.MongoDb.Repositories; + +/// +/// A repository for working with renters in MongoDB +/// +public class MongoRenterRepository( + BikesDbContext context) : IRepository +{ + /// + /// Creates a new object + /// + /// Object + /// ID of the created object + public int Create(Renter entity) + { + if (entity.Id == 0) + { + var lastId = context.Renters + .OrderByDescending(b => b.Id) + .Select(b => b.Id) + .FirstOrDefault(); + + entity.Id = lastId + 1; + } + + context.Renters.Add(entity); + context.SaveChanges(); + return entity.Id; + } + + /// + /// Returns all existing objects + /// + /// List of existing objects + public List ReadAll() + { + return [.. context.Renters]; + } + + /// + /// Returns object by id + /// + /// Id + /// Object if exist + public Renter? Read(int id) + { + return context.Renters.Find(id); + } + + /// + /// Updates an existing object + /// + /// Id + /// Object + /// Object if exist + public Renter? Update(int id, Renter entity) + { + var existingRenter = context.Renters.Find(id); + if (existingRenter == null) return null; + + context.Entry(existingRenter).CurrentValues.SetValues(entity); + existingRenter.Id = id; + + context.SaveChanges(); + return existingRenter; + } + + /// + /// Deletes an existing object by id + /// + /// + /// True or false? result of deleting + public bool Delete(int id) + { + var renter = context.Renters.Find(id); + if (renter == null) return false; + + context.Renters.Remove(renter); + context.SaveChanges(); + return true; + } +} \ No newline at end of file diff --git a/Bikes/Bikes.ServiceDefaults/Bikes.ServiceDefaults.csproj b/Bikes/Bikes.ServiceDefaults/Bikes.ServiceDefaults.csproj new file mode 100644 index 000000000..760aa0e6c --- /dev/null +++ b/Bikes/Bikes.ServiceDefaults/Bikes.ServiceDefaults.csproj @@ -0,0 +1,24 @@ + + + + net8.0 + enable + enable + true + false + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/Bikes/Bikes.ServiceDefaults/Extensions.cs b/Bikes/Bikes.ServiceDefaults/Extensions.cs new file mode 100644 index 000000000..697fac22f --- /dev/null +++ b/Bikes/Bikes.ServiceDefaults/Extensions.cs @@ -0,0 +1,88 @@ +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Diagnostics.HealthChecks; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Diagnostics.HealthChecks; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Hosting; +using OpenTelemetry.Logs; +using OpenTelemetry.Metrics; +using OpenTelemetry.Trace; + +namespace Bikes.ServiceDefaults; + +/// +/// Extensions for configuring standard services and middleware applications +/// +public static class Extensions +{ + /// + /// Adds standard services and default settings for the application + /// + /// Application Builder for service configuration + /// The same builder for the call chain + public static IHostApplicationBuilder AddServiceDefaults(this IHostApplicationBuilder builder) + { + builder.AddDefaultHealthChecks(); + + builder.Services.AddServiceDiscovery(); + builder.Services.ConfigureHttpClientDefaults(http => + { + http.AddStandardResilienceHandler(); + http.AddServiceDiscovery(); + }); + + builder.Logging.AddOpenTelemetry(logging => + { + logging.IncludeFormattedMessage = true; + logging.IncludeScopes = true; + }); + + builder.Services.AddOpenTelemetry() + .WithMetrics(metrics => + { + metrics.AddAspNetCoreInstrumentation() + .AddHttpClientInstrumentation() + .AddRuntimeInstrumentation(); + }) + .WithTracing(tracing => + { + tracing.AddAspNetCoreInstrumentation() + .AddHttpClientInstrumentation(); + }); + + builder.Services.Configure(logging => logging.AddOtlpExporter()); + builder.Services.ConfigureOpenTelemetryMeterProvider(metrics => metrics.AddOtlpExporter()); + builder.Services.ConfigureOpenTelemetryTracerProvider(tracing => tracing.AddOtlpExporter()); + + return builder; + } + + /// + /// Adds standard health checks for the app + /// + /// Health checks registration application builder + /// The same builder for the call chain + public static IHostApplicationBuilder AddDefaultHealthChecks(this IHostApplicationBuilder builder) + { + builder.Services.AddHealthChecks() + .AddCheck("self", () => HealthCheckResult.Healthy(), ["live"]); + + return builder; + } + + /// + /// Sets up standard endpoints for the application + /// + /// An instance of WebApplication for configuring routes + /// The same application instance for the call chain + public static WebApplication MapDefaultEndpoints(this WebApplication app) + { + app.MapHealthChecks("/health"); + app.MapHealthChecks("/alive", new HealthCheckOptions + { + Predicate = r => r.Tags.Contains("live") + }); + + return app; + } +} \ No newline at end of file diff --git a/Bikes/Bikes.Tests/Bikes.Tests.csproj b/Bikes/Bikes.Tests/Bikes.Tests.csproj new file mode 100644 index 000000000..27dc94319 --- /dev/null +++ b/Bikes/Bikes.Tests/Bikes.Tests.csproj @@ -0,0 +1,29 @@ + + + + net8.0 + enable + enable + + false + true + + + + + + + + + + + + + + + + + + + + diff --git a/Bikes/Bikes.Tests/BikesFixture.cs b/Bikes/Bikes.Tests/BikesFixture.cs new file mode 100644 index 000000000..af3590f7a --- /dev/null +++ b/Bikes/Bikes.Tests/BikesFixture.cs @@ -0,0 +1,37 @@ +using AutoMapper; +using Bikes.Application.Interfaces; +using Bikes.Application.Services; +using Bikes.Application.Mapping; +using Bikes.Domain.Repositories; +using Bikes.Infrastructure.InMemory.Repositories; + +namespace Bikes.Tests; + +/// +/// A class for tests +/// +public class BikesFixture +{ + public readonly IAnalyticsService AnalyticsService; + private readonly IMapper _mapper; + + /// + /// A constructor that creates repositories and service classes + /// + public BikesFixture() + { + IRepository bikeRepo = new InMemoryBikeRepository(); + IRepository modelRepo = new InMemoryBikeModelRepository(); + IRepository rentRepo = new InMemoryRentRepository(); + IRepository renterRepo = new InMemoryRenterRepository(); + + var configuration = new MapperConfiguration(cfg => + { + cfg.AddProfile(); + }); + + _mapper = configuration.CreateMapper(); + + AnalyticsService = new AnalyticsService(bikeRepo, rentRepo, renterRepo, modelRepo, _mapper); + } +} diff --git a/Bikes/Bikes.Tests/BikesTests.cs b/Bikes/Bikes.Tests/BikesTests.cs new file mode 100644 index 000000000..81ebb655f --- /dev/null +++ b/Bikes/Bikes.Tests/BikesTests.cs @@ -0,0 +1,129 @@ +using Bikes.Domain.Models; + +namespace Bikes.Tests; + +/// +/// A class that implements a set of unit tests +/// +public class BikesTests(BikesFixture fixture) : IClassFixture +{ + /// + /// A test that outputs information about all sports bikes + /// + [Fact] + public void InformationAboutSportBikes() + { + var expectedSerialNumbers = new List + { + "SPT202402001", // Bike Id 2 + "SPT202403001", // Bike Id 5 + "SPT202305001" // Bike Id 8 + }; + + var sportBikes = fixture.AnalyticsService.GetSportBikes(); + + Assert.Equal(3, sportBikes.Count); + + var actualSerialNumbers = sportBikes.Select(bike => bike.SerialNumber).ToList(); + Assert.Equal(expectedSerialNumbers, actualSerialNumbers); + } + + /// + /// A test that outputs the top 5 bike models by rental duration + /// + [Fact] + public void TopFiveModelsRentDurationIds() + { + var expectedModelTypes = new List + { + BikeType.Mountain, // Model Id 10 + BikeType.Mountain, // Model Id 1 + BikeType.Sport, // Model Id 2 + BikeType.Sport, // Model Id 5 + BikeType.City // Model Id 3 + }; + + var topModels = fixture.AnalyticsService.GetTopFiveModelsByRentDuration(); + + Assert.Equal(5, topModels.Count); + + var actualModelTypes = topModels.Select(model => model.Type).ToList(); + Assert.Equal(expectedModelTypes, actualModelTypes); + } + + /// + /// A test that outputs the top 5 bike models in terms of rental income + /// + [Fact] + public void TopFiveModelsProfit() + { + var expectedModelTypes = new List + { + BikeType.Mountain, // Model Id 10 + BikeType.Sport, // Model Id 5 + BikeType.Sport, // Model Id 2 + BikeType.Mountain, // Model Id 1 + BikeType.City // Model Id 3 + }; + + var topModels = fixture.AnalyticsService.GetTopFiveModelsByProfit(); + + Assert.Equal(5, topModels.Count); + + var actualModelTypes = topModels.Select(model => model.Type).ToList(); + Assert.Equal(expectedModelTypes, actualModelTypes); + } + + /// + /// A test that outputs information about the minimum, maximum, and average bike rental time. + /// + [Fact] + public void MinMaxAvgRentalDuration() + { + const int expectedMin = 1; + const int expectedMax = 5; + const double expectedAvg = 2.95; + + var stats = fixture.AnalyticsService.GetRentalDurationStats(); + + Assert.Equal(expectedMin, stats.Min); + Assert.Equal(expectedMax, stats.Max); + Assert.Equal(expectedAvg, stats.Average); + } + + /// + /// A test that outputs the total rental time of each type of bike + /// + [Theory] + [InlineData(BikeType.Sport, 17)] + [InlineData(BikeType.Mountain, 30)] + [InlineData(BikeType.City, 12)] + public void TotalRentalTimeByType(BikeType bikeType, int expectedRentalTime) + { + var rentalTimeByType = fixture.AnalyticsService.GetTotalRentalTimeByType(); + var actualRentalTime = rentalTimeByType[bikeType]; + + Assert.Equal(expectedRentalTime, actualRentalTime); + } + + /// + /// A test that outputs information about the customers who have rented bicycles the most times. + /// + [Fact] + public void TopThreeRenters() + { + var expectedFullNames = new List + { + "Иванов Иван Иванович", // Renter Id 1 + "Петров Петр Сергеевич", // Renter Id 2 + "Попов Денис Андреевич" // Renter Id 6 + }; + + var topRenters = fixture.AnalyticsService.GetTopThreeRenters(); + + Assert.Equal(3, topRenters.Count); + + var actualFullNames = topRenters.Select(renter => renter.FullName).ToList(); + Assert.Equal(expectedFullNames, actualFullNames); + } +} \ No newline at end of file diff --git a/Bikes/Bikes.sln b/Bikes/Bikes.sln new file mode 100644 index 000000000..8f2e3d1ba --- /dev/null +++ b/Bikes/Bikes.sln @@ -0,0 +1,82 @@ + +Microsoft Visual Studio Solution File, Format Version 12.00 +# Visual Studio Version 17 +VisualStudioVersion = 17.13.35806.99 +MinimumVisualStudioVersion = 10.0.40219.1 +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Bikes.Domain", "Bikes.Domain\Bikes.Domain.csproj", "{B6E3E827-ADA9-4B0E-B704-484513A051A8}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Bikes.Tests", "Bikes.Tests\Bikes.Tests.csproj", "{54F042E8-6681-4802-B300-ADFE25207ACB}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Bikes.Infrastructure.InMemory", "Bikes.Infrastructure.InMemory\Bikes.Infrastructure.InMemory.csproj", "{610AD524-2BD4-429B-AAC9-D5AD48B8C50D}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Bikes.Application", "Bikes.Application\Bikes.Application.csproj", "{619487B4-C5C4-4396-B2DF-18B83CD522CE}" + ProjectSection(ProjectDependencies) = postProject + {B6E3E827-ADA9-4B0E-B704-484513A051A8} = {B6E3E827-ADA9-4B0E-B704-484513A051A8} + EndProjectSection +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Bikes.Api.Host", "Bikes.Api.Host\Bikes.Api.Host.csproj", "{B5E2CA3E-DD81-4B28-8783-1D1EE5352EF3}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Bikes.Contracts", "Bikes.Contracts\Bikes.Contracts.csproj", "{2BE08B58-C908-406E-8B12-5EA2B44C39DB}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Bikes.Infrastructure.MongoDb", "Bikes.Infrastructure.MongoDb\Bikes.Infrastructure.MongoDb.csproj", "{4F00B2E3-DB82-4F90-9086-D9AA517058DF}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Bikes.ServiceDefaults", "Bikes.ServiceDefaults\Bikes.ServiceDefaults.csproj", "{51AF03C6-E1D9-4256-83CA-AE064A256692}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Bikes.AppHost", "Bikes.AppHost\Bikes.AppHost.csproj", "{C5C91635-69D6-41B4-8A1C-67D6A6AADE1D}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Bikes.Generator", "Bikes.Generator\Bikes.Generator.csproj", "{C6E99932-52ED-4DC1-B308-991E13074111}" +EndProject +Global + GlobalSection(SolutionConfigurationPlatforms) = preSolution + Debug|Any CPU = Debug|Any CPU + Release|Any CPU = Release|Any CPU + EndGlobalSection + GlobalSection(ProjectConfigurationPlatforms) = postSolution + {B6E3E827-ADA9-4B0E-B704-484513A051A8}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {B6E3E827-ADA9-4B0E-B704-484513A051A8}.Debug|Any CPU.Build.0 = Debug|Any CPU + {B6E3E827-ADA9-4B0E-B704-484513A051A8}.Release|Any CPU.ActiveCfg = Release|Any CPU + {B6E3E827-ADA9-4B0E-B704-484513A051A8}.Release|Any CPU.Build.0 = Release|Any CPU + {54F042E8-6681-4802-B300-ADFE25207ACB}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {54F042E8-6681-4802-B300-ADFE25207ACB}.Debug|Any CPU.Build.0 = Debug|Any CPU + {54F042E8-6681-4802-B300-ADFE25207ACB}.Release|Any CPU.ActiveCfg = Release|Any CPU + {54F042E8-6681-4802-B300-ADFE25207ACB}.Release|Any CPU.Build.0 = Release|Any CPU + {610AD524-2BD4-429B-AAC9-D5AD48B8C50D}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {610AD524-2BD4-429B-AAC9-D5AD48B8C50D}.Debug|Any CPU.Build.0 = Debug|Any CPU + {610AD524-2BD4-429B-AAC9-D5AD48B8C50D}.Release|Any CPU.ActiveCfg = Release|Any CPU + {610AD524-2BD4-429B-AAC9-D5AD48B8C50D}.Release|Any CPU.Build.0 = Release|Any CPU + {619487B4-C5C4-4396-B2DF-18B83CD522CE}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {619487B4-C5C4-4396-B2DF-18B83CD522CE}.Debug|Any CPU.Build.0 = Debug|Any CPU + {619487B4-C5C4-4396-B2DF-18B83CD522CE}.Release|Any CPU.ActiveCfg = Release|Any CPU + {619487B4-C5C4-4396-B2DF-18B83CD522CE}.Release|Any CPU.Build.0 = Release|Any CPU + {B5E2CA3E-DD81-4B28-8783-1D1EE5352EF3}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {B5E2CA3E-DD81-4B28-8783-1D1EE5352EF3}.Debug|Any CPU.Build.0 = Debug|Any CPU + {B5E2CA3E-DD81-4B28-8783-1D1EE5352EF3}.Release|Any CPU.ActiveCfg = Release|Any CPU + {B5E2CA3E-DD81-4B28-8783-1D1EE5352EF3}.Release|Any CPU.Build.0 = Release|Any CPU + {2BE08B58-C908-406E-8B12-5EA2B44C39DB}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {2BE08B58-C908-406E-8B12-5EA2B44C39DB}.Debug|Any CPU.Build.0 = Debug|Any CPU + {2BE08B58-C908-406E-8B12-5EA2B44C39DB}.Release|Any CPU.ActiveCfg = Release|Any CPU + {2BE08B58-C908-406E-8B12-5EA2B44C39DB}.Release|Any CPU.Build.0 = Release|Any CPU + {4F00B2E3-DB82-4F90-9086-D9AA517058DF}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {4F00B2E3-DB82-4F90-9086-D9AA517058DF}.Debug|Any CPU.Build.0 = Debug|Any CPU + {4F00B2E3-DB82-4F90-9086-D9AA517058DF}.Release|Any CPU.ActiveCfg = Release|Any CPU + {4F00B2E3-DB82-4F90-9086-D9AA517058DF}.Release|Any CPU.Build.0 = Release|Any CPU + {51AF03C6-E1D9-4256-83CA-AE064A256692}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {51AF03C6-E1D9-4256-83CA-AE064A256692}.Debug|Any CPU.Build.0 = Debug|Any CPU + {51AF03C6-E1D9-4256-83CA-AE064A256692}.Release|Any CPU.ActiveCfg = Release|Any CPU + {51AF03C6-E1D9-4256-83CA-AE064A256692}.Release|Any CPU.Build.0 = Release|Any CPU + {C5C91635-69D6-41B4-8A1C-67D6A6AADE1D}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {C5C91635-69D6-41B4-8A1C-67D6A6AADE1D}.Debug|Any CPU.Build.0 = Debug|Any CPU + {C5C91635-69D6-41B4-8A1C-67D6A6AADE1D}.Release|Any CPU.ActiveCfg = Release|Any CPU + {C5C91635-69D6-41B4-8A1C-67D6A6AADE1D}.Release|Any CPU.Build.0 = Release|Any CPU + {C6E99932-52ED-4DC1-B308-991E13074111}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {C6E99932-52ED-4DC1-B308-991E13074111}.Debug|Any CPU.Build.0 = Debug|Any CPU + {C6E99932-52ED-4DC1-B308-991E13074111}.Release|Any CPU.ActiveCfg = Release|Any CPU + {C6E99932-52ED-4DC1-B308-991E13074111}.Release|Any CPU.Build.0 = Release|Any CPU + EndGlobalSection + GlobalSection(SolutionProperties) = preSolution + HideSolutionNode = FALSE + EndGlobalSection + GlobalSection(ExtensibilityGlobals) = postSolution + SolutionGuid = {E4862602-E7E8-42DA-A694-8CBA919A75FA} + EndGlobalSection +EndGlobal diff --git a/README.md b/README.md index 39c9a8443..073213269 100644 --- a/README.md +++ b/README.md @@ -1,136 +1,80 @@ # Разработка корпоративных приложений -[Таблица с успеваемостью](https://docs.google.com/spreadsheets/d/1JD6aiOG6r7GrA79oJncjgUHWtfeW4g_YZ9ayNgxb_w0/edit?usp=sharing) -## Задание -### Цель +# Цель Реализация проекта сервисно-ориентированного приложения. -### Задачи -* Реализация объектно-ориентированной модели данных, -* Изучение реализации серверных приложений на базе WebAPI/OpenAPI, -* Изучение работы с брокерами сообщений, -* Изучение паттернов проектирования, -* Изучение работы со средствами оркестрации на примере .NET Aspire, -* Повторение основ работы с системами контроля версий, -* Unit-тестирование. - -### Лабораторные работы -
-1. «Классы» - Реализация объектной модели данных и unit-тестов -
-В рамках первой лабораторной работы необходимо подготовить структуру классов, описывающих предметную область, определяемую в задании. В каждом из заданий присутствует часть, связанная с обработкой данных, представленная в разделе «Unit-тесты». Данную часть необходимо реализовать в виде unit-тестов: подготовить тестовые данные, выполнить запрос с использованием LINQ, проверить результаты. - -Хранение данных на этом этапе допускается осуществлять в памяти в виде коллекций. -Необходимо включить **как минимум 10** экземпляров каждого класса в датасид. - -
-
-2. «Сервер» - Реализация серверного приложения с использованием REST API -
-Во второй лабораторной работе необходимо реализовать серверное приложение, которое должно: -- Осуществлять базовые CRUD-операции с реализованными в первой лабораторной сущностями -- Предоставлять результаты аналитических запросов (раздел «Unit-тесты» задания) - -Хранение данных на этом этапе допускается осуществлять в памяти в виде коллекций. -
-
-
-3. «ORM» - Реализация объектно-реляционной модели. Подключение к базе данных и настройка оркестрации -
-В третьей лабораторной работе хранение должно быть переделано c инмемори коллекций на базу данных. -Должны быть созданы миграции для создания таблиц в бд и их первоначального заполнения. -
-Также необходимо настроить оркестратор Aspire на запуск сервера и базы данных. -
-
-
-4. «Инфраструктура» - Реализация сервиса генерации данных и его интеграция с сервером -
-В четвертой лабораторной работе необходимо имплементировать сервис, который генерировал бы контракты. Контракты далее передаются в сервер и сохраняются в бд. -Сервис должен представлять из себя отдельное приложение без референсов к серверным проектам за исключением библиотеки с контрактами. -Отправка контрактов при помощи gRPC должна выполняться в потоковом виде. -При использовании брокеров сообщений, необходимо предусмотреть ретраи при подключении к брокеру. - -Также необходимо добавить в конфигурацию Aspire запуск генератора и (если того требует вариант) брокера сообщений. -
-
-
-5. «Клиент» - Интеграция клиентского приложения с оркестратором -
-В пятой лабораторной необходимо добавить в конфигурацию Aspire запуск клиентского приложения для написанного ранее сервера. Клиент создается в рамках курса "Веб разработка". -
-
- -## Задание. Общая часть -**Обязательно**: -* Реализация серверной части на [.NET 8](https://learn.microsoft.com/ru-ru/dotnet/core/whats-new/dotnet-8/overview). -* Реализация серверной части на [ASP.NET](https://dotnet.microsoft.com/ru-ru/apps/aspnet). -* Реализация unit-тестов с использованием [xUnit](https://xunit.net/?tabs=cs). -* Использование хранения данных в базе данных согласно варианту задания. -* Оркестрация проектов при помощи [.NET Aspire](https://learn.microsoft.com/ru-ru/dotnet/aspire/get-started/aspire-overview) -* Реализация сервиса генерации данных при помощи [Bogus](https://github.com/bchavez/Bogus) и его взаимодейсвие с сервером согласно варианту задания. -* Автоматизация тестирования на уровне репозитория через [GitHub Actions](https://docs.github.com/en/actions/learn-github-actions/understanding-github-actions). -* Создание минимальной документации к проекту: страница на GitHub с информацией о задании, скриншоты приложения и прочая информация. - -**Факультативно**: -* Реализация авторизации/аутентификации. -* Реализация atomic batch publishing/atomic batch consumption для брокеров, поддерживающих такой функционал. -* Реализация интеграционных тестов при помощи .NET Aspire. -* Реализация клиента на Blazor WASM. - -Внимательно прочитайте [дискуссии](https://github.com/itsecd/enterprise-development/discussions/1) о том, как работает автоматическое распределение на ревью. -Сразу корректно называйте свои pr, чтобы они попали на ревью нужному преподавателю. - -По итогу работы в семестре должна получиться следующая информационная система: -
-C4 диаграмма - -image1 - -
- -## Варианты заданий -Номер варианта задания присваивается в начале семестра. Изменить его нельзя. Каждый вариант имеет уникальную комбинацию из предметной области, базы данных и технологии для общения сервиса генерации данных и сервера апи. - -[Список вариантов](https://docs.google.com/document/d/1Wc8AvsKS_1JptpsxHO-cwfAxz2ghxvQRQ0fy4el2ZOc/edit?usp=sharing) -[Список предметных областей](https://docs.google.com/document/d/15jWhXMwd2K8giFMKku_yrY_s2uQNEu4ugJXLYPvYJAE/edit?usp=sharing) - -## Схема сдачи - -На каждую из лабораторных работ необходимо сделать отдельный [Pull Request (PR)](https://docs.github.com/en/pull-requests). - -Общая схема: -1. Сделать форк данного репозитория -2. Выполнить задание -3. Сделать PR в данный репозиторий -4. Исправить замечания после code review -5. Получить approve -6. Прийти на занятие и защитить работу - -## Критерии оценивания - -Конкурентный принцип. -Так как задания в первой лабораторной будут повторяться между студентами, то выделяются следующие показатели для оценки: -1. Скорость разработки -2. Качество разработки -3. Полнота выполнения задания - -Быстрее делаете PR - у вас преимущество. -Быстрее получаете Approve - у вас преимущество. -Выполните нечто немного выходящее за рамки проекта - у вас преимущество. - -### Шкала оценивания - -- **3 балла** за качество кода, из них: - - 2 балла - базовая оценка - - 1 балл (но не более) можно получить за выполнение любого из следующих пунктов: - - Реализация факультативного функционала - - Выполнение работы раньше других: первые 5 человек из каждой группы, которые сделали PR и получили approve, получают дополнительный балл -- **3 балла** за защиту: при сдаче лабораторной работы вам задается 3 вопроса, за каждый правильный ответ - 1 балл - -У вас 2 попытки пройти ревью (первичное ревью, ревью по результатам исправления). Если замечания по итогу не исправлены, то снимается один балл за код лабораторной работы. - -## Вопросы и обратная связь по курсу - -Чтобы задать вопрос по лабораторной, воспользуйтесь [соотвествующим разделом дискуссий](https://github.com/itsecd/enterprise-development/discussions/categories/questions) или заведите [ишью](https://github.com/itsecd/enterprise-development/issues/new). -Если у вас появились идеи/пожелания/прочие полезные мысли по преподаваемой дисциплине, их можно оставить [здесь](https://github.com/itsecd/enterprise-development/discussions/categories/ideas). +# Задание "Пункт велопроката" + +## Лабораторная работа 1 - "Классы" +В рамках первой лабораторной работы была добавлена доменная модель с основными сущностями пункта велопроката и реализованы юнит-тесты. + +В базе данных пункта проката хранятся сведения о велосипедах, их арендаторах и выданных в аренду транспортных средствах. + +Каждый велосипед характеризуется серийным номером, моделью, цветом. +Модель велосипеда является справочником, содержащим сведения о типе велосипеда, размере колес, предельно допустимом весе пассажира, весе велосипеда, типе тормозов, модельном годе. Для каждой модели велосипеда указывается цена часа аренды. +Тип велосипеда является перечислением. + +Арендатор характеризуется ФИО, телефоном. + +При выдаче велосипеда арендатору фиксируется время начала аренды и отмечается ее продолжительность в часах. + +### Классы +* [Bike](https://github.com/comandir26/enterprise-development/blob/main/Bikes/Bikes.Domain/Models/Bike.cs) - характеризует велосипед +* [BikeModel](https://github.com/comandir26/enterprise-development/blob/main/Bikes/Bikes.Domain/Models/BikeModel.cs) - информация о модели велосипеда +* [Renter](https://github.com/comandir26/enterprise-development/blob/main/Bikes/Bikes.Domain/Models/Renter.cs) - информация об арендаторе +* [Rent](https://github.com/comandir26/enterprise-development/blob/main/Bikes/Bikes.Domain/Models/Rent.cs) - информация об аренде велосипеда, класс содержит как велосипед, так и арендатора + +### Тесты +[BikeTests](https://github.com/comandir26/enterprise-development/blob/main/Bikes/Bikes.Tests/BikesTests.cs) - юнит-тесты +1. InformationAboutSportBikes - Вывести информацию обо всех спортивных велосипедах. +2. TopFiveModelsProfit, TopFiveModelsRentDurationIds - Вывести топ 5 моделей велосипедов (по прибыли от аренды и по длительности аренды отдельно). +3. MinMaxAvgRentalDuration - Вывести информацию о минимальном, максимальном и среднем времени аренды велосипедов. +4. TotalRentalTimeByType - Вывести суммарное время аренды велосипедов каждого типа. +5. TopThreeRenters - Вывести информацию о клиентах, бравших велосипеды на прокат больше всего раз. + +## Лабораторная работа 2 - "Сервер" + +В рамках второй лабораторной работы было разработано серверное приложение с REST API для управления пунктом велопроката. Приложение предоставляет полный набор операций для работы с сущностями системы и аналитическими запросами. + +### Bikes.Domain - доменный слой, содержащий бизнес-сущности и интерфейсы репозиториев + +### Bikes.Application - слой приложения, содержащий DTO, сервисы и бизнес-логику + +### Bikes.Infrastructure.InMemory - инфраструктурный слой с реализацией in-memory репозиториев + +### Bikes.Api.Host - веб-слой с REST API контроллерами + +### Bikes.Tests - модульные тесты для проверки функциональности + +### Bikes.Contracts - слой DTO + +## Лабораторная работа 3 - "ORM" + +В рамках третьей лабораторной работы хранение данных было переделано с InMemory коллекий на базу данных. +Также был настроен оркестратор Aspire на запуск сервера и базы данных + +Были добавлены слои: + +### Bikes.Infrastructure.MongoDb - инфраструктурный слой с реализацией хранения данных в БД MongoDb + +### Bikes.ServiceDefaults - слой инфраструктурных стандартов, cодержащий конфигурации и расширения по умолчанию для всех сервисов приложения + +### Bikes.AppHost - слой оркестрации приложений + +## Лабораторная работа 4 - "Инфраструктура" + +В четвертой лабораторной работе был имплементирован сервис, который генерирует контракты. +Контракты далее передаются в сервер и сохраняются в бд + +### Bikes.Generator - сервис генерации данных + +### Интеграция Kafka в существующие проекты: + +#### Bikes.Api.Host: + +##### KafkaConsumer - фоновая служба для потребления сообщений + +#### Bikes.AppHost: + +##### Добавлен Kafka-контейнер для брокера сообщений +