From 2aeb2329b73bfee2169a8bcc8f14367f52f10900 Mon Sep 17 00:00:00 2001 From: Vladimir Pecanac Date: Sun, 25 Oct 2026 13:18:06 +0100 Subject: [PATCH] Add WebApiBestPractices sample for the ASP.NET Core Web API best practices article New .NET 10 sample in aspnetcore-webapi/WebApiBestPractices: API, Application, Domain and Infrastructure projects plus 19 xUnit v3 tests covering the exception handler, action filter, JWT, rate limiting, output caching, content negotiation, versioning and OpenAPI. --- .../Controllers/FilesController.cs | 28 ++ .../Controllers/OwnersController.cs | 72 +++++ .../Controllers/OwnersV2Controller.cs | 20 ++ .../Extensions/ServiceExtensions.cs | 102 +++++++ .../Filters/ValidateOwnerExistsFilter.cs | 23 ++ .../Handlers/GlobalExceptionHandler.cs | 39 +++ .../Logging/LogMessages.cs | 7 + .../WebApiBestPractices.Api/Program.cs | 50 ++++ .../Properties/launchSettings.json | 14 + .../Security/AccountService.cs | 18 ++ .../Security/OwnerIdProtector.cs | 12 + .../WebApiBestPractices.Api.csproj | 20 ++ .../appsettings.Development.json | 14 + .../appsettings.Production.json | 10 + .../WebApiBestPractices.Api/appsettings.json | 19 ++ .../Dtos/OwnerDtos.cs | 32 +++ .../Mapping/OwnerMappings.cs | 23 ++ .../Services/IOwnerService.cs | 12 + .../Services/OwnerService.cs | 51 ++++ .../WebApiBestPractices.Application.csproj | 17 ++ .../Contracts/IOwnerRepository.cs | 13 + .../Entities/Owner.cs | 9 + .../DuplicateOwnerEmailException.cs | 4 + .../WebApiBestPractices.Domain.csproj | 9 + .../Repositories/InMemoryOwnerRepository.cs | 45 +++ .../WebApiBestPractices.Infrastructure.csproj | 13 + .../OwnersApiTests.cs | 269 ++++++++++++++++++ .../WebApiBestPractices.Tests/ServiceTests.cs | 84 ++++++ .../WebApiBestPractices.Tests.csproj | 27 ++ .../WebApiBestPractices.sln | 90 ++++++ 30 files changed, 1146 insertions(+) create mode 100644 aspnetcore-webapi/WebApiBestPractices/WebApiBestPractices.Api/Controllers/FilesController.cs create mode 100644 aspnetcore-webapi/WebApiBestPractices/WebApiBestPractices.Api/Controllers/OwnersController.cs create mode 100644 aspnetcore-webapi/WebApiBestPractices/WebApiBestPractices.Api/Controllers/OwnersV2Controller.cs create mode 100644 aspnetcore-webapi/WebApiBestPractices/WebApiBestPractices.Api/Extensions/ServiceExtensions.cs create mode 100644 aspnetcore-webapi/WebApiBestPractices/WebApiBestPractices.Api/Filters/ValidateOwnerExistsFilter.cs create mode 100644 aspnetcore-webapi/WebApiBestPractices/WebApiBestPractices.Api/Handlers/GlobalExceptionHandler.cs create mode 100644 aspnetcore-webapi/WebApiBestPractices/WebApiBestPractices.Api/Logging/LogMessages.cs create mode 100644 aspnetcore-webapi/WebApiBestPractices/WebApiBestPractices.Api/Program.cs create mode 100644 aspnetcore-webapi/WebApiBestPractices/WebApiBestPractices.Api/Properties/launchSettings.json create mode 100644 aspnetcore-webapi/WebApiBestPractices/WebApiBestPractices.Api/Security/AccountService.cs create mode 100644 aspnetcore-webapi/WebApiBestPractices/WebApiBestPractices.Api/Security/OwnerIdProtector.cs create mode 100644 aspnetcore-webapi/WebApiBestPractices/WebApiBestPractices.Api/WebApiBestPractices.Api.csproj create mode 100644 aspnetcore-webapi/WebApiBestPractices/WebApiBestPractices.Api/appsettings.Development.json create mode 100644 aspnetcore-webapi/WebApiBestPractices/WebApiBestPractices.Api/appsettings.Production.json create mode 100644 aspnetcore-webapi/WebApiBestPractices/WebApiBestPractices.Api/appsettings.json create mode 100644 aspnetcore-webapi/WebApiBestPractices/WebApiBestPractices.Application/Dtos/OwnerDtos.cs create mode 100644 aspnetcore-webapi/WebApiBestPractices/WebApiBestPractices.Application/Mapping/OwnerMappings.cs create mode 100644 aspnetcore-webapi/WebApiBestPractices/WebApiBestPractices.Application/Services/IOwnerService.cs create mode 100644 aspnetcore-webapi/WebApiBestPractices/WebApiBestPractices.Application/Services/OwnerService.cs create mode 100644 aspnetcore-webapi/WebApiBestPractices/WebApiBestPractices.Application/WebApiBestPractices.Application.csproj create mode 100644 aspnetcore-webapi/WebApiBestPractices/WebApiBestPractices.Domain/Contracts/IOwnerRepository.cs create mode 100644 aspnetcore-webapi/WebApiBestPractices/WebApiBestPractices.Domain/Entities/Owner.cs create mode 100644 aspnetcore-webapi/WebApiBestPractices/WebApiBestPractices.Domain/Exceptions/DuplicateOwnerEmailException.cs create mode 100644 aspnetcore-webapi/WebApiBestPractices/WebApiBestPractices.Domain/WebApiBestPractices.Domain.csproj create mode 100644 aspnetcore-webapi/WebApiBestPractices/WebApiBestPractices.Infrastructure/Repositories/InMemoryOwnerRepository.cs create mode 100644 aspnetcore-webapi/WebApiBestPractices/WebApiBestPractices.Infrastructure/WebApiBestPractices.Infrastructure.csproj create mode 100644 aspnetcore-webapi/WebApiBestPractices/WebApiBestPractices.Tests/OwnersApiTests.cs create mode 100644 aspnetcore-webapi/WebApiBestPractices/WebApiBestPractices.Tests/ServiceTests.cs create mode 100644 aspnetcore-webapi/WebApiBestPractices/WebApiBestPractices.Tests/WebApiBestPractices.Tests.csproj create mode 100644 aspnetcore-webapi/WebApiBestPractices/WebApiBestPractices.sln diff --git a/aspnetcore-webapi/WebApiBestPractices/WebApiBestPractices.Api/Controllers/FilesController.cs b/aspnetcore-webapi/WebApiBestPractices/WebApiBestPractices.Api/Controllers/FilesController.cs new file mode 100644 index 000000000..0d72069c2 --- /dev/null +++ b/aspnetcore-webapi/WebApiBestPractices/WebApiBestPractices.Api/Controllers/FilesController.cs @@ -0,0 +1,28 @@ +using Microsoft.AspNetCore.Mvc; + +namespace WebApiBestPractices.Api.Controllers; + +[ApiController] +[Route("api/files")] +public class FilesController(IConfiguration configuration) : ControllerBase +{ + [HttpPost] + [RequestSizeLimit(10 * 1024 * 1024)] + public async Task Upload(CancellationToken cancellationToken) + { + var formCollection = await Request.ReadFormAsync(cancellationToken); + var file = formCollection.Files.FirstOrDefault(); + if (file is null || file.Length == 0) + return BadRequest("No file was uploaded."); + + var folder = configuration["Uploads:Path"] ?? "Uploads"; + Directory.CreateDirectory(folder); + + // Never trust the client's file name: generate our own. + var fileName = Path.GetRandomFileName(); + await using var stream = System.IO.File.Create(Path.Combine(folder, fileName)); + await file.CopyToAsync(stream, cancellationToken); + + return Ok(new { fileName }); + } +} diff --git a/aspnetcore-webapi/WebApiBestPractices/WebApiBestPractices.Api/Controllers/OwnersController.cs b/aspnetcore-webapi/WebApiBestPractices/WebApiBestPractices.Api/Controllers/OwnersController.cs new file mode 100644 index 000000000..6f52d0b28 --- /dev/null +++ b/aspnetcore-webapi/WebApiBestPractices/WebApiBestPractices.Api/Controllers/OwnersController.cs @@ -0,0 +1,72 @@ +using Asp.Versioning; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.OutputCaching; +using Microsoft.AspNetCore.RateLimiting; +using WebApiBestPractices.Api.Extensions; +using WebApiBestPractices.Api.Filters; +using WebApiBestPractices.Api.Logging; +using WebApiBestPractices.Application.Dtos; +using WebApiBestPractices.Application.Services; + +namespace WebApiBestPractices.Api.Controllers; + +[ApiController] +[ApiVersion("1.0")] +[Route("api/owners")] +[EnableRateLimiting(ServiceExtensions.PerClientRateLimit)] +public class OwnersController( + IOwnerService ownerService, + IOutputCacheStore outputCache, + ILogger logger) : ControllerBase +{ + [HttpGet] + [OutputCache(PolicyName = ServiceExtensions.OwnersCachePolicy)] + public async Task>> GetOwners(CancellationToken cancellationToken) + { + var owners = await ownerService.GetAllAsync(cancellationToken); + logger.OwnersReturned(owners.Count); + + return Ok(owners); + } + + [HttpGet("{id:guid}", Name = "OwnerById")] + public async Task> GetOwnerById(Guid id, CancellationToken cancellationToken) + { + var owner = await ownerService.GetByIdAsync(id, cancellationToken); + + return owner is null ? NotFound() : Ok(owner); + } + + [HttpPost] + public async Task> CreateOwner( + OwnerForCreationDto owner, CancellationToken cancellationToken) + { + var createdOwner = await ownerService.CreateAsync(owner, cancellationToken); + await outputCache.EvictByTagAsync(ServiceExtensions.OwnersCacheTag, cancellationToken); + + return CreatedAtRoute("OwnerById", new { id = createdOwner.Id }, createdOwner); + } + + [HttpPut("{id:guid}")] + [ServiceFilter] + public async Task UpdateOwner( + Guid id, OwnerForUpdateDto owner, CancellationToken cancellationToken) + { + await ownerService.UpdateAsync(id, owner, cancellationToken); + await outputCache.EvictByTagAsync(ServiceExtensions.OwnersCacheTag, cancellationToken); + + return NoContent(); + } + + [Authorize] + [HttpDelete("{id:guid}")] + [ServiceFilter] + public async Task DeleteOwner(Guid id, CancellationToken cancellationToken) + { + await ownerService.DeleteAsync(id, cancellationToken); + await outputCache.EvictByTagAsync(ServiceExtensions.OwnersCacheTag, cancellationToken); + + return NoContent(); + } +} diff --git a/aspnetcore-webapi/WebApiBestPractices/WebApiBestPractices.Api/Controllers/OwnersV2Controller.cs b/aspnetcore-webapi/WebApiBestPractices/WebApiBestPractices.Api/Controllers/OwnersV2Controller.cs new file mode 100644 index 000000000..3472a63f0 --- /dev/null +++ b/aspnetcore-webapi/WebApiBestPractices/WebApiBestPractices.Api/Controllers/OwnersV2Controller.cs @@ -0,0 +1,20 @@ +using Asp.Versioning; +using Microsoft.AspNetCore.Mvc; +using WebApiBestPractices.Application.Services; + +namespace WebApiBestPractices.Api.Controllers; + +[ApiController] +[ApiVersion("2.0")] +[Route("api/owners")] +public class OwnersV2Controller(IOwnerService ownerService) : ControllerBase +{ + // Version 2 returns a lighter list: names only. + [HttpGet] + public async Task>> GetOwners(CancellationToken cancellationToken) + { + var owners = await ownerService.GetAllAsync(cancellationToken); + + return Ok(owners.Select(o => o.Name)); + } +} diff --git a/aspnetcore-webapi/WebApiBestPractices/WebApiBestPractices.Api/Extensions/ServiceExtensions.cs b/aspnetcore-webapi/WebApiBestPractices/WebApiBestPractices.Api/Extensions/ServiceExtensions.cs new file mode 100644 index 000000000..5c5611123 --- /dev/null +++ b/aspnetcore-webapi/WebApiBestPractices/WebApiBestPractices.Api/Extensions/ServiceExtensions.cs @@ -0,0 +1,102 @@ +using System.Text; +using System.Threading.RateLimiting; +using Asp.Versioning; +using Microsoft.AspNetCore.Authentication.JwtBearer; +using Microsoft.AspNetCore.Identity; +using Microsoft.IdentityModel.Tokens; +using WebApiBestPractices.Api.Filters; +using WebApiBestPractices.Api.Security; +using WebApiBestPractices.Application.Services; +using WebApiBestPractices.Domain.Contracts; +using WebApiBestPractices.Infrastructure.Repositories; + +namespace WebApiBestPractices.Api.Extensions; + +public static class ServiceExtensions +{ + public const string CorsPolicy = "CorsPolicy"; + public const string OwnersCachePolicy = "Owners"; + public const string OwnersCacheTag = "owners"; + public const string PerClientRateLimit = "PerClient"; + + public static void ConfigureCors(this IServiceCollection services, IConfiguration configuration) => + services.AddCors(options => + options.AddPolicy(CorsPolicy, policy => policy + .WithOrigins(configuration.GetSection("Cors:AllowedOrigins").Get() ?? []) + .AllowAnyMethod() + .AllowAnyHeader())); + + public static void ConfigureApplicationServices(this IServiceCollection services) + { + services.AddSingleton(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped, PasswordHasher>(); + services.AddScoped(); + services.AddScoped(); + services.AddDataProtection(); + services.AddHybridCache(); + } + + public static void ConfigureVersioning(this IServiceCollection services) => + services.AddApiVersioning(options => + { + options.DefaultApiVersion = new ApiVersion(1, 0); + options.AssumeDefaultVersionWhenUnspecified = true; + options.ReportApiVersions = true; + options.ApiVersionReader = ApiVersionReader.Combine( + new QueryStringApiVersionReader("api-version"), + new HeaderApiVersionReader("api-version")); + }) + .AddMvc(); + + public static void ConfigureOutputCache(this IServiceCollection services) => + services.AddOutputCache(options => + options.AddPolicy(OwnersCachePolicy, policy => policy + .Expire(TimeSpan.FromSeconds(30)) + .SetVaryByHeader("Accept") + .Tag(OwnersCacheTag))); + + public static void ConfigureRateLimiting(this IServiceCollection services, IConfiguration configuration) + { + var permitLimit = configuration.GetValue("RateLimiting:PermitLimit", 100); + + services.AddRateLimiter(options => + { + options.RejectionStatusCode = StatusCodes.Status429TooManyRequests; + options.AddPolicy(PerClientRateLimit, httpContext => + RateLimitPartition.GetFixedWindowLimiter( + partitionKey: httpContext.User.Identity?.Name + ?? httpContext.Connection.RemoteIpAddress?.ToString() + ?? "anonymous", + factory: _ => new FixedWindowRateLimiterOptions + { + PermitLimit = permitLimit, + Window = TimeSpan.FromMinutes(1), + QueueLimit = 0 + })); + }); + } + + public static void ConfigureJwt(this IServiceCollection services, IConfiguration configuration) + { + var jwtSettings = configuration.GetSection("JwtSettings"); + var secretKey = jwtSettings["SecretKey"] + ?? throw new InvalidOperationException("JwtSettings:SecretKey is not configured."); + + services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme) + .AddJwtBearer(options => + { + options.TokenValidationParameters = new TokenValidationParameters + { + ValidateIssuer = true, + ValidateAudience = true, + ValidateLifetime = true, + ValidateIssuerSigningKey = true, + ValidIssuer = jwtSettings["ValidIssuer"], + ValidAudience = jwtSettings["ValidAudience"], + IssuerSigningKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(secretKey)) + }; + }); + } +} diff --git a/aspnetcore-webapi/WebApiBestPractices/WebApiBestPractices.Api/Filters/ValidateOwnerExistsFilter.cs b/aspnetcore-webapi/WebApiBestPractices/WebApiBestPractices.Api/Filters/ValidateOwnerExistsFilter.cs new file mode 100644 index 000000000..562a6c7a3 --- /dev/null +++ b/aspnetcore-webapi/WebApiBestPractices/WebApiBestPractices.Api/Filters/ValidateOwnerExistsFilter.cs @@ -0,0 +1,23 @@ +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc.Filters; +using WebApiBestPractices.Application.Services; + +namespace WebApiBestPractices.Api.Filters; + +public class ValidateOwnerExistsFilter(IOwnerService ownerService) : IAsyncActionFilter +{ + public async Task OnActionExecutionAsync(ActionExecutingContext context, ActionExecutionDelegate next) + { + if (context.ActionArguments.TryGetValue("id", out var value) && value is Guid id) + { + var owner = await ownerService.GetByIdAsync(id, context.HttpContext.RequestAborted); + if (owner is null) + { + context.Result = new NotFoundResult(); + return; + } + } + + await next(); + } +} diff --git a/aspnetcore-webapi/WebApiBestPractices/WebApiBestPractices.Api/Handlers/GlobalExceptionHandler.cs b/aspnetcore-webapi/WebApiBestPractices/WebApiBestPractices.Api/Handlers/GlobalExceptionHandler.cs new file mode 100644 index 000000000..d010715f3 --- /dev/null +++ b/aspnetcore-webapi/WebApiBestPractices/WebApiBestPractices.Api/Handlers/GlobalExceptionHandler.cs @@ -0,0 +1,39 @@ +using Microsoft.AspNetCore.Diagnostics; +using Microsoft.AspNetCore.Mvc; +using WebApiBestPractices.Domain.Exceptions; + +namespace WebApiBestPractices.Api.Handlers; + +public class GlobalExceptionHandler( + IProblemDetailsService problemDetailsService, + ILogger logger) : IExceptionHandler +{ + public async ValueTask TryHandleAsync( + HttpContext httpContext, + Exception exception, + CancellationToken cancellationToken) + { + var (statusCode, title) = exception switch + { + DuplicateOwnerEmailException => (StatusCodes.Status409Conflict, "The owner already exists."), + _ => (StatusCodes.Status500InternalServerError, "An unexpected error occurred.") + }; + + if (statusCode == StatusCodes.Status500InternalServerError) + logger.LogError(exception, "Unhandled exception for {Path}", httpContext.Request.Path); + + httpContext.Response.StatusCode = statusCode; + + return await problemDetailsService.TryWriteAsync(new ProblemDetailsContext + { + HttpContext = httpContext, + Exception = exception, + ProblemDetails = new ProblemDetails + { + Status = statusCode, + Title = title, + Detail = statusCode == StatusCodes.Status409Conflict ? exception.Message : null + } + }); + } +} diff --git a/aspnetcore-webapi/WebApiBestPractices/WebApiBestPractices.Api/Logging/LogMessages.cs b/aspnetcore-webapi/WebApiBestPractices/WebApiBestPractices.Api/Logging/LogMessages.cs new file mode 100644 index 000000000..1437c8cc9 --- /dev/null +++ b/aspnetcore-webapi/WebApiBestPractices/WebApiBestPractices.Api/Logging/LogMessages.cs @@ -0,0 +1,7 @@ +namespace WebApiBestPractices.Api.Logging; + +public static partial class LogMessages +{ + [LoggerMessage(Level = LogLevel.Information, Message = "Returned {Count} owners from the database")] + public static partial void OwnersReturned(this ILogger logger, int count); +} diff --git a/aspnetcore-webapi/WebApiBestPractices/WebApiBestPractices.Api/Program.cs b/aspnetcore-webapi/WebApiBestPractices/WebApiBestPractices.Api/Program.cs new file mode 100644 index 000000000..90f3d99aa --- /dev/null +++ b/aspnetcore-webapi/WebApiBestPractices/WebApiBestPractices.Api/Program.cs @@ -0,0 +1,50 @@ +using WebApiBestPractices.Api.Extensions; +using WebApiBestPractices.Api.Handlers; +using WebApiBestPractices.Application.Services; + +var builder = WebApplication.CreateBuilder(args); + +builder.Services.ConfigureCors(builder.Configuration); +builder.Services.ConfigureApplicationServices(); +builder.Services.ConfigureVersioning(); +builder.Services.ConfigureOutputCache(); +builder.Services.ConfigureRateLimiting(builder.Configuration); +builder.Services.ConfigureJwt(builder.Configuration); +builder.Services.AddAuthorization(); + +builder.Services.AddExceptionHandler(); +builder.Services.AddProblemDetails(); + +builder.Services.AddControllers(options => +{ + options.RespectBrowserAcceptHeader = true; + options.ReturnHttpNotAcceptable = true; +}) +.AddXmlSerializerFormatters(); + +builder.Services.AddOpenApi(); + +var app = builder.Build(); + +app.UseExceptionHandler(); +app.UseStatusCodePages(); + +if (app.Environment.IsDevelopment()) +{ + app.MapOpenApi(); +} + +app.UseHttpsRedirection(); +app.UseCors(ServiceExtensions.CorsPolicy); +app.UseAuthentication(); +app.UseAuthorization(); +app.UseRateLimiter(); +app.UseOutputCache(); + +app.MapControllers(); + +// The same data through a Minimal API endpoint, for comparison. +app.MapGet("/api/minimal/owners", async (IOwnerService ownerService, CancellationToken cancellationToken) => + TypedResults.Ok(await ownerService.GetAllAsync(cancellationToken))); + +app.Run(); diff --git a/aspnetcore-webapi/WebApiBestPractices/WebApiBestPractices.Api/Properties/launchSettings.json b/aspnetcore-webapi/WebApiBestPractices/WebApiBestPractices.Api/Properties/launchSettings.json new file mode 100644 index 000000000..47c46b004 --- /dev/null +++ b/aspnetcore-webapi/WebApiBestPractices/WebApiBestPractices.Api/Properties/launchSettings.json @@ -0,0 +1,14 @@ +{ + "$schema": "https://json.schemastore.org/launchsettings.json", + "profiles": { + "https": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": false, + "applicationUrl": "https://localhost:5001;http://localhost:5000", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + } + } +} diff --git a/aspnetcore-webapi/WebApiBestPractices/WebApiBestPractices.Api/Security/AccountService.cs b/aspnetcore-webapi/WebApiBestPractices/WebApiBestPractices.Api/Security/AccountService.cs new file mode 100644 index 000000000..b7fb50da2 --- /dev/null +++ b/aspnetcore-webapi/WebApiBestPractices/WebApiBestPractices.Api/Security/AccountService.cs @@ -0,0 +1,18 @@ +using Microsoft.AspNetCore.Identity; + +namespace WebApiBestPractices.Api.Security; + +public class AppUser +{ + public required string UserName { get; init; } +} + +public class AccountService(IPasswordHasher passwordHasher) +{ + public string HashPassword(AppUser user, string password) => + passwordHasher.HashPassword(user, password); + + public bool VerifyPassword(AppUser user, string hashedPassword, string password) => + passwordHasher.VerifyHashedPassword(user, hashedPassword, password) + != PasswordVerificationResult.Failed; +} diff --git a/aspnetcore-webapi/WebApiBestPractices/WebApiBestPractices.Api/Security/OwnerIdProtector.cs b/aspnetcore-webapi/WebApiBestPractices/WebApiBestPractices.Api/Security/OwnerIdProtector.cs new file mode 100644 index 000000000..2b2299d9b --- /dev/null +++ b/aspnetcore-webapi/WebApiBestPractices/WebApiBestPractices.Api/Security/OwnerIdProtector.cs @@ -0,0 +1,12 @@ +using Microsoft.AspNetCore.DataProtection; + +namespace WebApiBestPractices.Api.Security; + +public class OwnerIdProtector(IDataProtectionProvider provider) +{ + private readonly IDataProtector _protector = provider.CreateProtector("WebApiBestPractices.OwnerIds"); + + public string Protect(Guid ownerId) => _protector.Protect(ownerId.ToString()); + + public Guid Unprotect(string protectedId) => Guid.Parse(_protector.Unprotect(protectedId)); +} diff --git a/aspnetcore-webapi/WebApiBestPractices/WebApiBestPractices.Api/WebApiBestPractices.Api.csproj b/aspnetcore-webapi/WebApiBestPractices/WebApiBestPractices.Api/WebApiBestPractices.Api.csproj new file mode 100644 index 000000000..3c125e883 --- /dev/null +++ b/aspnetcore-webapi/WebApiBestPractices/WebApiBestPractices.Api/WebApiBestPractices.Api.csproj @@ -0,0 +1,20 @@ + + + + net10.0 + enable + enable + + + + + + + + + + + + + + diff --git a/aspnetcore-webapi/WebApiBestPractices/WebApiBestPractices.Api/appsettings.Development.json b/aspnetcore-webapi/WebApiBestPractices/WebApiBestPractices.Api/appsettings.Development.json new file mode 100644 index 000000000..157e097d5 --- /dev/null +++ b/aspnetcore-webapi/WebApiBestPractices/WebApiBestPractices.Api/appsettings.Development.json @@ -0,0 +1,14 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + }, + "JwtSettings": { + "SecretKey": "development-only-key-replace-me-0123456789abcdef" + }, + "Cors": { + "AllowedOrigins": [ "https://localhost:4200" ] + } +} diff --git a/aspnetcore-webapi/WebApiBestPractices/WebApiBestPractices.Api/appsettings.Production.json b/aspnetcore-webapi/WebApiBestPractices/WebApiBestPractices.Api/appsettings.Production.json new file mode 100644 index 000000000..518d1b036 --- /dev/null +++ b/aspnetcore-webapi/WebApiBestPractices/WebApiBestPractices.Api/appsettings.Production.json @@ -0,0 +1,10 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Warning" + } + }, + "Cors": { + "AllowedOrigins": [ "https://app.example.com" ] + } +} diff --git a/aspnetcore-webapi/WebApiBestPractices/WebApiBestPractices.Api/appsettings.json b/aspnetcore-webapi/WebApiBestPractices/WebApiBestPractices.Api/appsettings.json new file mode 100644 index 000000000..3b421c7b6 --- /dev/null +++ b/aspnetcore-webapi/WebApiBestPractices/WebApiBestPractices.Api/appsettings.json @@ -0,0 +1,19 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + }, + "AllowedHosts": "*", + "Cors": { + "AllowedOrigins": [] + }, + "JwtSettings": { + "ValidIssuer": "WebApiBestPractices", + "ValidAudience": "https://localhost:5001" + }, + "RateLimiting": { + "PermitLimit": 100 + } +} diff --git a/aspnetcore-webapi/WebApiBestPractices/WebApiBestPractices.Application/Dtos/OwnerDtos.cs b/aspnetcore-webapi/WebApiBestPractices/WebApiBestPractices.Application/Dtos/OwnerDtos.cs new file mode 100644 index 000000000..a890ae46a --- /dev/null +++ b/aspnetcore-webapi/WebApiBestPractices/WebApiBestPractices.Application/Dtos/OwnerDtos.cs @@ -0,0 +1,32 @@ +using System.ComponentModel.DataAnnotations; + +namespace WebApiBestPractices.Application.Dtos; + +public record OwnerDto +{ + public Guid Id { get; init; } + public string Name { get; init; } = string.Empty; + public string Email { get; init; } = string.Empty; + public string? Address { get; init; } +} + +public record OwnerForCreationDto +{ + [Required, MaxLength(60)] + public string Name { get; init; } = string.Empty; + + [Required, EmailAddress] + public string Email { get; init; } = string.Empty; + + [MaxLength(100)] + public string? Address { get; init; } +} + +public record OwnerForUpdateDto +{ + [Required, MaxLength(60)] + public string Name { get; init; } = string.Empty; + + [MaxLength(100)] + public string? Address { get; init; } +} diff --git a/aspnetcore-webapi/WebApiBestPractices/WebApiBestPractices.Application/Mapping/OwnerMappings.cs b/aspnetcore-webapi/WebApiBestPractices/WebApiBestPractices.Application/Mapping/OwnerMappings.cs new file mode 100644 index 000000000..c193f096c --- /dev/null +++ b/aspnetcore-webapi/WebApiBestPractices/WebApiBestPractices.Application/Mapping/OwnerMappings.cs @@ -0,0 +1,23 @@ +using WebApiBestPractices.Application.Dtos; +using WebApiBestPractices.Domain.Entities; + +namespace WebApiBestPractices.Application.Mapping; + +public static class OwnerMappings +{ + public static OwnerDto ToDto(this Owner owner) => new() + { + Id = owner.Id, + Name = owner.Name, + Email = owner.Email, + Address = owner.Address + }; + + public static Owner ToEntity(this OwnerForCreationDto dto) => new() + { + Id = Guid.NewGuid(), + Name = dto.Name, + Email = dto.Email, + Address = dto.Address + }; +} diff --git a/aspnetcore-webapi/WebApiBestPractices/WebApiBestPractices.Application/Services/IOwnerService.cs b/aspnetcore-webapi/WebApiBestPractices/WebApiBestPractices.Application/Services/IOwnerService.cs new file mode 100644 index 000000000..28c218ecc --- /dev/null +++ b/aspnetcore-webapi/WebApiBestPractices/WebApiBestPractices.Application/Services/IOwnerService.cs @@ -0,0 +1,12 @@ +using WebApiBestPractices.Application.Dtos; + +namespace WebApiBestPractices.Application.Services; + +public interface IOwnerService +{ + Task> GetAllAsync(CancellationToken cancellationToken); + Task GetByIdAsync(Guid id, CancellationToken cancellationToken); + Task CreateAsync(OwnerForCreationDto owner, CancellationToken cancellationToken); + Task UpdateAsync(Guid id, OwnerForUpdateDto owner, CancellationToken cancellationToken); + Task DeleteAsync(Guid id, CancellationToken cancellationToken); +} diff --git a/aspnetcore-webapi/WebApiBestPractices/WebApiBestPractices.Application/Services/OwnerService.cs b/aspnetcore-webapi/WebApiBestPractices/WebApiBestPractices.Application/Services/OwnerService.cs new file mode 100644 index 000000000..596f2c7ab --- /dev/null +++ b/aspnetcore-webapi/WebApiBestPractices/WebApiBestPractices.Application/Services/OwnerService.cs @@ -0,0 +1,51 @@ +using Microsoft.Extensions.Caching.Hybrid; +using WebApiBestPractices.Application.Dtos; +using WebApiBestPractices.Application.Mapping; +using WebApiBestPractices.Domain.Contracts; +using WebApiBestPractices.Domain.Exceptions; + +namespace WebApiBestPractices.Application.Services; + +public class OwnerService(IOwnerRepository repository, HybridCache cache) : IOwnerService +{ + public async Task> GetAllAsync(CancellationToken cancellationToken) + { + var owners = await repository.GetAllAsync(cancellationToken); + + return owners.Select(o => o.ToDto()).ToList(); + } + + public async Task GetByIdAsync(Guid id, CancellationToken cancellationToken) => + await cache.GetOrCreateAsync( + $"owner-{id}", + async token => (await repository.GetByIdAsync(id, token))?.ToDto(), + cancellationToken: cancellationToken); + + public async Task CreateAsync(OwnerForCreationDto owner, CancellationToken cancellationToken) + { + if (await repository.EmailExistsAsync(owner.Email, cancellationToken)) + throw new DuplicateOwnerEmailException(owner.Email); + + var entity = owner.ToEntity(); + await repository.AddAsync(entity, cancellationToken); + + return entity.ToDto(); + } + + public async Task UpdateAsync(Guid id, OwnerForUpdateDto owner, CancellationToken cancellationToken) + { + var entity = await repository.GetByIdAsync(id, cancellationToken) + ?? throw new KeyNotFoundException($"Owner {id} was not found."); + + entity.Name = owner.Name; + entity.Address = owner.Address; + await repository.UpdateAsync(entity, cancellationToken); + await cache.RemoveAsync($"owner-{id}", cancellationToken); + } + + public async Task DeleteAsync(Guid id, CancellationToken cancellationToken) + { + await repository.DeleteAsync(id, cancellationToken); + await cache.RemoveAsync($"owner-{id}", cancellationToken); + } +} diff --git a/aspnetcore-webapi/WebApiBestPractices/WebApiBestPractices.Application/WebApiBestPractices.Application.csproj b/aspnetcore-webapi/WebApiBestPractices/WebApiBestPractices.Application/WebApiBestPractices.Application.csproj new file mode 100644 index 000000000..5f813672d --- /dev/null +++ b/aspnetcore-webapi/WebApiBestPractices/WebApiBestPractices.Application/WebApiBestPractices.Application.csproj @@ -0,0 +1,17 @@ + + + + net10.0 + enable + enable + + + + + + + + + + + diff --git a/aspnetcore-webapi/WebApiBestPractices/WebApiBestPractices.Domain/Contracts/IOwnerRepository.cs b/aspnetcore-webapi/WebApiBestPractices/WebApiBestPractices.Domain/Contracts/IOwnerRepository.cs new file mode 100644 index 000000000..cbe99926a --- /dev/null +++ b/aspnetcore-webapi/WebApiBestPractices/WebApiBestPractices.Domain/Contracts/IOwnerRepository.cs @@ -0,0 +1,13 @@ +using WebApiBestPractices.Domain.Entities; + +namespace WebApiBestPractices.Domain.Contracts; + +public interface IOwnerRepository +{ + Task> GetAllAsync(CancellationToken cancellationToken); + Task GetByIdAsync(Guid id, CancellationToken cancellationToken); + Task EmailExistsAsync(string email, CancellationToken cancellationToken); + Task AddAsync(Owner owner, CancellationToken cancellationToken); + Task UpdateAsync(Owner owner, CancellationToken cancellationToken); + Task DeleteAsync(Guid id, CancellationToken cancellationToken); +} diff --git a/aspnetcore-webapi/WebApiBestPractices/WebApiBestPractices.Domain/Entities/Owner.cs b/aspnetcore-webapi/WebApiBestPractices/WebApiBestPractices.Domain/Entities/Owner.cs new file mode 100644 index 000000000..96d779d85 --- /dev/null +++ b/aspnetcore-webapi/WebApiBestPractices/WebApiBestPractices.Domain/Entities/Owner.cs @@ -0,0 +1,9 @@ +namespace WebApiBestPractices.Domain.Entities; + +public class Owner +{ + public Guid Id { get; set; } + public required string Name { get; set; } + public required string Email { get; set; } + public string? Address { get; set; } +} diff --git a/aspnetcore-webapi/WebApiBestPractices/WebApiBestPractices.Domain/Exceptions/DuplicateOwnerEmailException.cs b/aspnetcore-webapi/WebApiBestPractices/WebApiBestPractices.Domain/Exceptions/DuplicateOwnerEmailException.cs new file mode 100644 index 000000000..e0cb1a6cc --- /dev/null +++ b/aspnetcore-webapi/WebApiBestPractices/WebApiBestPractices.Domain/Exceptions/DuplicateOwnerEmailException.cs @@ -0,0 +1,4 @@ +namespace WebApiBestPractices.Domain.Exceptions; + +public class DuplicateOwnerEmailException(string email) + : Exception($"An owner with the email '{email}' already exists."); diff --git a/aspnetcore-webapi/WebApiBestPractices/WebApiBestPractices.Domain/WebApiBestPractices.Domain.csproj b/aspnetcore-webapi/WebApiBestPractices/WebApiBestPractices.Domain/WebApiBestPractices.Domain.csproj new file mode 100644 index 000000000..9ed914b5b --- /dev/null +++ b/aspnetcore-webapi/WebApiBestPractices/WebApiBestPractices.Domain/WebApiBestPractices.Domain.csproj @@ -0,0 +1,9 @@ + + + + net10.0 + enable + enable + + + diff --git a/aspnetcore-webapi/WebApiBestPractices/WebApiBestPractices.Infrastructure/Repositories/InMemoryOwnerRepository.cs b/aspnetcore-webapi/WebApiBestPractices/WebApiBestPractices.Infrastructure/Repositories/InMemoryOwnerRepository.cs new file mode 100644 index 000000000..1368a0c61 --- /dev/null +++ b/aspnetcore-webapi/WebApiBestPractices/WebApiBestPractices.Infrastructure/Repositories/InMemoryOwnerRepository.cs @@ -0,0 +1,45 @@ +using System.Collections.Concurrent; +using WebApiBestPractices.Domain.Contracts; +using WebApiBestPractices.Domain.Entities; + +namespace WebApiBestPractices.Infrastructure.Repositories; + +// Stands in for a database so the sample runs with no setup. +public class InMemoryOwnerRepository : IOwnerRepository +{ + public static readonly Guid JohnId = Guid.Parse("3f2b8c1e-6a4d-4e5f-9b7a-1c2d3e4f5a6b"); + public static readonly Guid AnnaId = Guid.Parse("8d9e0f1a-2b3c-4d5e-8f90-a1b2c3d4e5f6"); + + private readonly ConcurrentDictionary _owners = new() + { + [JohnId] = new Owner { Id = JohnId, Name = "John Keen", Email = "john@example.com", Address = "61 Wellfield Road" }, + [AnnaId] = new Owner { Id = AnnaId, Name = "Anna Bosh", Email = "anna@example.com", Address = "27 Colored Row" } + }; + + public Task> GetAllAsync(CancellationToken cancellationToken) => + Task.FromResult>(_owners.Values.OrderBy(o => o.Name).ToList()); + + public Task GetByIdAsync(Guid id, CancellationToken cancellationToken) => + Task.FromResult(_owners.GetValueOrDefault(id)); + + public Task EmailExistsAsync(string email, CancellationToken cancellationToken) => + Task.FromResult(_owners.Values.Any(o => string.Equals(o.Email, email, StringComparison.OrdinalIgnoreCase))); + + public Task AddAsync(Owner owner, CancellationToken cancellationToken) + { + _owners[owner.Id] = owner; + return Task.CompletedTask; + } + + public Task UpdateAsync(Owner owner, CancellationToken cancellationToken) + { + _owners[owner.Id] = owner; + return Task.CompletedTask; + } + + public Task DeleteAsync(Guid id, CancellationToken cancellationToken) + { + _owners.TryRemove(id, out _); + return Task.CompletedTask; + } +} diff --git a/aspnetcore-webapi/WebApiBestPractices/WebApiBestPractices.Infrastructure/WebApiBestPractices.Infrastructure.csproj b/aspnetcore-webapi/WebApiBestPractices/WebApiBestPractices.Infrastructure/WebApiBestPractices.Infrastructure.csproj new file mode 100644 index 000000000..c0daa3cbf --- /dev/null +++ b/aspnetcore-webapi/WebApiBestPractices/WebApiBestPractices.Infrastructure/WebApiBestPractices.Infrastructure.csproj @@ -0,0 +1,13 @@ + + + + net10.0 + enable + enable + + + + + + + diff --git a/aspnetcore-webapi/WebApiBestPractices/WebApiBestPractices.Tests/OwnersApiTests.cs b/aspnetcore-webapi/WebApiBestPractices/WebApiBestPractices.Tests/OwnersApiTests.cs new file mode 100644 index 000000000..f91463f54 --- /dev/null +++ b/aspnetcore-webapi/WebApiBestPractices/WebApiBestPractices.Tests/OwnersApiTests.cs @@ -0,0 +1,269 @@ +using System.IdentityModel.Tokens.Jwt; +using System.Net; +using System.Net.Http.Headers; +using System.Net.Http.Json; +using System.Text; +using Microsoft.AspNetCore.Hosting; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc.Testing; +using Microsoft.AspNetCore.TestHost; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.IdentityModel.Tokens; +using WebApiBestPractices.Application.Dtos; +using WebApiBestPractices.Domain.Contracts; +using WebApiBestPractices.Domain.Entities; +using WebApiBestPractices.Infrastructure.Repositories; + +namespace WebApiBestPractices.Tests; + +public class OwnersApiTests +{ + private static CancellationToken Ct => TestContext.Current.CancellationToken; + + [Fact] + public async Task GetOwners_ReturnsSeededOwners_AndReportsApiVersions() + { + await using var factory = new WebApplicationFactory(); + var client = factory.CreateClient(); + + var response = await client.GetAsync("/api/owners", Ct); + var owners = await response.Content.ReadFromJsonAsync>(Ct); + + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + Assert.Equal(2, owners!.Count); + Assert.Contains("1.0", response.Headers.GetValues("api-supported-versions").Single()); + } + + [Fact] + public async Task GetOwners_WithApiVersion2_ReturnsNamesOnly() + { + await using var factory = new WebApplicationFactory(); + var client = factory.CreateClient(); + + var names = await client.GetFromJsonAsync>("/api/owners?api-version=2.0", Ct); + + Assert.Equal(["Anna Bosh", "John Keen"], names); + } + + [Fact] + public async Task GetOwnerById_UnknownId_Returns404ProblemDetails() + { + await using var factory = new WebApplicationFactory(); + var client = factory.CreateClient(); + + var response = await client.GetAsync($"/api/owners/{Guid.NewGuid()}", Ct); + + Assert.Equal(HttpStatusCode.NotFound, response.StatusCode); + Assert.Equal("application/problem+json", response.Content.Headers.ContentType?.MediaType); + } + + [Fact] + public async Task CreateOwner_InvalidBody_Returns400ValidationProblem() + { + await using var factory = new WebApplicationFactory(); + var client = factory.CreateClient(); + + var response = await client.PostAsJsonAsync("/api/owners", new { Name = "", Email = "not-an-email" }, Ct); + var problem = await response.Content.ReadFromJsonAsync(Ct); + + Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); + Assert.Contains("Email", problem!.Errors.Keys); + } + + [Fact] + public async Task CreateOwner_DuplicateEmail_Returns409FromExceptionHandler() + { + await using var factory = new WebApplicationFactory(); + var client = factory.CreateClient(); + + var response = await client.PostAsJsonAsync("/api/owners", + new OwnerForCreationDto { Name = "John Again", Email = "john@example.com" }, Ct); + var problem = await response.Content.ReadFromJsonAsync(Ct); + + Assert.Equal(HttpStatusCode.Conflict, response.StatusCode); + Assert.Equal("The owner already exists.", problem!.Title); + } + + [Fact] + public async Task UnhandledException_Returns500ProblemDetails_WithoutExceptionMessage() + { + await using var factory = new WebApplicationFactory().WithWebHostBuilder(builder => + { + builder.UseEnvironment("Production"); + builder.UseSetting("JwtSettings:SecretKey", "test-only-key-0123456789abcdef0123456789"); + builder.ConfigureTestServices(services => + services.AddSingleton()); + }); + var client = factory.CreateClient(); + + var response = await client.GetAsync("/api/owners", Ct); + var body = await response.Content.ReadAsStringAsync(Ct); + + Assert.Equal(HttpStatusCode.InternalServerError, response.StatusCode); + Assert.Equal("application/problem+json", response.Content.Headers.ContentType?.MediaType); + Assert.DoesNotContain("database is on fire", body); + } + + [Fact] + public async Task DeleteOwner_RequiresAValidJwt() + { + await using var factory = new WebApplicationFactory(); + var client = factory.CreateClient(); + var url = $"/api/owners/{InMemoryOwnerRepository.AnnaId}"; + + var anonymous = await client.DeleteAsync(url, Ct); + + client.DefaultRequestHeaders.Authorization = + new AuthenticationHeaderValue("Bearer", CreateToken(factory.Services.GetRequiredService())); + var authenticated = await client.DeleteAsync(url, Ct); + + Assert.Equal(HttpStatusCode.Unauthorized, anonymous.StatusCode); + Assert.Equal(HttpStatusCode.NoContent, authenticated.StatusCode); + } + + [Fact] + public async Task UpdateOwner_UnknownId_Returns404FromActionFilter() + { + await using var factory = new WebApplicationFactory(); + var client = factory.CreateClient(); + + var response = await client.PutAsJsonAsync($"/api/owners/{Guid.NewGuid()}", + new OwnerForUpdateDto { Name = "Nobody" }, Ct); + + Assert.Equal(HttpStatusCode.NotFound, response.StatusCode); + } + + [Fact] + public async Task ContentNegotiation_ReturnsXml_AndRejectsUnknownFormats() + { + await using var factory = new WebApplicationFactory(); + var client = factory.CreateClient(); + + var xmlRequest = new HttpRequestMessage(HttpMethod.Get, "/api/owners"); + xmlRequest.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue("application/xml")); + var xml = await client.SendAsync(xmlRequest, Ct); + + var csvRequest = new HttpRequestMessage(HttpMethod.Get, $"/api/owners/{InMemoryOwnerRepository.JohnId}"); + csvRequest.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue("text/csv")); + var csv = await client.SendAsync(csvRequest, Ct); + + Assert.Equal("application/xml", xml.Content.Headers.ContentType?.MediaType); + Assert.Contains("", await xml.Content.ReadAsStringAsync(Ct)); + Assert.Equal(HttpStatusCode.NotAcceptable, csv.StatusCode); + } + + [Fact] + public async Task RateLimiter_Returns429_WhenThePermitLimitIsUsedUp() + { + await using var factory = new WebApplicationFactory() + .WithWebHostBuilder(builder => builder.UseSetting("RateLimiting:PermitLimit", "2")); + var client = factory.CreateClient(); + var url = $"/api/owners/{InMemoryOwnerRepository.JohnId}"; + + var first = await client.GetAsync(url, Ct); + var second = await client.GetAsync(url, Ct); + var third = await client.GetAsync(url, Ct); + + Assert.Equal(HttpStatusCode.OK, first.StatusCode); + Assert.Equal(HttpStatusCode.OK, second.StatusCode); + Assert.Equal(HttpStatusCode.TooManyRequests, third.StatusCode); + } + + [Fact] + public async Task OutputCache_IsEvicted_WhenAnOwnerIsCreated() + { + await using var factory = new WebApplicationFactory(); + var client = factory.CreateClient(); + + var before = await client.GetFromJsonAsync>("/api/owners", Ct); + await client.PostAsJsonAsync("/api/owners", + new OwnerForCreationDto { Name = "Mia Stone", Email = "mia@example.com" }, Ct); + var after = await client.GetFromJsonAsync>("/api/owners", Ct); + + Assert.Equal(2, before!.Count); + Assert.Equal(3, after!.Count); + } + + [Fact] + public async Task OutputCache_KeepsJsonAndXmlApart() + { + await using var factory = new WebApplicationFactory(); + var client = factory.CreateClient(); + + var xmlRequest = new HttpRequestMessage(HttpMethod.Get, "/api/owners"); + xmlRequest.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue("application/xml")); + var xml = await client.SendAsync(xmlRequest, Ct); + var json = await client.GetAsync("/api/owners", Ct); + + Assert.Equal("application/xml", xml.Content.Headers.ContentType?.MediaType); + Assert.Equal("application/json", json.Content.Headers.ContentType?.MediaType); + } + + [Fact] + public async Task OpenApiDocument_IsServedInDevelopment_AsOpenApi31() + { + await using var factory = new WebApplicationFactory(); + var client = factory.CreateClient(); + + var response = await client.GetAsync("/openapi/v1.json", Ct); + var document = await response.Content.ReadAsStringAsync(Ct); + + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + Assert.Contains("\"openapi\": \"3.1", document); + Assert.Contains("/api/owners", document); + } + + [Fact] + public async Task MinimalApiEndpoint_ReturnsTheSameOwners() + { + await using var factory = new WebApplicationFactory(); + var client = factory.CreateClient(); + + var owners = await client.GetFromJsonAsync>("/api/minimal/owners", Ct); + + Assert.Equal(2, owners!.Count); + } + + [Fact] + public async Task Upload_ReadsTheFormAsynchronously_AndStoresTheFile() + { + var uploadFolder = Path.Combine(AppContext.BaseDirectory, "uploads-test"); + await using var factory = new WebApplicationFactory() + .WithWebHostBuilder(builder => builder.UseSetting("Uploads:Path", uploadFolder)); + var client = factory.CreateClient(); + + using var content = new MultipartFormDataContent(); + content.Add(new ByteArrayContent(Encoding.UTF8.GetBytes("hello")), "file", "hello.txt"); + var response = await client.PostAsync("/api/files", content, Ct); + + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + Assert.Single(Directory.GetFiles(uploadFolder)); + Directory.Delete(uploadFolder, recursive: true); + } + + private static string CreateToken(IConfiguration configuration) + { + var settings = configuration.GetSection("JwtSettings"); + var key = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(settings["SecretKey"]!)); + var token = new JwtSecurityToken( + issuer: settings["ValidIssuer"], + audience: settings["ValidAudience"], + claims: [new System.Security.Claims.Claim("name", "test-user")], + expires: DateTime.UtcNow.AddMinutes(5), + signingCredentials: new SigningCredentials(key, SecurityAlgorithms.HmacSha256)); + + return new JwtSecurityTokenHandler().WriteToken(token); + } + + private sealed class ThrowingOwnerRepository : IOwnerRepository + { + public Task> GetAllAsync(CancellationToken cancellationToken) => + throw new InvalidOperationException("The database is on fire."); + public Task GetByIdAsync(Guid id, CancellationToken cancellationToken) => throw new NotImplementedException(); + public Task EmailExistsAsync(string email, CancellationToken cancellationToken) => throw new NotImplementedException(); + public Task AddAsync(Owner owner, CancellationToken cancellationToken) => throw new NotImplementedException(); + public Task UpdateAsync(Owner owner, CancellationToken cancellationToken) => throw new NotImplementedException(); + public Task DeleteAsync(Guid id, CancellationToken cancellationToken) => throw new NotImplementedException(); + } +} diff --git a/aspnetcore-webapi/WebApiBestPractices/WebApiBestPractices.Tests/ServiceTests.cs b/aspnetcore-webapi/WebApiBestPractices/WebApiBestPractices.Tests/ServiceTests.cs new file mode 100644 index 000000000..247fa0708 --- /dev/null +++ b/aspnetcore-webapi/WebApiBestPractices/WebApiBestPractices.Tests/ServiceTests.cs @@ -0,0 +1,84 @@ +using Microsoft.AspNetCore.DataProtection; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Identity; +using Microsoft.AspNetCore.RateLimiting; +using Microsoft.Extensions.Caching.Hybrid; +using Microsoft.Extensions.DependencyInjection; +using WebApiBestPractices.Api.Security; +using WebApiBestPractices.Application.Services; +using WebApiBestPractices.Domain.Contracts; +using WebApiBestPractices.Domain.Entities; +using WebApiBestPractices.Infrastructure.Repositories; + +namespace WebApiBestPractices.Tests; + +public class ServiceTests +{ + private static CancellationToken Ct => TestContext.Current.CancellationToken; + + [Fact] + public async Task HybridCache_CallsTheRepositoryOnce_ForRepeatedReads() + { + var repository = new CountingOwnerRepository(); + var services = new ServiceCollection(); + services.AddHybridCache(); + await using var provider = services.BuildServiceProvider(); + var ownerService = new OwnerService(repository, provider.GetRequiredService()); + + await ownerService.GetByIdAsync(InMemoryOwnerRepository.JohnId, Ct); + var owner = await ownerService.GetByIdAsync(InMemoryOwnerRepository.JohnId, Ct); + + Assert.Equal("John Keen", owner!.Name); + Assert.Equal(1, repository.GetByIdCalls); + } + + [Fact] + public void PasswordHasher_VerifiesTheRightPassword_AndRejectsTheWrongOne() + { + var accountService = new AccountService(new PasswordHasher()); + var user = new AppUser { UserName = "john" }; + + var hash = accountService.HashPassword(user, "Correct-Horse-1"); + + Assert.NotEqual("Correct-Horse-1", hash); + Assert.True(accountService.VerifyPassword(user, hash, "Correct-Horse-1")); + Assert.False(accountService.VerifyPassword(user, hash, "wrong")); + } + + [Fact] + public void DataProtection_RoundTripsAProtectedValue() + { + var services = new ServiceCollection(); + services.AddDataProtection(); + using var provider = services.BuildServiceProvider(); + var protector = new OwnerIdProtector(provider.GetRequiredService()); + + var protectedId = protector.Protect(InMemoryOwnerRepository.JohnId); + + Assert.NotEqual(InMemoryOwnerRepository.JohnId.ToString(), protectedId); + Assert.Equal(InMemoryOwnerRepository.JohnId, protector.Unprotect(protectedId)); + } + + [Fact] + public void RateLimiterOptions_DefaultRejectionStatusCode_Is503() + { + Assert.Equal(StatusCodes.Status503ServiceUnavailable, new RateLimiterOptions().RejectionStatusCode); + } + + private sealed class CountingOwnerRepository : IOwnerRepository + { + private readonly InMemoryOwnerRepository _inner = new(); + public int GetByIdCalls { get; private set; } + + public Task> GetAllAsync(CancellationToken cancellationToken) => _inner.GetAllAsync(cancellationToken); + public Task GetByIdAsync(Guid id, CancellationToken cancellationToken) + { + GetByIdCalls++; + return _inner.GetByIdAsync(id, cancellationToken); + } + public Task EmailExistsAsync(string email, CancellationToken cancellationToken) => _inner.EmailExistsAsync(email, cancellationToken); + public Task AddAsync(Owner owner, CancellationToken cancellationToken) => _inner.AddAsync(owner, cancellationToken); + public Task UpdateAsync(Owner owner, CancellationToken cancellationToken) => _inner.UpdateAsync(owner, cancellationToken); + public Task DeleteAsync(Guid id, CancellationToken cancellationToken) => _inner.DeleteAsync(id, cancellationToken); + } +} diff --git a/aspnetcore-webapi/WebApiBestPractices/WebApiBestPractices.Tests/WebApiBestPractices.Tests.csproj b/aspnetcore-webapi/WebApiBestPractices/WebApiBestPractices.Tests/WebApiBestPractices.Tests.csproj new file mode 100644 index 000000000..b7c3cdf02 --- /dev/null +++ b/aspnetcore-webapi/WebApiBestPractices/WebApiBestPractices.Tests/WebApiBestPractices.Tests.csproj @@ -0,0 +1,27 @@ + + + + net10.0 + enable + enable + false + true + + + + + + + + + + + + + + + + + + + diff --git a/aspnetcore-webapi/WebApiBestPractices/WebApiBestPractices.sln b/aspnetcore-webapi/WebApiBestPractices/WebApiBestPractices.sln new file mode 100644 index 000000000..e61aa1f3b --- /dev/null +++ b/aspnetcore-webapi/WebApiBestPractices/WebApiBestPractices.sln @@ -0,0 +1,90 @@ + +Microsoft Visual Studio Solution File, Format Version 12.00 +# Visual Studio Version 17 +VisualStudioVersion = 17.0.31903.59 +MinimumVisualStudioVersion = 10.0.40219.1 +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "WebApiBestPractices.Domain", "WebApiBestPractices.Domain\WebApiBestPractices.Domain.csproj", "{354F6B71-C12E-457B-B829-46AF4D93331C}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "WebApiBestPractices.Application", "WebApiBestPractices.Application\WebApiBestPractices.Application.csproj", "{59B50705-6D38-496A-BE5E-9FA5A9AE2A3A}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "WebApiBestPractices.Infrastructure", "WebApiBestPractices.Infrastructure\WebApiBestPractices.Infrastructure.csproj", "{21B7F3F2-129B-4887-A90C-6783118737A0}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "WebApiBestPractices.Api", "WebApiBestPractices.Api\WebApiBestPractices.Api.csproj", "{DED8CA42-71D5-40C6-902D-5904E85AA248}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "WebApiBestPractices.Tests", "WebApiBestPractices.Tests\WebApiBestPractices.Tests.csproj", "{D3F67A98-B3A7-4995-9F53-BF6D1B925B20}" +EndProject +Global + GlobalSection(SolutionConfigurationPlatforms) = preSolution + Debug|Any CPU = Debug|Any CPU + Debug|x64 = Debug|x64 + Debug|x86 = Debug|x86 + Release|Any CPU = Release|Any CPU + Release|x64 = Release|x64 + Release|x86 = Release|x86 + EndGlobalSection + GlobalSection(ProjectConfigurationPlatforms) = postSolution + {354F6B71-C12E-457B-B829-46AF4D93331C}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {354F6B71-C12E-457B-B829-46AF4D93331C}.Debug|Any CPU.Build.0 = Debug|Any CPU + {354F6B71-C12E-457B-B829-46AF4D93331C}.Debug|x64.ActiveCfg = Debug|Any CPU + {354F6B71-C12E-457B-B829-46AF4D93331C}.Debug|x64.Build.0 = Debug|Any CPU + {354F6B71-C12E-457B-B829-46AF4D93331C}.Debug|x86.ActiveCfg = Debug|Any CPU + {354F6B71-C12E-457B-B829-46AF4D93331C}.Debug|x86.Build.0 = Debug|Any CPU + {354F6B71-C12E-457B-B829-46AF4D93331C}.Release|Any CPU.ActiveCfg = Release|Any CPU + {354F6B71-C12E-457B-B829-46AF4D93331C}.Release|Any CPU.Build.0 = Release|Any CPU + {354F6B71-C12E-457B-B829-46AF4D93331C}.Release|x64.ActiveCfg = Release|Any CPU + {354F6B71-C12E-457B-B829-46AF4D93331C}.Release|x64.Build.0 = Release|Any CPU + {354F6B71-C12E-457B-B829-46AF4D93331C}.Release|x86.ActiveCfg = Release|Any CPU + {354F6B71-C12E-457B-B829-46AF4D93331C}.Release|x86.Build.0 = Release|Any CPU + {59B50705-6D38-496A-BE5E-9FA5A9AE2A3A}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {59B50705-6D38-496A-BE5E-9FA5A9AE2A3A}.Debug|Any CPU.Build.0 = Debug|Any CPU + {59B50705-6D38-496A-BE5E-9FA5A9AE2A3A}.Debug|x64.ActiveCfg = Debug|Any CPU + {59B50705-6D38-496A-BE5E-9FA5A9AE2A3A}.Debug|x64.Build.0 = Debug|Any CPU + {59B50705-6D38-496A-BE5E-9FA5A9AE2A3A}.Debug|x86.ActiveCfg = Debug|Any CPU + {59B50705-6D38-496A-BE5E-9FA5A9AE2A3A}.Debug|x86.Build.0 = Debug|Any CPU + {59B50705-6D38-496A-BE5E-9FA5A9AE2A3A}.Release|Any CPU.ActiveCfg = Release|Any CPU + {59B50705-6D38-496A-BE5E-9FA5A9AE2A3A}.Release|Any CPU.Build.0 = Release|Any CPU + {59B50705-6D38-496A-BE5E-9FA5A9AE2A3A}.Release|x64.ActiveCfg = Release|Any CPU + {59B50705-6D38-496A-BE5E-9FA5A9AE2A3A}.Release|x64.Build.0 = Release|Any CPU + {59B50705-6D38-496A-BE5E-9FA5A9AE2A3A}.Release|x86.ActiveCfg = Release|Any CPU + {59B50705-6D38-496A-BE5E-9FA5A9AE2A3A}.Release|x86.Build.0 = Release|Any CPU + {21B7F3F2-129B-4887-A90C-6783118737A0}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {21B7F3F2-129B-4887-A90C-6783118737A0}.Debug|Any CPU.Build.0 = Debug|Any CPU + {21B7F3F2-129B-4887-A90C-6783118737A0}.Debug|x64.ActiveCfg = Debug|Any CPU + {21B7F3F2-129B-4887-A90C-6783118737A0}.Debug|x64.Build.0 = Debug|Any CPU + {21B7F3F2-129B-4887-A90C-6783118737A0}.Debug|x86.ActiveCfg = Debug|Any CPU + {21B7F3F2-129B-4887-A90C-6783118737A0}.Debug|x86.Build.0 = Debug|Any CPU + {21B7F3F2-129B-4887-A90C-6783118737A0}.Release|Any CPU.ActiveCfg = Release|Any CPU + {21B7F3F2-129B-4887-A90C-6783118737A0}.Release|Any CPU.Build.0 = Release|Any CPU + {21B7F3F2-129B-4887-A90C-6783118737A0}.Release|x64.ActiveCfg = Release|Any CPU + {21B7F3F2-129B-4887-A90C-6783118737A0}.Release|x64.Build.0 = Release|Any CPU + {21B7F3F2-129B-4887-A90C-6783118737A0}.Release|x86.ActiveCfg = Release|Any CPU + {21B7F3F2-129B-4887-A90C-6783118737A0}.Release|x86.Build.0 = Release|Any CPU + {DED8CA42-71D5-40C6-902D-5904E85AA248}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {DED8CA42-71D5-40C6-902D-5904E85AA248}.Debug|Any CPU.Build.0 = Debug|Any CPU + {DED8CA42-71D5-40C6-902D-5904E85AA248}.Debug|x64.ActiveCfg = Debug|Any CPU + {DED8CA42-71D5-40C6-902D-5904E85AA248}.Debug|x64.Build.0 = Debug|Any CPU + {DED8CA42-71D5-40C6-902D-5904E85AA248}.Debug|x86.ActiveCfg = Debug|Any CPU + {DED8CA42-71D5-40C6-902D-5904E85AA248}.Debug|x86.Build.0 = Debug|Any CPU + {DED8CA42-71D5-40C6-902D-5904E85AA248}.Release|Any CPU.ActiveCfg = Release|Any CPU + {DED8CA42-71D5-40C6-902D-5904E85AA248}.Release|Any CPU.Build.0 = Release|Any CPU + {DED8CA42-71D5-40C6-902D-5904E85AA248}.Release|x64.ActiveCfg = Release|Any CPU + {DED8CA42-71D5-40C6-902D-5904E85AA248}.Release|x64.Build.0 = Release|Any CPU + {DED8CA42-71D5-40C6-902D-5904E85AA248}.Release|x86.ActiveCfg = Release|Any CPU + {DED8CA42-71D5-40C6-902D-5904E85AA248}.Release|x86.Build.0 = Release|Any CPU + {D3F67A98-B3A7-4995-9F53-BF6D1B925B20}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {D3F67A98-B3A7-4995-9F53-BF6D1B925B20}.Debug|Any CPU.Build.0 = Debug|Any CPU + {D3F67A98-B3A7-4995-9F53-BF6D1B925B20}.Debug|x64.ActiveCfg = Debug|Any CPU + {D3F67A98-B3A7-4995-9F53-BF6D1B925B20}.Debug|x64.Build.0 = Debug|Any CPU + {D3F67A98-B3A7-4995-9F53-BF6D1B925B20}.Debug|x86.ActiveCfg = Debug|Any CPU + {D3F67A98-B3A7-4995-9F53-BF6D1B925B20}.Debug|x86.Build.0 = Debug|Any CPU + {D3F67A98-B3A7-4995-9F53-BF6D1B925B20}.Release|Any CPU.ActiveCfg = Release|Any CPU + {D3F67A98-B3A7-4995-9F53-BF6D1B925B20}.Release|Any CPU.Build.0 = Release|Any CPU + {D3F67A98-B3A7-4995-9F53-BF6D1B925B20}.Release|x64.ActiveCfg = Release|Any CPU + {D3F67A98-B3A7-4995-9F53-BF6D1B925B20}.Release|x64.Build.0 = Release|Any CPU + {D3F67A98-B3A7-4995-9F53-BF6D1B925B20}.Release|x86.ActiveCfg = Release|Any CPU + {D3F67A98-B3A7-4995-9F53-BF6D1B925B20}.Release|x86.Build.0 = Release|Any CPU + EndGlobalSection + GlobalSection(SolutionProperties) = preSolution + HideSolutionNode = FALSE + EndGlobalSection +EndGlobal