Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 28 additions & 0 deletions .github/workflows/test.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
name: .NET Tests

on:
push:
branches: [ "*" ]
pull_request:
branches: [ main ]

jobs:
test:
runs-on: ubuntu-latest

steps:
- uses: actions/checkout@v4

- name: Setup .NET
uses: actions/setup-dotnet@v4
with:
dotnet-version: 8.0.x

- name: Restore dependencies
run: dotnet restore ./CarRental/CarRental.sln

- name: Build
run: dotnet build ./CarRental/CarRental.sln --no-restore

- name: Test
run: dotnet test ./CarRental/CarRental.Tests/CarRental.Tests.csproj --no-build --verbosity normal
26 changes: 26 additions & 0 deletions CarRental/CarRental.Api/CarRental.Api.csproj
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
<Project Sdk="Microsoft.NET.Sdk.Web">

<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<GenerateDocumentationFile>True</GenerateDocumentationFile>
<NoWarn>$(NoWarn);1591</NoWarn>
<NoWarn>$(NoWarn);9107</NoWarn>
<Nullable>enable</Nullable>
</PropertyGroup>

<ItemGroup>
<PackageReference Include="Microsoft.AspNetCore.Mvc" Version="2.3.0" />
<PackageReference Include="Swashbuckle.AspNetCore" Version="9.0.6" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="9.0.10">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
<PackageReference Include="Aspire.Microsoft.EntityFrameworkCore.SqlServer" Version="9.1.0" />
</ItemGroup>

<ItemGroup>
<ProjectReference Include="..\CarRental.Application\CarRental.Application.csproj" />
<ProjectReference Include="..\CarRental.ServiceDefaults\CarRental.ServiceDefaults.csproj" />
</ItemGroup>
</Project>
136 changes: 136 additions & 0 deletions CarRental/CarRental.Api/Controllers/AnalyticController.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,136 @@
using CarRental.Application.Dtos.Cars;
using CarRental.Application.Dtos.Customers;
using CarRental.Application.Services;
using Microsoft.AspNetCore.Mvc;

namespace CarRental.Api.Controllers;

/// <summary>
/// Controller for analytic queries and reports in the car rental system.
/// Provides endpoints for generating various business intelligence reports and data analytics.
/// </summary>
[ApiController]
[Produces("application/json")]
[Route("api/[controller]")]
public class AnalyticController(IAnalyticQueryService analyticQueryService) : ControllerBase
{
/// <summary>
/// Retrieves the top 5 most rented cars.
/// </summary>
/// <returns>Collection of top 5 cars by rental count</returns>
/// <response code="200">Returns list of cars</response>
/// <response code="500">If there was an internal server error</response>
[HttpGet("cars/top5-most-rented")]
[ProducesResponseType(typeof(IEnumerable<CarResponseDto>), 200)]
[ProducesResponseType(500)]
public async Task<ActionResult<IEnumerable<CarResponseDto>>> GetTop5MostRentedCars()
{
try
{
var result = await analyticQueryService.GetTop5MostRentedCarsAsync();
return Ok(result);
}
catch (InvalidOperationException)
{
return StatusCode(500);
}
}

/// <summary>
/// Retrieves the number of rentals for each car.
/// </summary>
/// <returns>Dictionary with license plate as key and rental count as value</returns>
/// <response code="200">Returns rental counts per car</response>
/// <response code="500">If there was an internal server error</response>
[HttpGet("cars/rental-counts")]
[ProducesResponseType(typeof(Dictionary<string, int>), 200)]
[ProducesResponseType(500)]
public async Task<ActionResult<Dictionary<string, int>>> GetRentalCountForEachCar()
{
try
{
var result = await analyticQueryService.GetRentalCountForEachCarAsync();
return Ok(result);
}
catch (InvalidOperationException)
{
return StatusCode(500);
}
}

/// <summary>
/// Retrieves the top 5 customers by total rental cost.
/// </summary>
/// <returns>Collection of top 5 customers by total spent</returns>
/// <response code="200">Returns list of customers</response>
/// <response code="500">If there was an internal server error</response>
[HttpGet("customers/top5-by-total-cost")]
[ProducesResponseType(typeof(IEnumerable<CustomerResponseDto>), 200)]
[ProducesResponseType(500)]
public async Task<ActionResult<IEnumerable<CustomerResponseDto>>> GetTop5CustomersByTotalCost()
{
try
{
var result = await analyticQueryService.GetTop5CustomersByTotalCostAsync();
return Ok(result);
}
catch (InvalidOperationException)
{
return StatusCode(500);
}
}

/// <summary>
/// Retrieves all customers who rented cars of a specific model.
/// </summary>
/// <param name="modelId">ID of the car model</param>
/// <returns>Collection of customers who rented the specified model</returns>
/// <response code="200">Returns list of customers</response>
/// <response code="400">If model ID is invalid</response>
/// <response code="500">If there was an internal server error</response>
[HttpGet("models/{modelId}/customers")]
[ProducesResponseType(typeof(IEnumerable<CustomerResponseDto>), 200)]
[ProducesResponseType(400)]
[ProducesResponseType(500)]
public async Task<ActionResult<IEnumerable<CustomerResponseDto>>> GetCustomersByModel([FromRoute] int modelId)
{
try
{
if (modelId <= 0)
return BadRequest(new { error = "Model ID must be positive" });

var result = await analyticQueryService.GetCustomersByModelAsync(modelId);
return Ok(result);
}
catch (InvalidOperationException)
{
return StatusCode(500);
}
}

/// <summary>
/// Retrieves cars that are currently rented at the specified moment.
/// </summary>
/// <param name="now">Current date and time (optional, defaults to server time)</param>
/// <returns>Collection of currently rented cars</returns>
/// <response code="200">Returns list of cars</response>
/// <response code="400">If the provided time is invalid</response>
/// <response code="500">If there was an internal server error</response>
[HttpGet("cars/currently-rented")]
[ProducesResponseType(typeof(IEnumerable<CarResponseDto>), 200)]
[ProducesResponseType(400)]
[ProducesResponseType(500)]
public async Task<ActionResult<IEnumerable<CarResponseDto>>> GetCurrentlyRentedCars([FromQuery] DateTime? now = null)
{
try
{
var currentTime = now ?? DateTime.UtcNow;
var result = await analyticQueryService.GetCurrentlyRentedCarsAsync(currentTime);
return Ok(result);
}
catch (InvalidOperationException)
{
return StatusCode(500);
}
}
}
41 changes: 41 additions & 0 deletions CarRental/CarRental.Api/Controllers/CarController.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
using CarRental.Application.Dtos.Cars;
using CarRental.Application.Dtos.ModelGenerations;
using CarRental.Application.Services;
using Microsoft.AspNetCore.Mvc;

namespace CarRental.Api.Controllers;

/// <summary>
/// Controller for managing cars in the car rental system.
/// </summary>
public class CarsController(
ICrudService<CarResponseDto, CarCreateDto, CarUpdateDto> service,
ICrudService<ModelGenerationResponseDto, ModelGenerationCreateDto, ModelGenerationUpdateDto> modelGenerationService
) : CrudControllerBase<CarResponseDto, CarCreateDto, CarUpdateDto>(service)
{
public override async Task<ActionResult<CarResponseDto>> Create([FromBody] CarCreateDto createDto)
{
if (!ModelState.IsValid) return BadRequest(ModelState);

var generation = await modelGenerationService.GetAsync(createDto.ModelGenerationId);
if (generation == null)
return BadRequest(new { error = $"Model generation with ID {createDto.ModelGenerationId} does not exist" });

var entity = await service.CreateAsync(createDto);
return CreatedAtAction(nameof(GetById), new { id = entity.Id }, entity);
}

public override async Task<IActionResult> Update(int id, [FromBody] CarUpdateDto updateDto)
{
if (!ModelState.IsValid) return BadRequest(ModelState);

var generation = await modelGenerationService.GetAsync(updateDto.ModelGenerationId);
if (generation == null)
return BadRequest(new { error = $"Model generation with ID {updateDto.ModelGenerationId} does not exist" });

var updated = await service.UpdateAsync(id, updateDto);
if (updated == null) return NotFound();

return NoContent();
}
}
11 changes: 11 additions & 0 deletions CarRental/CarRental.Api/Controllers/CarModelController.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
using CarRental.Application.Dtos.CarModels;
using CarRental.Application.Services;

namespace CarRental.Api.Controllers;

/// <summary>
/// Controller for managing car models in the car rental system.
/// </summary>
public class CarModelController(
ICrudService<CarModelResponseDto, CarModelCreateDto, CarModelUpdateDto> service
) : CrudControllerBase<CarModelResponseDto, CarModelCreateDto, CarModelUpdateDto>(service);
123 changes: 123 additions & 0 deletions CarRental/CarRental.Api/Controllers/CrudControllerBase.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,123 @@
using CarRental.Application.Services;
using Microsoft.AspNetCore.Mvc;
namespace CarRental.Api.Controllers;

/// <summary>
/// Base controller providing CRUD operations for entities.
/// </summary>
/// <typeparam name="TDto">The response DTO type</typeparam>
/// <typeparam name="TCreateDto">The create DTO type</typeparam>
/// <typeparam name="TUpdateDto">The update DTO type</typeparam>
[ApiController]
[Produces("application/json")]
[Route("api/[controller]")]
public abstract class CrudControllerBase<TDto, TCreateDto, TUpdateDto>
(ICrudService<TDto, TCreateDto, TUpdateDto> service) : ControllerBase
{

/// <summary>
/// Retrieves all entities.
/// </summary>
/// <returns>A list of all entities</returns>
[HttpGet]
[ProducesResponseType(StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status500InternalServerError)]
public virtual async Task<ActionResult<IEnumerable<TDto>>> GetAll()
{
var entities = await service.GetAsync();
return Ok(entities);
}

/// <summary>
/// Retrieves a specific entity by its unique identifier.
/// </summary>
/// <param name="id">The ID of the entity to retrieve</param>
/// <returns>The entity record if found</returns>
[HttpGet("{id:int}")]
[ProducesResponseType(StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
[ProducesResponseType(StatusCodes.Status500InternalServerError)]
public virtual async Task<ActionResult<TDto>> GetById(int id)
{
var entity = await service.GetAsync(id);
if (entity == null)
return NotFound();

return Ok(entity);
}

/// <summary>
/// Creates a new entity record.
/// </summary>
/// <param name="createDto">The entity data to create</param>
/// <returns>The newly created entity record</returns>
[HttpPost]
[ProducesResponseType(StatusCodes.Status201Created)]
[ProducesResponseType(StatusCodes.Status400BadRequest)]
[ProducesResponseType(StatusCodes.Status500InternalServerError)]
public virtual async Task<ActionResult<TDto>> Create([FromBody] TCreateDto createDto)
{
if (!ModelState.IsValid)
return BadRequest(ModelState);

var entity = await service.CreateAsync(createDto);
var id = GetIdFromDto(entity);

return CreatedAtAction(nameof(GetById), new { id }, entity);
}

/// <summary>
/// Updates an existing entity record.
/// </summary>
/// <param name="id">The ID of the entity to update</param>
/// <param name="updateDto">The updated entity data</param>
/// <returns>No content if successful</returns>
[HttpPut("{id:int}")]
[ProducesResponseType(StatusCodes.Status204NoContent)]
[ProducesResponseType(StatusCodes.Status400BadRequest)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
[ProducesResponseType(StatusCodes.Status500InternalServerError)]
public virtual async Task<IActionResult> Update(int id, [FromBody] TUpdateDto updateDto)
{
if (!ModelState.IsValid)
return BadRequest(ModelState);

var updatedEntity = await service.UpdateAsync(id, updateDto);
if (updatedEntity == null)
return NotFound();

return NoContent();
}

/// <summary>
/// Deletes an entity record.
/// </summary>
/// <param name="id">The ID of the entity to delete</param>
/// <returns>No content if successful</returns>
[HttpDelete("{id:int}")]
[ProducesResponseType(StatusCodes.Status204NoContent)]
[ProducesResponseType(StatusCodes.Status500InternalServerError)]
public virtual async Task<IActionResult> Delete(int id)
{
var deleted = await service.DeleteAsync(id);
return NoContent();
}

/// <summary>
/// Gets the ID from the DTO using reflection.
/// </summary>
private static int GetIdFromDto(TDto dto)
{
var property = dto?.GetType().GetProperty("Id")
?? throw new InvalidOperationException($"DTO type {typeof(TDto).Name} does not have an 'Id' property.");

if (property.PropertyType != typeof(int))
throw new InvalidOperationException($"Id property in {typeof(TDto).Name} must be of type int.");

var value = property.GetValue(dto);
if (value is not int id)
throw new InvalidOperationException($"Failed to retrieve Id from {typeof(TDto).Name}.");

return id;
}
}
11 changes: 11 additions & 0 deletions CarRental/CarRental.Api/Controllers/CustomerController.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
using CarRental.Application.Dtos.Customers;
using CarRental.Application.Services;

namespace CarRental.Api.Controllers;

/// <summary>
/// Controller for managing customers in the car rental system.
/// </summary>
public class CustomerController(
ICrudService<CustomerResponseDto, CustomerCreateDto, CustomerUpdateDto> service
) : CrudControllerBase<CustomerResponseDto, CustomerCreateDto, CustomerUpdateDto>(service);
Loading