From e4a03e39dfaa908aea374a9c7434438ab91d7924 Mon Sep 17 00:00:00 2001 From: Muhammad Rehan Saeed Date: Tue, 24 May 2022 14:29:31 +0100 Subject: [PATCH 1/2] Add FluentValidation --- .../Source/ApiTemplate/ApiTemplate.csproj | 1 + .../CustomServiceCollectionExtensions.cs | 10 ++++ .../ApiTemplate/Source/ApiTemplate/Startup.cs | 1 + .../Validators/PageOptionsValidator.cs | 13 +++++ .../Validators/SaveCarValidator.cs | 14 ++++++ .../ApiTemplate/ViewModels/PageOptions.cs | 4 -- .../Source/ApiTemplate/ViewModels/SaveCar.cs | 5 -- .../Controllers/CarsControllerTest.cs | 48 ++++++++++++++++--- 8 files changed, 81 insertions(+), 15 deletions(-) create mode 100644 Source/ApiTemplate/Source/ApiTemplate/Validators/PageOptionsValidator.cs create mode 100644 Source/ApiTemplate/Source/ApiTemplate/Validators/SaveCarValidator.cs diff --git a/Source/ApiTemplate/Source/ApiTemplate/ApiTemplate.csproj b/Source/ApiTemplate/Source/ApiTemplate/ApiTemplate.csproj index ce24b62c7..4a05c9636 100644 --- a/Source/ApiTemplate/Source/ApiTemplate/ApiTemplate.csproj +++ b/Source/ApiTemplate/Source/ApiTemplate/ApiTemplate.csproj @@ -55,6 +55,7 @@ + diff --git a/Source/ApiTemplate/Source/ApiTemplate/CustomServiceCollectionExtensions.cs b/Source/ApiTemplate/Source/ApiTemplate/CustomServiceCollectionExtensions.cs index 8210320bd..86925f9d6 100644 --- a/Source/ApiTemplate/Source/ApiTemplate/CustomServiceCollectionExtensions.cs +++ b/Source/ApiTemplate/Source/ApiTemplate/CustomServiceCollectionExtensions.cs @@ -6,6 +6,7 @@ namespace ApiTemplate; #endif using ApiTemplate.Options; using Boxed.AspNetCore; +using FluentValidation.AspNetCore; #if (!ForwardedHeaders && HostFiltering) using Microsoft.AspNetCore.HostFiltering; #endif @@ -17,6 +18,15 @@ namespace ApiTemplate; /// internal static class CustomServiceCollectionExtensions { + public static IServiceCollection AddCustomFluentValidation(this IServiceCollection services) => + services + .AddFluentValidation( + x => + { + x.RegisterValidatorsFromAssemblyContaining(lifetime: ServiceLifetime.Singleton); + x.DisableDataAnnotationsValidation = true; + }); + /// /// Configures the settings by binding the contents of the appsettings.json file to the specified Plain Old CLR /// Objects (POCO) and adding objects to the services collection. diff --git a/Source/ApiTemplate/Source/ApiTemplate/Startup.cs b/Source/ApiTemplate/Source/ApiTemplate/Startup.cs index f267916ca..aabea1de4 100644 --- a/Source/ApiTemplate/Source/ApiTemplate/Startup.cs +++ b/Source/ApiTemplate/Source/ApiTemplate/Startup.cs @@ -84,6 +84,7 @@ public virtual void ConfigureServices(IServiceCollection services) => .AddVersionedApiExplorer() #endif .AddServerTiming() + .AddCustomFluentValidation() #if Controllers .AddControllers() #if DataContractSerializer diff --git a/Source/ApiTemplate/Source/ApiTemplate/Validators/PageOptionsValidator.cs b/Source/ApiTemplate/Source/ApiTemplate/Validators/PageOptionsValidator.cs new file mode 100644 index 000000000..301dd83c0 --- /dev/null +++ b/Source/ApiTemplate/Source/ApiTemplate/Validators/PageOptionsValidator.cs @@ -0,0 +1,13 @@ +namespace ApiTemplate.Validators; + +using ApiTemplate.ViewModels; +using FluentValidation; + +public class PageOptionsValidator : AbstractValidator +{ + public PageOptionsValidator() + { + this.RuleFor(x => x.First).InclusiveBetween(1, 20); + this.RuleFor(x => x.Last).InclusiveBetween(1, 20); + } +} diff --git a/Source/ApiTemplate/Source/ApiTemplate/Validators/SaveCarValidator.cs b/Source/ApiTemplate/Source/ApiTemplate/Validators/SaveCarValidator.cs new file mode 100644 index 000000000..572870086 --- /dev/null +++ b/Source/ApiTemplate/Source/ApiTemplate/Validators/SaveCarValidator.cs @@ -0,0 +1,14 @@ +namespace ApiTemplate.Validators; + +using ApiTemplate.ViewModels; +using FluentValidation; + +public class SaveCarValidator : AbstractValidator +{ + public SaveCarValidator() + { + this.RuleFor(x => x.Cylinders).InclusiveBetween(1, 20); + this.RuleFor(x => x.Make).NotEmpty(); + this.RuleFor(x => x.Model).NotEmpty(); + } +} diff --git a/Source/ApiTemplate/Source/ApiTemplate/ViewModels/PageOptions.cs b/Source/ApiTemplate/Source/ApiTemplate/ViewModels/PageOptions.cs index 37bbdf08c..231227966 100644 --- a/Source/ApiTemplate/Source/ApiTemplate/ViewModels/PageOptions.cs +++ b/Source/ApiTemplate/Source/ApiTemplate/ViewModels/PageOptions.cs @@ -1,7 +1,5 @@ namespace ApiTemplate.ViewModels; -using System.ComponentModel.DataAnnotations; - #if Swagger /// /// The options used to request a page. @@ -15,7 +13,6 @@ public class PageOptions /// /// 10 #endif - [Range(1, 20)] public int? First { get; set; } #if Swagger @@ -24,7 +21,6 @@ public class PageOptions /// /// #endif - [Range(1, 20)] public int? Last { get; set; } #if Swagger diff --git a/Source/ApiTemplate/Source/ApiTemplate/ViewModels/SaveCar.cs b/Source/ApiTemplate/Source/ApiTemplate/ViewModels/SaveCar.cs index f3cddbf84..fb51afa4b 100644 --- a/Source/ApiTemplate/Source/ApiTemplate/ViewModels/SaveCar.cs +++ b/Source/ApiTemplate/Source/ApiTemplate/ViewModels/SaveCar.cs @@ -1,7 +1,5 @@ namespace ApiTemplate.ViewModels; -using System.ComponentModel.DataAnnotations; - #if Swagger /// /// A make and model of car. @@ -15,7 +13,6 @@ public class SaveCar /// /// 6 #endif - [Range(1, 20)] public int Cylinders { get; set; } #if Swagger @@ -24,7 +21,6 @@ public class SaveCar /// /// Honda #endif - [Required] public string Make { get; set; } = default!; #if Swagger @@ -33,6 +29,5 @@ public class SaveCar /// /// Civic #endif - [Required] public string Model { get; set; } = default!; } diff --git a/Source/ApiTemplate/Tests/ApiTemplate.IntegrationTest/Controllers/CarsControllerTest.cs b/Source/ApiTemplate/Tests/ApiTemplate.IntegrationTest/Controllers/CarsControllerTest.cs index c83fdd880..c787221fd 100644 --- a/Source/ApiTemplate/Tests/ApiTemplate.IntegrationTest/Controllers/CarsControllerTest.cs +++ b/Source/ApiTemplate/Tests/ApiTemplate.IntegrationTest/Controllers/CarsControllerTest.cs @@ -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(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() { @@ -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(this.formatters).ConfigureAwait(false); + Assert.Equal(ContentType.ProblemJson, response.Content.Headers.ContentType?.MediaType); + var problemDetails = await response.Content.ReadAsAsync(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] @@ -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(this.formatters).ConfigureAwait(false); + Assert.Equal(ContentType.ProblemJson, response.Content.Headers.ContentType?.MediaType); + var problemDetails = await response.Content.ReadAsAsync(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] @@ -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(this.formatters).ConfigureAwait(false); + Assert.Equal(ContentType.ProblemJson, response.Content.Headers.ContentType?.MediaType); + var problemDetails = await response.Content.ReadAsAsync(this.formatters).ConfigureAwait(false); Assert.Equal(StatusCodes.Status415UnsupportedMediaType, problemDetails.Status); + Assert.Empty(problemDetails.Errors); } [Fact] @@ -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(this.formatters).ConfigureAwait(false); + Assert.Equal(ContentType.ProblemJson, response.Content.Headers.ContentType?.MediaType); + var problemDetails = await response.Content.ReadAsAsync(this.formatters).ConfigureAwait(false); Assert.Equal(StatusCodes.Status404NotFound, problemDetails.Status); + Assert.Empty(problemDetails.Errors); } [Fact] @@ -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(this.formatters).ConfigureAwait(false); + Assert.Equal(ContentType.ProblemJson, response.Content.Headers.ContentType?.MediaType); + var problemDetails = await response.Content.ReadAsAsync(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] @@ -465,8 +499,10 @@ public async Task PatchCar_CarNotFound_Returns404NotFoundAsync() .ConfigureAwait(false); Assert.Equal(HttpStatusCode.NotFound, response.StatusCode); - var problemDetails = await response.Content.ReadAsAsync(this.formatters).ConfigureAwait(false); + Assert.Equal(ContentType.ProblemJson, response.Content.Headers.ContentType?.MediaType); + var problemDetails = await response.Content.ReadAsAsync(this.formatters).ConfigureAwait(false); Assert.Equal(StatusCodes.Status404NotFound, problemDetails.Status); + Assert.Empty(problemDetails.Errors); } [Fact] From 00f002afce0ad7839b8b9efb2e35d80c5685a517 Mon Sep 17 00:00:00 2001 From: Muhammad Rehan Saeed Date: Tue, 24 May 2022 14:42:53 +0100 Subject: [PATCH 2/2] Remove code when Controllers is disabled --- Source/ApiTemplate/.template.config/template.json | 1 + .../Source/ApiTemplate/CustomServiceCollectionExtensions.cs | 4 ++++ Source/ApiTemplate/Source/ApiTemplate/Startup.cs | 2 +- 3 files changed, 6 insertions(+), 1 deletion(-) diff --git a/Source/ApiTemplate/.template.config/template.json b/Source/ApiTemplate/.template.config/template.json index a0e01cda3..e0d0c8152 100644 --- a/Source/ApiTemplate/.template.config/template.json +++ b/Source/ApiTemplate/.template.config/template.json @@ -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/**/*" diff --git a/Source/ApiTemplate/Source/ApiTemplate/CustomServiceCollectionExtensions.cs b/Source/ApiTemplate/Source/ApiTemplate/CustomServiceCollectionExtensions.cs index 86925f9d6..5b3ed5eba 100644 --- a/Source/ApiTemplate/Source/ApiTemplate/CustomServiceCollectionExtensions.cs +++ b/Source/ApiTemplate/Source/ApiTemplate/CustomServiceCollectionExtensions.cs @@ -6,7 +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 @@ -18,6 +20,7 @@ namespace ApiTemplate; /// internal static class CustomServiceCollectionExtensions { +#if Controllers public static IServiceCollection AddCustomFluentValidation(this IServiceCollection services) => services .AddFluentValidation( @@ -27,6 +30,7 @@ public static IServiceCollection AddCustomFluentValidation(this IServiceCollecti x.DisableDataAnnotationsValidation = true; }); +#endif /// /// Configures the settings by binding the contents of the appsettings.json file to the specified Plain Old CLR /// Objects (POCO) and adding objects to the services collection. diff --git a/Source/ApiTemplate/Source/ApiTemplate/Startup.cs b/Source/ApiTemplate/Source/ApiTemplate/Startup.cs index aabea1de4..faaa094c4 100644 --- a/Source/ApiTemplate/Source/ApiTemplate/Startup.cs +++ b/Source/ApiTemplate/Source/ApiTemplate/Startup.cs @@ -84,8 +84,8 @@ public virtual void ConfigureServices(IServiceCollection services) => .AddVersionedApiExplorer() #endif .AddServerTiming() - .AddCustomFluentValidation() #if Controllers + .AddCustomFluentValidation() .AddControllers() #if DataContractSerializer // Adds the XML input and output formatter using the DataContractSerializer.