Skip to content

Commit

Permalink
Add FluentValidation to WebApi
Browse files Browse the repository at this point in the history
  • Loading branch information
jonathanpotts committed Feb 2, 2024
1 parent 79992df commit 6e7dbff
Show file tree
Hide file tree
Showing 28 changed files with 189 additions and 147 deletions.
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ RecipeCatalog is a [.NET](https://dotnet.microsoft.com/) 8 project showcasing a
- [Entity Framework Core](https://learn.microsoft.com/ef/core/)
- [Migrations](https://learn.microsoft.com/ef/core/managing-schemas/migrations/)
- [SQLite](https://www.sqlite.org/)
- [FluentValidation](https://github.com/FluentValidation/FluentValidation)
- Markdown ([Markdig](https://github.com/xoofx/markdig))
- Snowflake IDs ([IdGen](https://github.com/RobThree/IdGen))

Expand Down
30 changes: 4 additions & 26 deletions src/WebApi/Apis/CuisinesApi.cs
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Http.HttpResults;
using Microsoft.EntityFrameworkCore;
using SharpGrip.FluentValidation.AutoValidation.Endpoints.Extensions;

namespace JonathanPotts.RecipeCatalog.WebApi.Apis;

Expand All @@ -13,6 +14,7 @@ public static class CuisinesApi
public static IEndpointRouteBuilder MapCuisinesApi(this IEndpointRouteBuilder builder)
{
var group = builder.MapGroup("/api/v1/cuisines")
.AddFluentValidationAutoValidation()
.WithTags("Cuisines");

group.MapGet("/", GetListAsync);
Expand Down Expand Up @@ -61,23 +63,11 @@ public static async Task<Results<Ok<CuisineDto>, NotFound>> GetAsync(
}

[Authorize]
public static async Task<Results<Created<CuisineDto>, ValidationProblem, ForbidHttpResult>> PostAsync(
public static async Task<Results<Created<CuisineDto>, ForbidHttpResult>> PostAsync(
[AsParameters] Services services,
ClaimsPrincipal user,
CuisineDto dto)
{
Dictionary<string, string[]> errors = [];

if (string.IsNullOrWhiteSpace(dto.Name))
{
errors.Add(nameof(dto.Name), ["Value is required."]);
}

if (errors.Count != 0)
{
return TypedResults.ValidationProblem(errors);
}

Cuisine cuisine = new()
{
Name = dto.Name
Expand All @@ -101,24 +91,12 @@ public static async Task<Results<Created<CuisineDto>, ValidationProblem, ForbidH
}

[Authorize]
public static async Task<Results<Ok<CuisineDto>, ValidationProblem, NotFound, ForbidHttpResult>> PutAsync(
public static async Task<Results<Ok<CuisineDto>, NotFound, ForbidHttpResult>> PutAsync(
[AsParameters] Services services,
ClaimsPrincipal user,
int id,
CuisineDto dto)
{
Dictionary<string, string[]> errors = [];

if (string.IsNullOrWhiteSpace(dto.Name))
{
errors.Add(nameof(dto.Name), ["Value is required."]);
}

if (errors.Count != 0)
{
return TypedResults.ValidationProblem(errors);
}

var cuisine = await services.Context.Cuisines
.FirstOrDefaultAsync(x => x.Id == id);

Expand Down
56 changes: 6 additions & 50 deletions src/WebApi/Apis/RecipesApi.cs
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
using Microsoft.AspNetCore.Http.HttpResults;
using Microsoft.AspNetCore.Identity;
using Microsoft.EntityFrameworkCore;
using SharpGrip.FluentValidation.AutoValidation.Endpoints.Extensions;

namespace JonathanPotts.RecipeCatalog.WebApi.Apis;

Expand All @@ -27,6 +28,7 @@ public static class RecipesApi
public static IEndpointRouteBuilder MapRecipesApi(this IEndpointRouteBuilder builder)
{
var group = builder.MapGroup("/api/v1/recipes")
.AddFluentValidationAutoValidation()
.WithTags("Recipes");

group.MapGet("/", GetListAsync);
Expand All @@ -48,12 +50,10 @@ public static async Task<Results<Ok<PagedResult<RecipeWithCuisineDto>>, Validati
{
if (top is < 1 or > MaxItemsPerPage)
{
Dictionary<string, string[]> errors = new()
return TypedResults.ValidationProblem(new Dictionary<string, string[]>
{
{ nameof(top), [$"Value must be between 1 and {MaxItemsPerPage}."] }
};

return TypedResults.ValidationProblem(errors);
});
}

IQueryable<Recipe> recipes = services.Context.Recipes;
Expand Down Expand Up @@ -163,33 +163,11 @@ public static async Task<Results<PhysicalFileHttpResult, NotFound>> GetCoverImag
}

[Authorize]
public static async Task<Results<Created<RecipeWithCuisineDto>, ValidationProblem, ForbidHttpResult>> PostAsync(
public static async Task<Results<Created<RecipeWithCuisineDto>, ForbidHttpResult>> PostAsync(
[AsParameters] Services services,
ClaimsPrincipal user,
RecipeCreateOrUpdateDto dto)
{
Dictionary<string, string[]> errors = [];

if (string.IsNullOrWhiteSpace(dto.Name))
{
errors.Add(nameof(dto.Name), ["Value is required."]);
}

if (dto.Ingredients?.Length == 0 || (dto.Ingredients?.Any(string.IsNullOrWhiteSpace) ?? false))
{
errors.Add(nameof(dto.Ingredients), ["Value is required."]);
}

if (string.IsNullOrWhiteSpace(dto.Instructions))
{
errors.Add(nameof(dto.Instructions), ["Value is required."]);
}

if (errors.Count != 0)
{
return TypedResults.ValidationProblem(errors);
}

Recipe recipe = new()
{
Id = services.IdGenerator.CreateId(),
Expand Down Expand Up @@ -237,34 +215,12 @@ public static async Task<Results<Created<RecipeWithCuisineDto>, ValidationProble
}

[Authorize]
public static async Task<Results<Ok<RecipeWithCuisineDto>, ValidationProblem, NotFound, ForbidHttpResult>> PutAsync(
public static async Task<Results<Ok<RecipeWithCuisineDto>, NotFound, ForbidHttpResult>> PutAsync(
[AsParameters] Services services,
ClaimsPrincipal user,
long id,
RecipeCreateOrUpdateDto dto)
{
Dictionary<string, string[]> errors = [];

if (string.IsNullOrWhiteSpace(dto.Name))
{
errors.Add(nameof(dto.Name), ["Value is required."]);
}

if (dto.Ingredients?.Length == 0 || (dto.Ingredients?.Any(string.IsNullOrWhiteSpace) ?? false))
{
errors.Add(nameof(dto.Ingredients), ["Value is required."]);
}

if (string.IsNullOrWhiteSpace(dto.Instructions))
{
errors.Add(nameof(dto.Instructions), ["Value is required."]);
}

if (errors.Count != 0)
{
return TypedResults.ValidationProblem(errors);
}

var recipe = await services.Context.Recipes
.FirstOrDefaultAsync(x => x.Id == id);

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,9 @@ public class CuisineEntityTypeConfiguration : IEntityTypeConfiguration<Cuisine>
{
public void Configure(EntityTypeBuilder<Cuisine> builder)
{
builder.Property(x => x.Name)
.IsRequired();

builder.HasIndex(x => x.Name)
.IsUnique();
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,17 +17,45 @@ public void Configure(EntityTypeBuilder<Recipe> builder)
x => x != null ? utcConverter.ConvertToProviderTyped(x.Value) : null,
x => x != null ? utcConverter.ConvertFromProviderTyped(x.Value) : null);

builder.OwnsOne(x => x.CoverImage);
builder.OwnsOne(x => x.CoverImage, ownedBuilder =>
{
ownedBuilder.Property(x => x.Url)
.IsRequired();
});

builder.OwnsOne(x => x.Instructions);
builder.OwnsOne(x => x.Instructions, ownedBuilder =>
{
ownedBuilder.Property(x => x.Markdown)
.IsRequired();

ownedBuilder.Property(x => x.Html)
.IsRequired();
});

builder.Navigation(x => x.Instructions)
.IsRequired();

builder.HasOne(x => x.Owner).WithMany()
.HasForeignKey(x => x.OwnerId)
.IsRequired();

builder.HasOne(x => x.Cuisine).WithMany(x => x.Recipes)
.HasForeignKey(x => x.CuisineId)
.IsRequired();

builder.Property(x => x.Id)
.ValueGeneratedNever();

builder.Property(x => x.Name)
.IsRequired();

builder.Property(x => x.Created)
.HasConversion(utcConverter);

builder.Property(x => x.Modified)
.HasConversion(nullableUtcConverter);

builder.Property(x => x.Ingredients)
.IsRequired();
}
}
12 changes: 6 additions & 6 deletions src/WebApi/Data/Migrations/ApplicationDbContextModelSnapshot.cs
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@ protected override void BuildModel(ModelBuilder modelBuilder)
b.HasIndex("Name")
.IsUnique();

b.ToTable("Cuisines");
b.ToTable("Cuisines", (string)null);
});

modelBuilder.Entity("JonathanPotts.RecipeCatalog.WebApi.Models.Recipe", b =>
Expand Down Expand Up @@ -70,7 +70,7 @@ protected override void BuildModel(ModelBuilder modelBuilder)

b.HasIndex("OwnerId");

b.ToTable("Recipes");
b.ToTable("Recipes", (string)null);
});

modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRole", b =>
Expand Down Expand Up @@ -279,7 +279,7 @@ protected override void BuildModel(ModelBuilder modelBuilder)
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();

b.OwnsOne("JonathanPotts.RecipeCatalog.WebApi.Models.ImageData", "CoverImage", b1 =>
b.OwnsOne("JonathanPotts.RecipeCatalog.WebApi.Models.Recipe.CoverImage#JonathanPotts.RecipeCatalog.WebApi.Models.ImageData", "CoverImage", b1 =>
{
b1.Property<long>("RecipeId")
.HasColumnType("INTEGER");
Expand All @@ -293,13 +293,13 @@ protected override void BuildModel(ModelBuilder modelBuilder)

b1.HasKey("RecipeId");

b1.ToTable("Recipes");
b1.ToTable("Recipes", (string)null);

b1.WithOwner()
.HasForeignKey("RecipeId");
});

b.OwnsOne("JonathanPotts.RecipeCatalog.WebApi.Models.MarkdownData", "Instructions", b1 =>
b.OwnsOne("JonathanPotts.RecipeCatalog.WebApi.Models.Recipe.Instructions#JonathanPotts.RecipeCatalog.WebApi.Models.MarkdownData", "Instructions", b1 =>
{
b1.Property<long>("RecipeId")
.HasColumnType("INTEGER");
Expand All @@ -314,7 +314,7 @@ protected override void BuildModel(ModelBuilder modelBuilder)

b1.HasKey("RecipeId");

b1.ToTable("Recipes");
b1.ToTable("Recipes", (string)null);

b1.WithOwner()
.HasForeignKey("RecipeId");
Expand Down
5 changes: 1 addition & 4 deletions src/WebApi/Models/Cuisine.cs
Original file line number Diff line number Diff line change
@@ -1,12 +1,9 @@
using System.ComponentModel.DataAnnotations;

namespace JonathanPotts.RecipeCatalog.WebApi.Models;
namespace JonathanPotts.RecipeCatalog.WebApi.Models;

public class Cuisine
{
public int Id { get; set; }

[Required]
public string? Name { get; set; }

public List<Recipe>? Recipes { get; set; }
Expand Down
5 changes: 1 addition & 4 deletions src/WebApi/Models/CuisineCreateOrUpdateDto.cs
Original file line number Diff line number Diff line change
@@ -1,9 +1,6 @@
using System.ComponentModel.DataAnnotations;

namespace JonathanPotts.RecipeCatalog.WebApi.Models;
namespace JonathanPotts.RecipeCatalog.WebApi.Models;

public class CuisineCreateOrUpdateDto
{
[Required]
public string? Name { get; set; }
}
5 changes: 1 addition & 4 deletions src/WebApi/Models/CuisineDto.cs
Original file line number Diff line number Diff line change
@@ -1,11 +1,8 @@
using System.ComponentModel.DataAnnotations;

namespace JonathanPotts.RecipeCatalog.WebApi.Models;
namespace JonathanPotts.RecipeCatalog.WebApi.Models;

public class CuisineDto
{
public int Id { get; set; }

[Required]
public string? Name { get; set; }
}
5 changes: 1 addition & 4 deletions src/WebApi/Models/ImageData.cs
Original file line number Diff line number Diff line change
@@ -1,10 +1,7 @@
using System.ComponentModel.DataAnnotations;

namespace JonathanPotts.RecipeCatalog.WebApi.Models;
namespace JonathanPotts.RecipeCatalog.WebApi.Models;

public class ImageData
{
[Required]
public string? Url { get; set; }

public string? AltText { get; set; }
Expand Down
6 changes: 1 addition & 5 deletions src/WebApi/Models/MarkdownData.cs
Original file line number Diff line number Diff line change
@@ -1,12 +1,8 @@
using System.ComponentModel.DataAnnotations;

namespace JonathanPotts.RecipeCatalog.WebApi.Models;
namespace JonathanPotts.RecipeCatalog.WebApi.Models;

public class MarkdownData
{
[Required]
public string? Markdown { get; set; }

[Required]
public string? Html { get; set; }
}
9 changes: 1 addition & 8 deletions src/WebApi/Models/Recipe.cs
Original file line number Diff line number Diff line change
@@ -1,26 +1,21 @@
using System.ComponentModel.DataAnnotations;
using Microsoft.AspNetCore.Identity;
using Microsoft.AspNetCore.Identity;

namespace JonathanPotts.RecipeCatalog.WebApi.Models;

public class Recipe
{
public long Id { get; set; }

[Required]
public string? OwnerId { get; set; }

[Required]
public IdentityUser? Owner { get; set; }

[Required]
public string? Name { get; set; }

public ImageData? CoverImage { get; set; }

public int CuisineId { get; set; }

[Required]
public Cuisine? Cuisine { get; set; }

public string? Description { get; set; }
Expand All @@ -29,9 +24,7 @@ public class Recipe

public DateTime? Modified { get; set; }

[Required]
public string[]? Ingredients { get; set; }

[Required]
public MarkdownData? Instructions { get; set; }
}
Loading

0 comments on commit 6e7dbff

Please sign in to comment.