Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add FluentValidation #1628

Merged
merged 2 commits into from
May 24, 2022
Merged
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
1 change: 1 addition & 0 deletions Source/ApiTemplate/.template.config/template.json
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@
"Source/ApiTemplate/Models/**/*",
"Source/ApiTemplate/Repositories/**/*",
"Source/ApiTemplate/Services/**/*",
"Source/ApiTemplate/Validators/**/*",
"Source/ApiTemplate/ViewModels/**/*",
"Source/ApiTemplate/ProjectServiceCollectionExtensions.cs",
"Tests/ApiTemplate.IntegrationTest/Controllers/**/*"
Expand Down
1 change: 1 addition & 0 deletions Source/ApiTemplate/Source/ApiTemplate/ApiTemplate.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,7 @@
<PackageReference Include="Boxed.AspNetCore" Version="8.0.0" />
<PackageReference Include="Boxed.AspNetCore.Swagger" Version="10.0.0" Condition="'$(Swagger)' == 'true'" />
<PackageReference Include="Boxed.Mapping" Version="6.0.0" />
<PackageReference Include="FluentValidation.AspNetCore" Version="11.0.1" />
<PackageReference Include="Microsoft.ApplicationInsights.AspNetCore" Version="2.20.0" Condition="'$(ApplicationInsights)' == 'true'" />
<PackageReference Include="Microsoft.AspNetCore.ApplicationInsights.HostingStartup" Version="2.2.0" Condition="'$(ApplicationInsights)' == 'true'" />
<PackageReference Include="Microsoft.AspNetCore.AzureAppServicesIntegration" Version="6.0.5" Condition="'$(Azure)' == 'true'" />
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,9 @@ namespace ApiTemplate;
#endif
using ApiTemplate.Options;
using Boxed.AspNetCore;
#if Controllers
using FluentValidation.AspNetCore;
#endif
#if (!ForwardedHeaders && HostFiltering)
using Microsoft.AspNetCore.HostFiltering;
#endif
Expand All @@ -17,6 +20,17 @@ namespace ApiTemplate;
/// </summary>
internal static class CustomServiceCollectionExtensions
{
#if Controllers
public static IServiceCollection AddCustomFluentValidation(this IServiceCollection services) =>
services
.AddFluentValidation(
x =>
{
x.RegisterValidatorsFromAssemblyContaining<Startup>(lifetime: ServiceLifetime.Singleton);
x.DisableDataAnnotationsValidation = true;
});

#endif
/// <summary>
/// Configures the settings by binding the contents of the appsettings.json file to the specified Plain Old CLR
/// Objects (POCO) and adding <see cref="IOptions{T}"/> objects to the services collection.
Expand Down
1 change: 1 addition & 0 deletions Source/ApiTemplate/Source/ApiTemplate/Startup.cs
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,7 @@ public virtual void ConfigureServices(IServiceCollection services) =>
#endif
.AddServerTiming()
#if Controllers
.AddCustomFluentValidation()
.AddControllers()
#if DataContractSerializer
// Adds the XML input and output formatter using the DataContractSerializer.
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
namespace ApiTemplate.Validators;

using ApiTemplate.ViewModels;
using FluentValidation;

public class PageOptionsValidator : AbstractValidator<PageOptions>
{
public PageOptionsValidator()
{
this.RuleFor(x => x.First).InclusiveBetween(1, 20);
this.RuleFor(x => x.Last).InclusiveBetween(1, 20);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
namespace ApiTemplate.Validators;

using ApiTemplate.ViewModels;
using FluentValidation;

public class SaveCarValidator : AbstractValidator<SaveCar>
{
public SaveCarValidator()
{
this.RuleFor(x => x.Cylinders).InclusiveBetween(1, 20);
this.RuleFor(x => x.Make).NotEmpty();
this.RuleFor(x => x.Model).NotEmpty();
}
}
Original file line number Diff line number Diff line change
@@ -1,7 +1,5 @@
namespace ApiTemplate.ViewModels;

using System.ComponentModel.DataAnnotations;

#if Swagger
/// <summary>
/// The options used to request a page.
Expand All @@ -15,7 +13,6 @@ public class PageOptions
/// </summary>
/// <example>10</example>
#endif
[Range(1, 20)]
public int? First { get; set; }

#if Swagger
Expand All @@ -24,7 +21,6 @@ public class PageOptions
/// </summary>
/// <example></example>
#endif
[Range(1, 20)]
public int? Last { get; set; }

#if Swagger
Expand Down
5 changes: 0 additions & 5 deletions Source/ApiTemplate/Source/ApiTemplate/ViewModels/SaveCar.cs
Original file line number Diff line number Diff line change
@@ -1,7 +1,5 @@
namespace ApiTemplate.ViewModels;

using System.ComponentModel.DataAnnotations;

#if Swagger
/// <summary>
/// A make and model of car.
Expand All @@ -15,7 +13,6 @@ public class SaveCar
/// </summary>
/// <example>6</example>
#endif
[Range(1, 20)]
public int Cylinders { get; set; }

#if Swagger
Expand All @@ -24,7 +21,6 @@ public class SaveCar
/// </summary>
/// <example>Honda</example>
#endif
[Required]
public string Make { get; set; } = default!;

#if Swagger
Expand All @@ -33,6 +29,5 @@ public class SaveCar
/// </summary>
/// <example>Civic</example>
#endif
[Required]
public string Model { get; set; } = default!;
}
Original file line number Diff line number Diff line change
Expand Up @@ -333,6 +333,23 @@ await this.AssertPageUrlsAsync(
.ConfigureAwait(false);
}

[Theory]
[InlineData("First")]
[InlineData("Last")]
public async Task GetPage_Invalid_Returns400BadRequestAsync(string direction)
{
var response = await this.client
.GetAsync(new Uri($"/cars?{direction}=21", UriKind.Relative))
.ConfigureAwait(false);

Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
Assert.Equal(ContentType.ProblemJson, response.Content.Headers.ContentType?.MediaType);
var problemDetails = await response.Content.ReadAsAsync<ValidationProblemDetails>(this.formatters).ConfigureAwait(false);
Assert.Equal(StatusCodes.Status400BadRequest, problemDetails.Status);
Assert.Equal(1, problemDetails.Errors.Count);
Assert.Equal(new string[] { $"'{direction}' must be between 1 and 20. You entered 21." }, problemDetails.Errors[direction]);
}

[Fact]
public async Task PostCar_Valid_Returns201CreatedAsync()
{
Expand Down Expand Up @@ -366,8 +383,13 @@ public async Task PostCar_Invalid_Returns400BadRequestAsync()
var response = await this.client.PostAsJsonAsync("cars", new SaveCar()).ConfigureAwait(false);

Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
var problemDetails = await response.Content.ReadAsAsync<ProblemDetails>(this.formatters).ConfigureAwait(false);
Assert.Equal(ContentType.ProblemJson, response.Content.Headers.ContentType?.MediaType);
var problemDetails = await response.Content.ReadAsAsync<ValidationProblemDetails>(this.formatters).ConfigureAwait(false);
Assert.Equal(StatusCodes.Status400BadRequest, problemDetails.Status);
Assert.Equal(3, problemDetails.Errors.Count);
Assert.Equal(new string[] { "'Cylinders' must be between 1 and 20. You entered 0." }, problemDetails.Errors[nameof(SaveCar.Cylinders)]);
Assert.Equal(new string[] { "'Make' must not be empty." }, problemDetails.Errors[nameof(SaveCar.Make)]);
Assert.Equal(new string[] { "'Model' must not be empty." }, problemDetails.Errors[nameof(SaveCar.Model)]);
}

[Fact]
Expand All @@ -381,8 +403,11 @@ public async Task PostCar_EmptyRequestBody_Returns400BadRequestAsync()
var response = await this.client.SendAsync(request).ConfigureAwait(false);

Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
var problemDetails = await response.Content.ReadAsAsync<ProblemDetails>(this.formatters).ConfigureAwait(false);
Assert.Equal(ContentType.ProblemJson, response.Content.Headers.ContentType?.MediaType);
var problemDetails = await response.Content.ReadAsAsync<ValidationProblemDetails>(this.formatters).ConfigureAwait(false);
Assert.Equal(StatusCodes.Status400BadRequest, problemDetails.Status);
Assert.Equal(1, problemDetails.Errors.Count);
Assert.Equal(new string[] { "A non-empty request body is required." }, problemDetails.Errors[string.Empty]);
}

[Fact]
Expand All @@ -396,8 +421,10 @@ public async Task PostCar_UnsupportedMediaType_Returns415UnsupportedMediaTypeAsy
var response = await this.client.SendAsync(request).ConfigureAwait(false);

Assert.Equal(HttpStatusCode.UnsupportedMediaType, response.StatusCode);
var problemDetails = await response.Content.ReadAsAsync<ProblemDetails>(this.formatters).ConfigureAwait(false);
Assert.Equal(ContentType.ProblemJson, response.Content.Headers.ContentType?.MediaType);
var problemDetails = await response.Content.ReadAsAsync<ValidationProblemDetails>(this.formatters).ConfigureAwait(false);
Assert.Equal(StatusCodes.Status415UnsupportedMediaType, problemDetails.Status);
Assert.Empty(problemDetails.Errors);
}

[Fact]
Expand Down Expand Up @@ -437,8 +464,10 @@ public async Task PutCar_CarNotFound_Returns404NotFoundAsync()
var response = await this.client.PutAsJsonAsync("cars/999", saveCar).ConfigureAwait(false);

Assert.Equal(HttpStatusCode.NotFound, response.StatusCode);
var problemDetails = await response.Content.ReadAsAsync<ProblemDetails>(this.formatters).ConfigureAwait(false);
Assert.Equal(ContentType.ProblemJson, response.Content.Headers.ContentType?.MediaType);
var problemDetails = await response.Content.ReadAsAsync<ValidationProblemDetails>(this.formatters).ConfigureAwait(false);
Assert.Equal(StatusCodes.Status404NotFound, problemDetails.Status);
Assert.Empty(problemDetails.Errors);
}

[Fact]
Expand All @@ -447,8 +476,13 @@ public async Task PutCar_Invalid_Returns400BadRequestAsync()
var response = await this.client.PutAsJsonAsync("cars/1", new SaveCar()).ConfigureAwait(false);

Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
var problemDetails = await response.Content.ReadAsAsync<ProblemDetails>(this.formatters).ConfigureAwait(false);
Assert.Equal(ContentType.ProblemJson, response.Content.Headers.ContentType?.MediaType);
var problemDetails = await response.Content.ReadAsAsync<ValidationProblemDetails>(this.formatters).ConfigureAwait(false);
Assert.Equal(StatusCodes.Status400BadRequest, problemDetails.Status);
Assert.Equal(3, problemDetails.Errors.Count);
Assert.Equal(new string[] { "'Cylinders' must be between 1 and 20. You entered 0." }, problemDetails.Errors[nameof(SaveCar.Cylinders)]);
Assert.Equal(new string[] { "'Make' must not be empty." }, problemDetails.Errors[nameof(SaveCar.Make)]);
Assert.Equal(new string[] { "'Model' must not be empty." }, problemDetails.Errors[nameof(SaveCar.Model)]);
}

[Fact]
Expand All @@ -465,8 +499,10 @@ public async Task PatchCar_CarNotFound_Returns404NotFoundAsync()
.ConfigureAwait(false);

Assert.Equal(HttpStatusCode.NotFound, response.StatusCode);
var problemDetails = await response.Content.ReadAsAsync<ProblemDetails>(this.formatters).ConfigureAwait(false);
Assert.Equal(ContentType.ProblemJson, response.Content.Headers.ContentType?.MediaType);
var problemDetails = await response.Content.ReadAsAsync<ValidationProblemDetails>(this.formatters).ConfigureAwait(false);
Assert.Equal(StatusCodes.Status404NotFound, problemDetails.Status);
Assert.Empty(problemDetails.Errors);
}

[Fact]
Expand Down