Skip to content

Commit 461d29d

Browse files
Merge branch 'main' into seo/63330-csharp-catch-multiple-exceptions
2 parents d0d310a + 55d01e3 commit 461d29d

41 files changed

Lines changed: 1396 additions & 26 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.
Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
using Microsoft.AspNetCore.Mvc;
2+
3+
namespace WebApiBestPractices.Api.Controllers;
4+
5+
[ApiController]
6+
[Route("api/files")]
7+
public class FilesController(IConfiguration configuration) : ControllerBase
8+
{
9+
[HttpPost]
10+
[RequestSizeLimit(10 * 1024 * 1024)]
11+
public async Task<IActionResult> Upload(CancellationToken cancellationToken)
12+
{
13+
var formCollection = await Request.ReadFormAsync(cancellationToken);
14+
var file = formCollection.Files.FirstOrDefault();
15+
if (file is null || file.Length == 0)
16+
return BadRequest("No file was uploaded.");
17+
18+
var folder = configuration["Uploads:Path"] ?? "Uploads";
19+
Directory.CreateDirectory(folder);
20+
21+
// Never trust the client's file name: generate our own.
22+
var fileName = Path.GetRandomFileName();
23+
await using var stream = System.IO.File.Create(Path.Combine(folder, fileName));
24+
await file.CopyToAsync(stream, cancellationToken);
25+
26+
return Ok(new { fileName });
27+
}
28+
}
Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
1+
using Asp.Versioning;
2+
using Microsoft.AspNetCore.Authorization;
3+
using Microsoft.AspNetCore.Mvc;
4+
using Microsoft.AspNetCore.OutputCaching;
5+
using Microsoft.AspNetCore.RateLimiting;
6+
using WebApiBestPractices.Api.Extensions;
7+
using WebApiBestPractices.Api.Filters;
8+
using WebApiBestPractices.Api.Logging;
9+
using WebApiBestPractices.Application.Dtos;
10+
using WebApiBestPractices.Application.Services;
11+
12+
namespace WebApiBestPractices.Api.Controllers;
13+
14+
[ApiController]
15+
[ApiVersion("1.0")]
16+
[Route("api/owners")]
17+
[EnableRateLimiting(ServiceExtensions.PerClientRateLimit)]
18+
public class OwnersController(
19+
IOwnerService ownerService,
20+
IOutputCacheStore outputCache,
21+
ILogger<OwnersController> logger) : ControllerBase
22+
{
23+
[HttpGet]
24+
[OutputCache(PolicyName = ServiceExtensions.OwnersCachePolicy)]
25+
public async Task<ActionResult<IEnumerable<OwnerDto>>> GetOwners(CancellationToken cancellationToken)
26+
{
27+
var owners = await ownerService.GetAllAsync(cancellationToken);
28+
logger.OwnersReturned(owners.Count);
29+
30+
return Ok(owners);
31+
}
32+
33+
[HttpGet("{id:guid}", Name = "OwnerById")]
34+
public async Task<ActionResult<OwnerDto>> GetOwnerById(Guid id, CancellationToken cancellationToken)
35+
{
36+
var owner = await ownerService.GetByIdAsync(id, cancellationToken);
37+
38+
return owner is null ? NotFound() : Ok(owner);
39+
}
40+
41+
[HttpPost]
42+
public async Task<ActionResult<OwnerDto>> CreateOwner(
43+
OwnerForCreationDto owner, CancellationToken cancellationToken)
44+
{
45+
var createdOwner = await ownerService.CreateAsync(owner, cancellationToken);
46+
await outputCache.EvictByTagAsync(ServiceExtensions.OwnersCacheTag, cancellationToken);
47+
48+
return CreatedAtRoute("OwnerById", new { id = createdOwner.Id }, createdOwner);
49+
}
50+
51+
[HttpPut("{id:guid}")]
52+
[ServiceFilter<ValidateOwnerExistsFilter>]
53+
public async Task<IActionResult> UpdateOwner(
54+
Guid id, OwnerForUpdateDto owner, CancellationToken cancellationToken)
55+
{
56+
await ownerService.UpdateAsync(id, owner, cancellationToken);
57+
await outputCache.EvictByTagAsync(ServiceExtensions.OwnersCacheTag, cancellationToken);
58+
59+
return NoContent();
60+
}
61+
62+
[Authorize]
63+
[HttpDelete("{id:guid}")]
64+
[ServiceFilter<ValidateOwnerExistsFilter>]
65+
public async Task<IActionResult> DeleteOwner(Guid id, CancellationToken cancellationToken)
66+
{
67+
await ownerService.DeleteAsync(id, cancellationToken);
68+
await outputCache.EvictByTagAsync(ServiceExtensions.OwnersCacheTag, cancellationToken);
69+
70+
return NoContent();
71+
}
72+
}
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
using Asp.Versioning;
2+
using Microsoft.AspNetCore.Mvc;
3+
using WebApiBestPractices.Application.Services;
4+
5+
namespace WebApiBestPractices.Api.Controllers;
6+
7+
[ApiController]
8+
[ApiVersion("2.0")]
9+
[Route("api/owners")]
10+
public class OwnersV2Controller(IOwnerService ownerService) : ControllerBase
11+
{
12+
// Version 2 returns a lighter list: names only.
13+
[HttpGet]
14+
public async Task<ActionResult<IEnumerable<string>>> GetOwners(CancellationToken cancellationToken)
15+
{
16+
var owners = await ownerService.GetAllAsync(cancellationToken);
17+
18+
return Ok(owners.Select(o => o.Name));
19+
}
20+
}
Lines changed: 102 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,102 @@
1+
using System.Text;
2+
using System.Threading.RateLimiting;
3+
using Asp.Versioning;
4+
using Microsoft.AspNetCore.Authentication.JwtBearer;
5+
using Microsoft.AspNetCore.Identity;
6+
using Microsoft.IdentityModel.Tokens;
7+
using WebApiBestPractices.Api.Filters;
8+
using WebApiBestPractices.Api.Security;
9+
using WebApiBestPractices.Application.Services;
10+
using WebApiBestPractices.Domain.Contracts;
11+
using WebApiBestPractices.Infrastructure.Repositories;
12+
13+
namespace WebApiBestPractices.Api.Extensions;
14+
15+
public static class ServiceExtensions
16+
{
17+
public const string CorsPolicy = "CorsPolicy";
18+
public const string OwnersCachePolicy = "Owners";
19+
public const string OwnersCacheTag = "owners";
20+
public const string PerClientRateLimit = "PerClient";
21+
22+
public static void ConfigureCors(this IServiceCollection services, IConfiguration configuration) =>
23+
services.AddCors(options =>
24+
options.AddPolicy(CorsPolicy, policy => policy
25+
.WithOrigins(configuration.GetSection("Cors:AllowedOrigins").Get<string[]>() ?? [])
26+
.AllowAnyMethod()
27+
.AllowAnyHeader()));
28+
29+
public static void ConfigureApplicationServices(this IServiceCollection services)
30+
{
31+
services.AddSingleton<IOwnerRepository, InMemoryOwnerRepository>();
32+
services.AddScoped<IOwnerService, OwnerService>();
33+
services.AddScoped<ValidateOwnerExistsFilter>();
34+
services.AddScoped<IPasswordHasher<AppUser>, PasswordHasher<AppUser>>();
35+
services.AddScoped<AccountService>();
36+
services.AddScoped<OwnerIdProtector>();
37+
services.AddDataProtection();
38+
services.AddHybridCache();
39+
}
40+
41+
public static void ConfigureVersioning(this IServiceCollection services) =>
42+
services.AddApiVersioning(options =>
43+
{
44+
options.DefaultApiVersion = new ApiVersion(1, 0);
45+
options.AssumeDefaultVersionWhenUnspecified = true;
46+
options.ReportApiVersions = true;
47+
options.ApiVersionReader = ApiVersionReader.Combine(
48+
new QueryStringApiVersionReader("api-version"),
49+
new HeaderApiVersionReader("api-version"));
50+
})
51+
.AddMvc();
52+
53+
public static void ConfigureOutputCache(this IServiceCollection services) =>
54+
services.AddOutputCache(options =>
55+
options.AddPolicy(OwnersCachePolicy, policy => policy
56+
.Expire(TimeSpan.FromSeconds(30))
57+
.SetVaryByHeader("Accept")
58+
.Tag(OwnersCacheTag)));
59+
60+
public static void ConfigureRateLimiting(this IServiceCollection services, IConfiguration configuration)
61+
{
62+
var permitLimit = configuration.GetValue("RateLimiting:PermitLimit", 100);
63+
64+
services.AddRateLimiter(options =>
65+
{
66+
options.RejectionStatusCode = StatusCodes.Status429TooManyRequests;
67+
options.AddPolicy(PerClientRateLimit, httpContext =>
68+
RateLimitPartition.GetFixedWindowLimiter(
69+
partitionKey: httpContext.User.Identity?.Name
70+
?? httpContext.Connection.RemoteIpAddress?.ToString()
71+
?? "anonymous",
72+
factory: _ => new FixedWindowRateLimiterOptions
73+
{
74+
PermitLimit = permitLimit,
75+
Window = TimeSpan.FromMinutes(1),
76+
QueueLimit = 0
77+
}));
78+
});
79+
}
80+
81+
public static void ConfigureJwt(this IServiceCollection services, IConfiguration configuration)
82+
{
83+
var jwtSettings = configuration.GetSection("JwtSettings");
84+
var secretKey = jwtSettings["SecretKey"]
85+
?? throw new InvalidOperationException("JwtSettings:SecretKey is not configured.");
86+
87+
services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
88+
.AddJwtBearer(options =>
89+
{
90+
options.TokenValidationParameters = new TokenValidationParameters
91+
{
92+
ValidateIssuer = true,
93+
ValidateAudience = true,
94+
ValidateLifetime = true,
95+
ValidateIssuerSigningKey = true,
96+
ValidIssuer = jwtSettings["ValidIssuer"],
97+
ValidAudience = jwtSettings["ValidAudience"],
98+
IssuerSigningKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(secretKey))
99+
};
100+
});
101+
}
102+
}
Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
using Microsoft.AspNetCore.Mvc;
2+
using Microsoft.AspNetCore.Mvc.Filters;
3+
using WebApiBestPractices.Application.Services;
4+
5+
namespace WebApiBestPractices.Api.Filters;
6+
7+
public class ValidateOwnerExistsFilter(IOwnerService ownerService) : IAsyncActionFilter
8+
{
9+
public async Task OnActionExecutionAsync(ActionExecutingContext context, ActionExecutionDelegate next)
10+
{
11+
if (context.ActionArguments.TryGetValue("id", out var value) && value is Guid id)
12+
{
13+
var owner = await ownerService.GetByIdAsync(id, context.HttpContext.RequestAborted);
14+
if (owner is null)
15+
{
16+
context.Result = new NotFoundResult();
17+
return;
18+
}
19+
}
20+
21+
await next();
22+
}
23+
}
Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
using Microsoft.AspNetCore.Diagnostics;
2+
using Microsoft.AspNetCore.Mvc;
3+
using WebApiBestPractices.Domain.Exceptions;
4+
5+
namespace WebApiBestPractices.Api.Handlers;
6+
7+
public class GlobalExceptionHandler(
8+
IProblemDetailsService problemDetailsService,
9+
ILogger<GlobalExceptionHandler> logger) : IExceptionHandler
10+
{
11+
public async ValueTask<bool> TryHandleAsync(
12+
HttpContext httpContext,
13+
Exception exception,
14+
CancellationToken cancellationToken)
15+
{
16+
var (statusCode, title) = exception switch
17+
{
18+
DuplicateOwnerEmailException => (StatusCodes.Status409Conflict, "The owner already exists."),
19+
_ => (StatusCodes.Status500InternalServerError, "An unexpected error occurred.")
20+
};
21+
22+
if (statusCode == StatusCodes.Status500InternalServerError)
23+
logger.LogError(exception, "Unhandled exception for {Path}", httpContext.Request.Path);
24+
25+
httpContext.Response.StatusCode = statusCode;
26+
27+
return await problemDetailsService.TryWriteAsync(new ProblemDetailsContext
28+
{
29+
HttpContext = httpContext,
30+
Exception = exception,
31+
ProblemDetails = new ProblemDetails
32+
{
33+
Status = statusCode,
34+
Title = title,
35+
Detail = statusCode == StatusCodes.Status409Conflict ? exception.Message : null
36+
}
37+
});
38+
}
39+
}
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
namespace WebApiBestPractices.Api.Logging;
2+
3+
public static partial class LogMessages
4+
{
5+
[LoggerMessage(Level = LogLevel.Information, Message = "Returned {Count} owners from the database")]
6+
public static partial void OwnersReturned(this ILogger logger, int count);
7+
}
Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
using WebApiBestPractices.Api.Extensions;
2+
using WebApiBestPractices.Api.Handlers;
3+
using WebApiBestPractices.Application.Services;
4+
5+
var builder = WebApplication.CreateBuilder(args);
6+
7+
builder.Services.ConfigureCors(builder.Configuration);
8+
builder.Services.ConfigureApplicationServices();
9+
builder.Services.ConfigureVersioning();
10+
builder.Services.ConfigureOutputCache();
11+
builder.Services.ConfigureRateLimiting(builder.Configuration);
12+
builder.Services.ConfigureJwt(builder.Configuration);
13+
builder.Services.AddAuthorization();
14+
15+
builder.Services.AddExceptionHandler<GlobalExceptionHandler>();
16+
builder.Services.AddProblemDetails();
17+
18+
builder.Services.AddControllers(options =>
19+
{
20+
options.RespectBrowserAcceptHeader = true;
21+
options.ReturnHttpNotAcceptable = true;
22+
})
23+
.AddXmlSerializerFormatters();
24+
25+
builder.Services.AddOpenApi();
26+
27+
var app = builder.Build();
28+
29+
app.UseExceptionHandler();
30+
app.UseStatusCodePages();
31+
32+
if (app.Environment.IsDevelopment())
33+
{
34+
app.MapOpenApi();
35+
}
36+
37+
app.UseHttpsRedirection();
38+
app.UseCors(ServiceExtensions.CorsPolicy);
39+
app.UseAuthentication();
40+
app.UseAuthorization();
41+
app.UseRateLimiter();
42+
app.UseOutputCache();
43+
44+
app.MapControllers();
45+
46+
// The same data through a Minimal API endpoint, for comparison.
47+
app.MapGet("/api/minimal/owners", async (IOwnerService ownerService, CancellationToken cancellationToken) =>
48+
TypedResults.Ok(await ownerService.GetAllAsync(cancellationToken)));
49+
50+
app.Run();
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
{
2+
"$schema": "https://json.schemastore.org/launchsettings.json",
3+
"profiles": {
4+
"https": {
5+
"commandName": "Project",
6+
"dotnetRunMessages": true,
7+
"launchBrowser": false,
8+
"applicationUrl": "https://localhost:5001;http://localhost:5000",
9+
"environmentVariables": {
10+
"ASPNETCORE_ENVIRONMENT": "Development"
11+
}
12+
}
13+
}
14+
}
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
using Microsoft.AspNetCore.Identity;
2+
3+
namespace WebApiBestPractices.Api.Security;
4+
5+
public class AppUser
6+
{
7+
public required string UserName { get; init; }
8+
}
9+
10+
public class AccountService(IPasswordHasher<AppUser> passwordHasher)
11+
{
12+
public string HashPassword(AppUser user, string password) =>
13+
passwordHasher.HashPassword(user, password);
14+
15+
public bool VerifyPassword(AppUser user, string hashedPassword, string password) =>
16+
passwordHasher.VerifyHashedPassword(user, hashedPassword, password)
17+
!= PasswordVerificationResult.Failed;
18+
}

0 commit comments

Comments
 (0)