Implement all car endpoints

This commit is contained in:
ThompsonNye
2024-08-03 20:16:30 +02:00
parent a1014bd009
commit b75b7512c2
22 changed files with 688 additions and 46 deletions

View File

@@ -0,0 +1,78 @@
using Microsoft.Extensions.Options;
using System.Diagnostics.CodeAnalysis;
using System.Security.Claims;
namespace Vegasco.WebApi.Authentication;
public sealed class UserAccessor
{
private readonly IHttpContextAccessor _httpContextAccessor;
private readonly IOptions<JwtOptions> _jwtOptions;
/// <summary>
/// Stores the username upon first retrieval
/// </summary>
private string? _cachedUsername;
/// <summary>
/// Stores the id upon first retrieval
/// </summary>
private string? _cachedId;
public UserAccessor(IHttpContextAccessor httpContextAccessor, IOptions<JwtOptions> jwtOptions)
{
_httpContextAccessor = httpContextAccessor;
_jwtOptions = jwtOptions;
}
public string GetUsername()
{
if (string.IsNullOrEmpty(_cachedUsername))
{
_cachedUsername = GetClaimValue(_jwtOptions.Value.NameClaimType ?? ClaimTypes.Name);
}
return _cachedUsername;
}
public string GetUserId()
{
if (string.IsNullOrEmpty(_cachedId))
{
_cachedId = GetClaimValue(ClaimTypes.NameIdentifier);
}
return _cachedId;
}
private string GetClaimValue(string claimType)
{
var httpContext = _httpContextAccessor.HttpContext;
if (httpContext is null)
{
ThrowForMissingHttpContext();
}
var claimValue = httpContext.User.FindFirstValue(claimType);
if (string.IsNullOrWhiteSpace(claimValue))
{
ThrowForMissingClaim(claimType);
}
return claimValue;
}
[DoesNotReturn]
private static void ThrowForMissingHttpContext()
{
throw new InvalidOperationException("No HttpContext available.");
}
[DoesNotReturn]
private static void ThrowForMissingClaim(string claimType)
{
throw new InvalidOperationException($"No claim of type '{claimType}' found on the current user.");
}
}

View File

@@ -1,4 +1,8 @@
namespace Vegasco.WebApi.Cars;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
using Vegasco.WebApi.Users;
namespace Vegasco.WebApi.Cars;
public class Car
{
@@ -6,5 +10,27 @@ public class Car
public string Name { get; set; } = "";
public Guid UserId { get; set; }
public string UserId { get; set; } = "";
public virtual User User { get; set; } = null!;
}
public class CarTableConfiguration : IEntityTypeConfiguration<Car>
{
public const int NameMaxLength = 50;
public void Configure(EntityTypeBuilder<Car> builder)
{
builder.HasKey(x => x.Id);
builder.Property(x => x.Name)
.IsRequired()
.HasMaxLength(NameMaxLength);
builder.Property(x => x.UserId)
.IsRequired();
builder.HasOne(x => x.User)
.WithMany(x => x.Cars);
}
}

View File

@@ -1,6 +1,9 @@
using FluentValidation;
using FluentValidation.Results;
using Vegasco.WebApi.Authentication;
using Vegasco.WebApi.Common;
using Vegasco.WebApi.Persistence;
using Vegasco.WebApi.Users;
namespace Vegasco.WebApi.Cars;
@@ -12,7 +15,7 @@ public static class CreateCar
public static RouteHandlerBuilder MapEndpoint(IEndpointRouteBuilder builder)
{
return builder
.MapPost("cars", Handler)
.MapPost("cars", Endpoint)
.WithTags("Cars");
}
@@ -21,18 +24,46 @@ public static class CreateCar
public Validator()
{
RuleFor(x => x.Name)
.NotEmpty();
.NotEmpty()
.MaximumLength(CarTableConfiguration.NameMaxLength);
}
}
public static async Task<IResult> Handler(Request request, IEnumerable<IValidator<Request>> validators)
public static async Task<IResult> Endpoint(
Request request,
IEnumerable<IValidator<Request>> validators,
ApplicationDbContext dbContext,
UserAccessor userAccessor,
CancellationToken cancellationToken)
{
List<ValidationResult> failedValidations = await validators.ValidateAllAsync(request);
List<ValidationResult> failedValidations = await validators.ValidateAllAsync(request, cancellationToken: cancellationToken);
if (failedValidations.Count > 0)
{
return Results.BadRequest(new HttpValidationProblemDetails(failedValidations.ToCombinedDictionary()));
return TypedResults.BadRequest(new HttpValidationProblemDetails(failedValidations.ToCombinedDictionary()));
}
return Results.Ok();
var userId = userAccessor.GetUserId();
var user = await dbContext.Users.FindAsync([userId], cancellationToken: cancellationToken);
if (user is null)
{
user = new User
{
Id = userId
};
await dbContext.Users.AddAsync(user, cancellationToken);
}
Car car = new()
{
Name = request.Name,
UserId = userId
};
await dbContext.Cars.AddAsync(car, cancellationToken);
await dbContext.SaveChangesAsync(cancellationToken);
Response response = new(car.Id, car.Name);
return TypedResults.Created($"/v1/cars/{car.Id}", response);
}
}

View File

@@ -0,0 +1,31 @@
using Vegasco.WebApi.Persistence;
namespace Vegasco.WebApi.Cars;
public static class DeleteCar
{
public static RouteHandlerBuilder MapEndpoint(IEndpointRouteBuilder builder)
{
return builder
.MapDelete("cars/{id:guid}", Endpoint)
.WithTags("Cars");
}
public static async Task<IResult> Endpoint(
Guid id,
ApplicationDbContext dbContext,
CancellationToken cancellationToken)
{
var car = await dbContext.Cars.FindAsync([id], cancellationToken: cancellationToken);
if (car is null)
{
return TypedResults.NotFound();
}
dbContext.Cars.Remove(car);
await dbContext.SaveChangesAsync(cancellationToken);
return TypedResults.NoContent();
}
}

31
src/WebApi/Cars/GetCar.cs Normal file
View File

@@ -0,0 +1,31 @@
using Vegasco.WebApi.Persistence;
namespace Vegasco.WebApi.Cars;
public static class GetCar
{
public record Response(Guid Id, string Name);
public static RouteHandlerBuilder MapEndpoint(IEndpointRouteBuilder builder)
{
return builder
.MapGet("cars/{id:guid}", Endpoint)
.WithTags("Cars");
}
public static async Task<IResult> Endpoint(
Guid id,
ApplicationDbContext dbContext,
CancellationToken cancellationToken)
{
var car = await dbContext.Cars.FindAsync([id], cancellationToken: cancellationToken);
if (car is null)
{
return TypedResults.NotFound();
}
var response = new Response(car.Id, car.Name);
return TypedResults.Ok(response);
}
}

View File

@@ -0,0 +1,27 @@
using Microsoft.EntityFrameworkCore;
using Vegasco.WebApi.Persistence;
namespace Vegasco.WebApi.Cars;
public static class GetCars
{
public record Response(Guid Id, string Name);
public static RouteHandlerBuilder MapEndpoint(IEndpointRouteBuilder builder)
{
return builder
.MapGet("cars", Endpoint)
.WithTags("Cars");
}
public static async Task<IResult> Endpoint(
ApplicationDbContext dbContext,
CancellationToken cancellationToken)
{
var cars = await dbContext.Cars
.Select(x => new Response(x.Id, x.Name))
.ToListAsync(cancellationToken);
return TypedResults.Ok(cars);
}
}

View File

@@ -0,0 +1,58 @@
using FluentValidation;
using FluentValidation.Results;
using Vegasco.WebApi.Authentication;
using Vegasco.WebApi.Common;
using Vegasco.WebApi.Persistence;
namespace Vegasco.WebApi.Cars;
public static class UpdateCar
{
public record Request(string Name);
public record Response(Guid Id, string Name);
public static RouteHandlerBuilder MapEndpoint(IEndpointRouteBuilder builder)
{
return builder
.MapPut("cars/{id:guid}", Endpoint)
.WithTags("Cars");
}
public class Validator : AbstractValidator<Request>
{
public Validator()
{
RuleFor(x => x.Name)
.NotEmpty()
.MaximumLength(CarTableConfiguration.NameMaxLength);
}
}
public static async Task<IResult> Endpoint(
Guid id,
Request request,
IEnumerable<IValidator<Request>> validators,
ApplicationDbContext dbContext,
UserAccessor userAccessor,
CancellationToken cancellationToken)
{
List<ValidationResult> failedValidations = await validators.ValidateAllAsync(request, cancellationToken);
if (failedValidations.Count > 0)
{
return TypedResults.BadRequest(new HttpValidationProblemDetails(failedValidations.ToCombinedDictionary()));
}
var car = await dbContext.Cars.FindAsync([id], cancellationToken: cancellationToken);
if (car is null)
{
return TypedResults.NotFound();
}
car.Name = request.Name;
await dbContext.SaveChangesAsync(cancellationToken);
Response response = new(car.Id, car.Name);
return TypedResults.Ok(response);
}
}

View File

@@ -1,12 +1,14 @@
using Asp.Versioning;
using FluentValidation;
using Microsoft.AspNetCore.Authentication.JwtBearer;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Options;
using OpenTelemetry.Trace;
using System.Diagnostics;
using Vegasco.WebApi.Authentication;
using Vegasco.WebApi.Endpoints;
using Vegasco.WebApi.Endpoints.OpenApi;
using Vegasco.WebApi.Persistence;
namespace Vegasco.WebApi.Common;
@@ -16,14 +18,15 @@ public static class DependencyInjectionExtensions
/// Adds all the WebApi related services to the Dependency Injection container.
/// </summary>
/// <param name="services"></param>
public static void AddWebApiServices(this IServiceCollection services)
public static void AddWebApiServices(this IServiceCollection services, IConfiguration configuration)
{
services
.AddMiscellaneousServices()
.AddOpenApi()
.AddApiVersioning()
.AddOtel()
.AddAuthenticationAndAuthorization();
.AddAuthenticationAndAuthorization()
.AddDbContext(configuration);
}
private static IServiceCollection AddMiscellaneousServices(this IServiceCollection services)
@@ -38,6 +41,8 @@ public static class DependencyInjectionExtensions
services.AddHealthChecks();
services.AddEndpointsFromAssemblyContaining<IWebApiMarker>();
services.AddHttpContextAccessor();
return services;
}
@@ -46,7 +51,27 @@ public static class DependencyInjectionExtensions
services.ConfigureOptions<ConfigureSwaggerGenOptions>();
services.AddEndpointsApiExplorer();
services.AddSwaggerGen();
services.AddSwaggerGen(o =>
{
o.CustomSchemaIds(type =>
{
if (string.IsNullOrEmpty(type.FullName))
{
return type.Name;
}
var fullClassName = type.FullName;
if (!string.IsNullOrEmpty(type.Namespace))
{
fullClassName = fullClassName
.Replace(type.Namespace, "")
.TrimStart('.');
}
return fullClassName;
});
});
return services;
}
@@ -119,6 +144,21 @@ public static class DependencyInjectionExtensions
.RequireAuthenticatedUser()
.AddAuthenticationSchemes(JwtBearerDefaults.AuthenticationScheme));
services.AddScoped<UserAccessor>();
return services;
}
private static IServiceCollection AddDbContext(this IServiceCollection services, IConfiguration configuration)
{
services.AddDbContext<ApplicationDbContext>(o =>
{
o.UseNpgsql(configuration.GetConnectionString("Database"), c =>
{
c.EnableRetryOnFailure();
});
});
return services;
}
}

View File

@@ -0,0 +1,36 @@
using FluentValidation;
using Microsoft.Extensions.Options;
namespace Vegasco.WebApi.Common;
public class FluentValidationOptions<TOptions> : IValidateOptions<TOptions>
where TOptions : class
{
private readonly IEnumerable<IValidator<TOptions>> _validators;
public string? Name { get; set; }
public FluentValidationOptions(string? name, IEnumerable<IValidator<TOptions>> validators)
{
Name = name;
_validators = validators;
}
public ValidateOptionsResult Validate(string? name, TOptions options)
{
if (name is not null && name != Name)
{
return ValidateOptionsResult.Skip;
}
ArgumentNullException.ThrowIfNull(options);
var failedValidations = _validators.ValidateAllAsync(options).Result;
if (failedValidations.Count == 0)
{
return ValidateOptionsResult.Success;
}
return ValidateOptionsResult.Fail(failedValidations.SelectMany(x => x.Errors.Select(x => x.ErrorMessage)));
}
}

View File

@@ -11,7 +11,7 @@ internal static class StartupExtensions
{
builder.Configuration.AddEnvironmentVariables("Vegasco_");
builder.Services.AddWebApiServices();
builder.Services.AddWebApiServices(builder.Configuration);
WebApplication app = builder.Build();
return app;

View File

@@ -13,10 +13,10 @@ public static class ValidatorExtensions
/// <param name="validators"></param>
/// <param name="instance"></param>
/// <returns>The failed validation results.</returns>
public static async Task<List<ValidationResult>> ValidateAllAsync<T>(this IEnumerable<IValidator<T>> validators, T instance)
public static async Task<List<ValidationResult>> ValidateAllAsync<T>(this IEnumerable<IValidator<T>> validators, T instance, CancellationToken cancellationToken = default)
{
var validationTasks = validators
.Select(validator => validator.ValidateAsync(instance))
.Select(validator => validator.ValidateAsync(instance, cancellationToken))
.ToList();
await Task.WhenAll(validationTasks);
@@ -60,35 +60,3 @@ public static class ValidatorExtensions
return builder;
}
}
internal class FluentValidationOptions<TOptions> : IValidateOptions<TOptions>
where TOptions : class
{
private readonly IEnumerable<IValidator<TOptions>> _validators;
public string? Name { get; set; }
public FluentValidationOptions(string? name, IEnumerable<IValidator<TOptions>> validators)
{
Name = name;
_validators = validators;
}
public ValidateOptionsResult Validate(string? name, TOptions options)
{
if (name is not null && name != Name)
{
return ValidateOptionsResult.Skip;
}
ArgumentNullException.ThrowIfNull(options);
var failedValidations = _validators.ValidateAllAsync(options).Result;
if (failedValidations.Count == 0)
{
return ValidateOptionsResult.Success;
}
return ValidateOptionsResult.Fail(failedValidations.SelectMany(x => x.Errors.Select(x => x.ErrorMessage)));
}
}

View File

@@ -34,6 +34,10 @@ public static class EndpointExtensions
.WithApiVersionSet(apiVersionSet)
.RequireAuthorization(Constants.Authorization.RequireAuthenticatedUserPolicy);
GetCar.MapEndpoint(versionedApis);
GetCars.MapEndpoint(versionedApis);
CreateCar.MapEndpoint(versionedApis);
UpdateCar.MapEndpoint(versionedApis);
DeleteCar.MapEndpoint(versionedApis);
}
}

View File

@@ -0,0 +1,19 @@
using Microsoft.EntityFrameworkCore;
using Vegasco.WebApi.Cars;
using Vegasco.WebApi.Common;
using Vegasco.WebApi.Users;
namespace Vegasco.WebApi.Persistence;
public class ApplicationDbContext(DbContextOptions<ApplicationDbContext> options) : DbContext(options)
{
public DbSet<Car> Cars { get; set; }
public DbSet<User> Users { get; set; }
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
base.OnModelCreating(modelBuilder);
modelBuilder.ApplyConfigurationsFromAssembly(typeof(IWebApiMarker).Assembly);
}
}

View File

@@ -0,0 +1,78 @@
// <auto-generated />
using System;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
using Vegasco.WebApi.Persistence;
#nullable disable
namespace Vegasco.WebApi.Persistence.Migrations
{
[DbContext(typeof(ApplicationDbContext))]
[Migration("20240803173803_AddCarsAndUsers")]
partial class AddCarsAndUsers
{
/// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder
.HasAnnotation("ProductVersion", "8.0.7")
.HasAnnotation("Relational:MaxIdentifierLength", 63);
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
modelBuilder.Entity("Vegasco.WebApi.Cars.Car", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(50)
.HasColumnType("character varying(50)");
b.Property<string>("UserId")
.IsRequired()
.HasColumnType("text");
b.HasKey("Id");
b.HasIndex("UserId");
b.ToTable("Cars");
});
modelBuilder.Entity("Vegasco.WebApi.Users.User", b =>
{
b.Property<string>("Id")
.HasColumnType("text");
b.HasKey("Id");
b.ToTable("Users");
});
modelBuilder.Entity("Vegasco.WebApi.Cars.Car", b =>
{
b.HasOne("Vegasco.WebApi.Users.User", "User")
.WithMany("Cars")
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("User");
});
modelBuilder.Entity("Vegasco.WebApi.Users.User", b =>
{
b.Navigation("Cars");
});
#pragma warning restore 612, 618
}
}
}

View File

@@ -0,0 +1,60 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Vegasco.WebApi.Persistence.Migrations
{
/// <inheritdoc />
public partial class AddCarsAndUsers : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "Users",
columns: table => new
{
Id = table.Column<string>(type: "text", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_Users", x => x.Id);
});
migrationBuilder.CreateTable(
name: "Cars",
columns: table => new
{
Id = table.Column<Guid>(type: "uuid", nullable: false),
Name = table.Column<string>(type: "character varying(50)", maxLength: 50, nullable: false),
UserId = table.Column<string>(type: "text", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_Cars", x => x.Id);
table.ForeignKey(
name: "FK_Cars_Users_UserId",
column: x => x.UserId,
principalTable: "Users",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateIndex(
name: "IX_Cars_UserId",
table: "Cars",
column: "UserId");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "Cars");
migrationBuilder.DropTable(
name: "Users");
}
}
}

View File

@@ -0,0 +1,75 @@
// <auto-generated />
using System;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
using Vegasco.WebApi.Persistence;
#nullable disable
namespace Vegasco.WebApi.Persistence.Migrations
{
[DbContext(typeof(ApplicationDbContext))]
partial class ApplicationDbContextModelSnapshot : ModelSnapshot
{
protected override void BuildModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder
.HasAnnotation("ProductVersion", "8.0.7")
.HasAnnotation("Relational:MaxIdentifierLength", 63);
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
modelBuilder.Entity("Vegasco.WebApi.Cars.Car", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(50)
.HasColumnType("character varying(50)");
b.Property<string>("UserId")
.IsRequired()
.HasColumnType("text");
b.HasKey("Id");
b.HasIndex("UserId");
b.ToTable("Cars");
});
modelBuilder.Entity("Vegasco.WebApi.Users.User", b =>
{
b.Property<string>("Id")
.HasColumnType("text");
b.HasKey("Id");
b.ToTable("Users");
});
modelBuilder.Entity("Vegasco.WebApi.Cars.Car", b =>
{
b.HasOne("Vegasco.WebApi.Users.User", "User")
.WithMany("Cars")
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("User");
});
modelBuilder.Entity("Vegasco.WebApi.Users.User", b =>
{
b.Navigation("Cars");
});
#pragma warning restore 612, 618
}
}
}

10
src/WebApi/Users/User.cs Normal file
View File

@@ -0,0 +1,10 @@
using Vegasco.WebApi.Cars;
namespace Vegasco.WebApi.Users;
public class User
{
public string Id { get; set; } = "";
public virtual IList<Car> Cars { get; set; } = [];
}

View File

@@ -0,0 +1,12 @@
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
namespace Vegasco.WebApi.Users;
public class UserTableConfiguration : IEntityTypeConfiguration<User>
{
public void Configure(EntityTypeBuilder<User> builder)
{
builder.HasKey(user => user.Id);
}
}

View File

@@ -16,7 +16,13 @@
<PackageReference Include="FluentValidation.DependencyInjectionExtensions" Version="11.9.2" />
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="8.0.7" />
<PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="8.0.7" />
<PackageReference Include="Microsoft.EntityFrameworkCore" Version="8.0.7" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="8.0.7">
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
<PrivateAssets>all</PrivateAssets>
</PackageReference>
<PackageReference Include="Microsoft.VisualStudio.Azure.Containers.Tools.Targets" Version="1.21.0" />
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="8.0.4" />
<PackageReference Include="OpenTelemetry" Version="1.9.0" />
<PackageReference Include="OpenTelemetry.Exporter.OpenTelemetryProtocol" Version="1.9.0" />
<PackageReference Include="OpenTelemetry.Extensions.Hosting" Version="1.9.0" />

View File

@@ -1,4 +1,7 @@
{
"ConnectionStrings": {
"Database": "Host=localhost;Port=5432;Database=postgres;Username=postgres;Password=postgres"
},
"Logging": {
"LogLevel": {
"Default": "Information",