Skip to content

Commit

Permalink
WTH #5 Cities and measurements.
Browse files Browse the repository at this point in the history
Created City and Measurement entities, registered them in DbContext and UnitOfWork.
Defined the Admin and User policies.

Wrote the city endpoints.
Wrote integration tests for the city endpoints happy paths.
Wrote unit tests for all other city cases.

Updated Readme.md
  • Loading branch information
Radu Terec committed Jan 8, 2024
1 parent 114d8dd commit 9272f60
Show file tree
Hide file tree
Showing 26 changed files with 858 additions and 9 deletions.
27 changes: 27 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -272,6 +272,33 @@ public async Task LoginAsync_ReturnsValidResponse()
}
```

### Authentication and authorization

The API defines [2 policies](./Weather.Api/Extensions/HostExtensions.cs) for user roles, one for admins and one for users. The _city_ endpoints for insert/update/delete can only be accessed by admins. See the `ToCityEndpoints` method in [`CityEndpoints`](./Weather.Api/Endpoints/CityEndpoints.cs).

By having default values for the [JWTSettings](./Weather.Api/Core/Options/JWTSettings.cs) we can ensure that a valid authentication schema, _with roles_, is created for the integration tests.
So, testing that an admin can update a city, becomes something as trivial as:

```C#
[Fact]
public async Task UpdateAsync_ReturnsOk_WhenAdminUpdatesData()
{
var client = _factory.CreateClient();
var scope = _factory.Services.CreateAsyncScope();
var dbContext = scope.ServiceProvider.GetRequiredService<WeatherDbContext>();
int cityId = dbContext.Cities.First(cty => cty.Name == "Zürich").Id;
var updatedCity = new CityDTO { Id = cityId, Name = "Züri" };

// Act
string token = await client.GetAuthenticationToken(FakeUserData.Admin);
var cityResponse = await client.AuthenticatedJsonPutAsync($"{CityPath}{cityId}", updatedCity, token);
var updatedCityDto = await cityResponse.Content.ReadFromJsonAsync<CityDTO>();

Assert.Equal(updatedCity.Id, updatedCityDto!.Id);
Assert.Equal(updatedCity.Name, updatedCityDto.Name);
}
```

## Getting started

1. If you don't have a Maria DB Server installed, head over to [MariaDB](https://mariadb.org/download/) and install the latest version.
Expand Down
2 changes: 2 additions & 0 deletions Weather.Api/Core/Constants.cs
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,9 @@ namespace Weather.Api.Core;
internal static class Constants
{
public const string User = nameof(User);
public const string UserAuthPolicy = nameof(UserAuthPolicy);
public const string Admin = nameof(Admin);
public const string AdminAuthPolicy = nameof(AdminAuthPolicy);
public const string LoginPolicy = nameof(LoginPolicy);
public const string RegisterPolicy = nameof(RegisterPolicy);
}
16 changes: 16 additions & 0 deletions Weather.Api/Core/DataTransferObjects/CityDTO.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
using System.ComponentModel.DataAnnotations;

using Microsoft.AspNetCore.Mvc.ModelBinding;

namespace Weather.Api.Core.DataTransferObjects;

public sealed class CityDTO
{
public int Id { get; init; }

[BindRequired]
[MinLength(1)]
[MaxLength(127)]
[RegularExpression(@"^[^`~!@#$%^&*_|+=?;:'\""<>./{}\[\]]+$")]
public string Name { get; init; } = string.Empty;
}
1 change: 1 addition & 0 deletions Weather.Api/Core/IUnitOfWork.cs
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ namespace Weather.Api.Core;

public interface IUnitOfWork
{
ICityRepository Cities { get; }
IRoleRepository Roles { get; }
IUserRepository Users { get; }
Task CommitAsync(CancellationToken cancellationToken = default);
Expand Down
16 changes: 16 additions & 0 deletions Weather.Api/Core/Models/City.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
using System.Collections.ObjectModel;
using System.ComponentModel.DataAnnotations;

namespace Weather.Api.Core.Models;

public sealed class City : IEntity
{

public int Id { get; init; }

[Required]
[StringLength(255)]
public string Name { get; set; } = string.Empty;

public ICollection<Measurement> Measurements { get; set; } = new Collection<Measurement>();
}
25 changes: 25 additions & 0 deletions Weather.Api/Core/Models/Measurement.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
using System.ComponentModel.DataAnnotations;

namespace Weather.Api.Core.Models;

public sealed class Measurement : IEntity
{

public int Id { get; init; }

[Required]
[Range(-273.15, 999999)]
public float Temperature { get; set; }

[Required]
[ConcurrencyCheck]
public DateTime Timestamp { get; set; }

[Required]
public User User { get; set; } = null!;
public int UserId { get; set; }

[Required]
public City City { get; set; } = null!;
public int CityId { get; set; }
}
2 changes: 2 additions & 0 deletions Weather.Api/Core/Models/User.cs
Original file line number Diff line number Diff line change
Expand Up @@ -22,4 +22,6 @@ public sealed class User : IEntity

[Required]
public ICollection<Role> Roles { get; set; } = new Collection<Role>();

public ICollection<Measurement> Measurements { get; set; } = new Collection<Measurement>();
}
9 changes: 9 additions & 0 deletions Weather.Api/Core/Repositories/ICityRepository.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
using Weather.Api.Core.Models;

namespace Weather.Api.Core.Repositories;

public interface ICityRepository : IRepository<City>
{
Task<bool> ExistsAsync(string name);
Task<City?> GetWithMeasurements(int id);
}
92 changes: 92 additions & 0 deletions Weather.Api/Endpoints/CityEndpoints.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
using Microsoft.AspNetCore.Mvc;

using Weather.Api.Core;
using Weather.Api.Core.DataTransferObjects;
using Weather.Api.Core.Models;

namespace Weather.Api.Endpoints;

public static class CityEndpoints
{
public static RouteGroupBuilder ToCityEndpoints(this RouteGroupBuilder cityItems)
{
cityItems.MapGet("/{id:int}", GetAsync).WithTags("Public");
cityItems.MapPost("/", InsertAsync).RequireAuthorization(Constants.AdminAuthPolicy).WithTags("Private");
cityItems.MapPut("/{id:int}", UpdateAsync).RequireAuthorization(Constants.AdminAuthPolicy).WithTags("Private");
cityItems.MapDelete("/{id:int}", DeleteAsync).RequireAuthorization(Constants.AdminAuthPolicy).WithTags("Private");

return cityItems;
}

public static async Task<IResult> GetAsync([FromRoute] int id, IUnitOfWork unitOfWork)
{
var city = await unitOfWork.Cities.GetAsync(id);
if (city == default)
{
return TypedResults.Problem(detail: "City not found", statusCode: StatusCodes.Status404NotFound);
}

var cityDto = new CityDTO { Id = city.Id, Name = city.Name };
return TypedResults.Ok(cityDto);
}

public static async Task<IResult> InsertAsync([FromBody] CityDTO cityDto, IUnitOfWork unitOfWork)
{
if (cityDto.Id != default)
{
return TypedResults.Problem(detail: "Id cannot be set for a new city", statusCode: StatusCodes.Status400BadRequest);
}

bool alreadyExists = await unitOfWork.Cities.ExistsAsync(cityDto.Name);
if (alreadyExists)
{
return TypedResults.Problem(detail: "This city already exists", statusCode: StatusCodes.Status400BadRequest);
}

var city = new City { Name = cityDto.Name };
await unitOfWork.Cities.AddAsync(city);
await unitOfWork.CommitAsync();

var insertedCityDto = new CityDTO { Id = city.Id, Name = city.Name };
return TypedResults.Ok(insertedCityDto);
}

public static async Task<IResult> UpdateAsync([FromRoute] int id, [FromBody] CityDTO cityDto, IUnitOfWork unitOfWork)
{
var city = await unitOfWork.Cities.GetAsync(id);
if (city == default)
{
return TypedResults.Problem(detail: "City not found", statusCode: StatusCodes.Status404NotFound);
}

bool alreadyExists = await unitOfWork.Cities.ExistsAsync(cityDto.Name);
if (alreadyExists)
{
return TypedResults.Problem(detail: "This city already exists", statusCode: StatusCodes.Status400BadRequest);
}

city.Name = cityDto.Name;
await unitOfWork.CommitAsync();

var updatedCityDto = new CityDTO { Id = city.Id, Name = city.Name };
return TypedResults.Ok(updatedCityDto);
}

public static async Task<IResult> DeleteAsync([FromRoute] int id, IUnitOfWork unitOfWork)
{
var city = await unitOfWork.Cities.GetWithMeasurements(id);
if (city == default)
{
return TypedResults.Problem(detail: "City not found", statusCode: StatusCodes.Status404NotFound);
}
if (city.Measurements != default && city.Measurements.Count != 0)
{
return TypedResults.Problem(detail: "Cannot delete a city with measurements", statusCode: StatusCodes.Status400BadRequest);
}

unitOfWork.Cities.Remove(city);
await unitOfWork.CommitAsync();

return TypedResults.Ok(id);
}
}
2 changes: 0 additions & 2 deletions Weather.Api/Endpoints/UserEndpoints.cs
Original file line number Diff line number Diff line change
@@ -1,5 +1,3 @@
using System.Collections.ObjectModel;

using Microsoft.AspNetCore.Identity;

using Weather.Api.Core;
Expand Down
8 changes: 8 additions & 0 deletions Weather.Api/Extensions/HostExtensions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
using Microsoft.AspNetCore.Authentication.JwtBearer;
using Microsoft.IdentityModel.Tokens;

using Weather.Api.Core;
using Weather.Api.Core.Options;

namespace Weather.Api.Extensions;
Expand Down Expand Up @@ -38,4 +39,11 @@ public static void AddJwtAuthentication(this WebApplicationBuilder builder)
};
});
}

public static void AddAuthorizationPolicies(this IServiceCollection services)
{
services.AddAuthorizationBuilder()
.AddPolicy(Constants.AdminAuthPolicy, policy => policy.RequireRole(Constants.Admin))
.AddPolicy(Constants.UserAuthPolicy, policy => policy.RequireRole(Constants.User));
}
}
Loading

0 comments on commit 9272f60

Please sign in to comment.