Skip to content
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
Original file line number Diff line number Diff line change
@@ -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<IActionResult> 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 });
}
}
Original file line number Diff line number Diff line change
@@ -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<OwnersController> logger) : ControllerBase
{
[HttpGet]
[OutputCache(PolicyName = ServiceExtensions.OwnersCachePolicy)]
public async Task<ActionResult<IEnumerable<OwnerDto>>> GetOwners(CancellationToken cancellationToken)
{
var owners = await ownerService.GetAllAsync(cancellationToken);
logger.OwnersReturned(owners.Count);

return Ok(owners);
}

[HttpGet("{id:guid}", Name = "OwnerById")]
public async Task<ActionResult<OwnerDto>> GetOwnerById(Guid id, CancellationToken cancellationToken)
{
var owner = await ownerService.GetByIdAsync(id, cancellationToken);

return owner is null ? NotFound() : Ok(owner);
}

[HttpPost]
public async Task<ActionResult<OwnerDto>> 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<ValidateOwnerExistsFilter>]
public async Task<IActionResult> 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<ValidateOwnerExistsFilter>]
public async Task<IActionResult> DeleteOwner(Guid id, CancellationToken cancellationToken)
{
await ownerService.DeleteAsync(id, cancellationToken);
await outputCache.EvictByTagAsync(ServiceExtensions.OwnersCacheTag, cancellationToken);

return NoContent();
}
}
Original file line number Diff line number Diff line change
@@ -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<ActionResult<IEnumerable<string>>> GetOwners(CancellationToken cancellationToken)
{
var owners = await ownerService.GetAllAsync(cancellationToken);

return Ok(owners.Select(o => o.Name));
}
}
Original file line number Diff line number Diff line change
@@ -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<string[]>() ?? [])
.AllowAnyMethod()
.AllowAnyHeader()));

public static void ConfigureApplicationServices(this IServiceCollection services)
{
services.AddSingleton<IOwnerRepository, InMemoryOwnerRepository>();
services.AddScoped<IOwnerService, OwnerService>();
services.AddScoped<ValidateOwnerExistsFilter>();
services.AddScoped<IPasswordHasher<AppUser>, PasswordHasher<AppUser>>();
services.AddScoped<AccountService>();
services.AddScoped<OwnerIdProtector>();
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))
};
});
}
}
Original file line number Diff line number Diff line change
@@ -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();
}
}
Original file line number Diff line number Diff line change
@@ -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<GlobalExceptionHandler> logger) : IExceptionHandler
{
public async ValueTask<bool> 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
}
});
}
}
Original file line number Diff line number Diff line change
@@ -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);
}
Original file line number Diff line number Diff line change
@@ -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<GlobalExceptionHandler>();
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();
Original file line number Diff line number Diff line change
@@ -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"
}
}
}
}
Original file line number Diff line number Diff line change
@@ -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<AppUser> 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;
}
Original file line number Diff line number Diff line change
@@ -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));
}
Loading
Loading