diff --git a/.codacy.yml b/.codacy.yml index 38b1635..be01c73 100644 --- a/.codacy.yml +++ b/.codacy.yml @@ -21,6 +21,7 @@ exclude_paths: - '**/*Program.cs' # Main entry point, often not useful for static analysis # Ignore specific folders across any depth in the project + - '**/Configurations/**' # Swagger options, etc - '**/Data/**' # Repositories, DbContext, database file, etc. - '**/Enums/**' # Enumeration types - '**/Mappings/**' # AutoMapper profiles diff --git a/.github/dependabot.yml b/.github/dependabot.yml index 51d6333..c470083 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -3,19 +3,22 @@ version: 2 updates: - package-ecosystem: "nuget" - directory: "/" + directory: "/src/Dotnet.Samples.AspNetCore.WebApi" schedule: interval: "daily" groups: efcore: patterns: - - "Microsoft.EntityFrameworkCore*" - xunit: - patterns: - - "xunit*" - coverlet: + - "Microsoft.EntityFrameworkCore*" + serilog: patterns: - - "coverlet*" + - "Serilog*" + + - package-ecosystem: "nuget" + directory: "/test/Dotnet.Samples.AspNetCore.WebApi.Tests" + schedule: + interval: "daily" + - package-ecosystem: "github-actions" directory: "/" schedule: diff --git a/README.md b/README.md index eabc231..58b7d5f 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,4 @@ -# 🧪 Web API made with .NET 8 (LTS) and ASP.NET Core 8.0 +# 🧪 Web API made with .NET 8 (LTS) and ASP.NET Core ## Status @@ -16,7 +16,7 @@ ## About -Proof of Concept for a Web API made with [ASP.NET Core 8.0](https://learn.microsoft.com/en-us/aspnet/core/release-notes/aspnetcore-8.0?view=aspnetcore-8.0) targeting [.NET 8](https://learn.microsoft.com/en-us/dotnet/core/whats-new/dotnet-8) inspired by the official guide: [Create a web API with ASP.NET Core](https://learn.microsoft.com/en-us/aspnet/core/tutorials/first-web-api?view=aspnetcore-8.0&tabs=visual-studio-code). +Proof of Concept for a Web API made with [.NET 8](https://learn.microsoft.com/en-us/dotnet/core/whats-new/dotnet-8) (LTS) and [ASP.NET Core](https://learn.microsoft.com/en-us/aspnet/core/release-notes/aspnetcore-8.0?view=aspnetcore-8.0). ## Start @@ -30,7 +30,7 @@ dotnet watch run --project src/Dotnet.Samples.AspNetCore.WebApi/Dotnet.Samples.A https://localhost:9000/swagger/index.html ``` -![API Documentation](/docs/Swagger.png) +![API Documentation](/assets/images/Swagger.png) ## Credits diff --git a/assets/images/Swagger.png b/assets/images/Swagger.png new file mode 100644 index 0000000..2b47e02 Binary files /dev/null and b/assets/images/Swagger.png differ diff --git a/codecov.yml b/codecov.yml index f289b69..d7ac00c 100644 --- a/codecov.yml +++ b/codecov.yml @@ -55,6 +55,7 @@ ignore: - '**/LICENSE' - '**/README.md' + - .*\/Configurations\/.* - .*\/Data\/.* - .*\/Enums\/.* - .*\/Mappings\/.* diff --git a/docs/Swagger.png b/docs/Swagger.png deleted file mode 100644 index 1cf5c5d..0000000 Binary files a/docs/Swagger.png and /dev/null differ diff --git a/src/Dotnet.Samples.AspNetCore.WebApi/Configurations/AuthorizeCheckOperationFilter.cs b/src/Dotnet.Samples.AspNetCore.WebApi/Configurations/AuthorizeCheckOperationFilter.cs new file mode 100644 index 0000000..a78b116 --- /dev/null +++ b/src/Dotnet.Samples.AspNetCore.WebApi/Configurations/AuthorizeCheckOperationFilter.cs @@ -0,0 +1,47 @@ +using System.Linq; +using Microsoft.AspNetCore.Authorization; +using Microsoft.OpenApi.Models; +using Swashbuckle.AspNetCore.SwaggerGen; + +namespace Dotnet.Samples.AspNetCore.WebApi.Configurations +{ + /// + /// Adds the Bearer security requirement only to operations that have [Authorize] attributes. + /// + public class AuthorizeCheckOperationFilter : IOperationFilter + { + public void Apply(OpenApiOperation operation, OperationFilterContext context) + { + // Check if [Authorize] is applied at the method or class level + var hasAuthorize = + context.MethodInfo.GetCustomAttributes(true).OfType().Any() + || context + .MethodInfo.DeclaringType?.GetCustomAttributes(true) + .OfType() + .Any() == true; + + // If there's no [Authorize] attribute, skip adding the security requirement + if (!hasAuthorize) + return; + + // Add security requirement (shows the lock icon) + operation.Security ??= new List(); + operation.Security.Add( + new OpenApiSecurityRequirement + { + { + new OpenApiSecurityScheme + { + Reference = new OpenApiReference + { + Id = "Bearer", + Type = ReferenceType.SecurityScheme + } + }, + Array.Empty() + } + } + ); + } + } +} diff --git a/src/Dotnet.Samples.AspNetCore.WebApi/Configurations/SwaggerDocOptions.cs b/src/Dotnet.Samples.AspNetCore.WebApi/Configurations/SwaggerDocOptions.cs new file mode 100644 index 0000000..f099a25 --- /dev/null +++ b/src/Dotnet.Samples.AspNetCore.WebApi/Configurations/SwaggerDocOptions.cs @@ -0,0 +1,68 @@ +using System.Reflection; +using Microsoft.OpenApi.Models; + +namespace Dotnet.Samples.AspNetCore.WebApi.Configurations; + +/// +/// Provides centralized configuration methods for Swagger/OpenAPI docs. +/// Includes XML comments path resolution and security (JWT Bearer) setup. +/// +public static class SwaggerGenDefaults +{ + /// + /// Resolves the path to the XML comments file generated from code + /// documentation. + /// This is used to enrich the Swagger UI with method summaries and remarks. + /// + /// Full file path to the XML documentation file. + public static string ConfigureXmlCommentsFilePath() + { + return Path.Combine( + AppContext.BaseDirectory, + $"{Assembly.GetExecutingAssembly().GetName().Name}.xml" + ); + } + + /// + /// Configures the OpenAPI security definition for JWT Bearer authentication. + /// This will show the padlock icon in Swagger UI and allow users to + /// authenticate. + /// + /// An describing the Bearer token format. + public static OpenApiSecurityScheme ConfigureSecurityDefinition() + { + return new OpenApiSecurityScheme + { + Name = "Authorization", + Type = SecuritySchemeType.Http, + Scheme = "bearer", + BearerFormat = "JWT", + In = ParameterLocation.Header, + Description = "Enter your JWT token below. Example: Bearer {token}" + }; + } + + /// + /// Adds a global security requirement to the Swagger spec that applies + /// the Bearer definition. + /// Routes decorated with [Authorize] will be marked as protected in the UI. + /// + /// An referencing the Bearer definition. + public static OpenApiSecurityRequirement ConfigureSecurityRequirement() + { + return new OpenApiSecurityRequirement + { + { + new OpenApiSecurityScheme + { + Reference = new OpenApiReference + { + Id = "Bearer", + Type = ReferenceType.SecurityScheme + } + }, + Array.Empty() + } + }; + } +} diff --git a/src/Dotnet.Samples.AspNetCore.WebApi/Controllers/PlayerController.cs b/src/Dotnet.Samples.AspNetCore.WebApi/Controllers/PlayerController.cs index 06bf789..d32f923 100644 --- a/src/Dotnet.Samples.AspNetCore.WebApi/Controllers/PlayerController.cs +++ b/src/Dotnet.Samples.AspNetCore.WebApi/Controllers/PlayerController.cs @@ -8,7 +8,7 @@ namespace Dotnet.Samples.AspNetCore.WebApi.Controllers; [ApiController] -[Route("[controller]")] +[Route("players")] [Produces("application/json")] public class PlayerController( IPlayerService playerService, @@ -94,13 +94,12 @@ public async Task GetAsync() } /// - /// Retrieves a Player by its ID + /// Retrieves a Player by its internal Id (GUID) /// - /// The ID of the Player + /// The internal Id (GUID) of the Player /// OK /// Not Found - [Authorize(Roles = "Admin")] - [ApiExplorerSettings(IgnoreApi = true)] + [Authorize] [HttpGet("{id:Guid}", Name = "RetrieveById")] [ProducesResponseType(StatusCodes.Status200OK)] [ProducesResponseType(StatusCodes.Status404NotFound)] @@ -125,7 +124,7 @@ public async Task GetByIdAsync([FromRoute] Guid id) /// The Squad Number of the Player /// OK /// Not Found - [HttpGet("squadNumber/{squadNumber:int}", Name = "RetrieveBySquadNumber")] + [HttpGet("{squadNumber:int}", Name = "RetrieveBySquadNumber")] [ProducesResponseType(StatusCodes.Status200OK)] [ProducesResponseType(StatusCodes.Status404NotFound)] public async Task GetBySquadNumberAsync([FromRoute] int squadNumber) @@ -134,7 +133,7 @@ public async Task GetBySquadNumberAsync([FromRoute] int squadNumber) if (player != null) { logger.LogInformation( - "GET /players/squad/{SquadNumber} retrieved: {@Player}", + "GET /players/{SquadNumber} retrieved: {@Player}", squadNumber, player ); @@ -142,7 +141,7 @@ public async Task GetBySquadNumberAsync([FromRoute] int squadNumber) } else { - logger.LogWarning("GET /players/squad/{SquadNumber} not found", squadNumber); + logger.LogWarning("GET /players/{SquadNumber} not found", squadNumber); return TypedResults.NotFound(); } } diff --git a/src/Dotnet.Samples.AspNetCore.WebApi/Program.cs b/src/Dotnet.Samples.AspNetCore.WebApi/Program.cs index fb2e912..df8376a 100644 --- a/src/Dotnet.Samples.AspNetCore.WebApi/Program.cs +++ b/src/Dotnet.Samples.AspNetCore.WebApi/Program.cs @@ -1,4 +1,4 @@ -using System.Reflection; +using Dotnet.Samples.AspNetCore.WebApi.Configurations; using Dotnet.Samples.AspNetCore.WebApi.Data; using Dotnet.Samples.AspNetCore.WebApi.Mappings; using Dotnet.Samples.AspNetCore.WebApi.Models; @@ -23,6 +23,8 @@ * Logging * -------------------------------------------------------------------------- */ Log.Logger = new LoggerConfiguration().ReadFrom.Configuration(builder.Configuration).CreateLogger(); + +/* Serilog ------------------------------------------------------------------ */ builder.Host.UseSerilog(); /* ----------------------------------------------------------------------------- @@ -30,6 +32,7 @@ * -------------------------------------------------------------------------- */ builder.Services.AddControllers(); +/* Entity Framework Core ---------------------------------------------------- */ builder.Services.AddDbContextPool(options => { var dataSource = Path.Combine(AppContext.BaseDirectory, "Data", "players-sqlite3.db"); @@ -44,20 +47,23 @@ builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddMemoryCache(); + +/* AutoMapper --------------------------------------------------------------- */ builder.Services.AddAutoMapper(typeof(PlayerMappingProfile)); + +/* FluentValidation --------------------------------------------------------- */ builder.Services.AddScoped, PlayerRequestModelValidator>(); if (builder.Environment.IsDevelopment()) { + /* Swagger UI ----------------------------------------------------------- */ + // https://learn.microsoft.com/en-us/aspnet/core/tutorials/getting-started-with-swashbuckle builder.Services.AddSwaggerGen(options => { options.SwaggerDoc("v1", builder.Configuration.GetSection("SwaggerDoc").Get()); - options.IncludeXmlComments( - Path.Combine( - AppContext.BaseDirectory, - $"{Assembly.GetExecutingAssembly().GetName().Name}.xml" - ) - ); + options.IncludeXmlComments(SwaggerGenDefaults.ConfigureXmlCommentsFilePath()); + options.AddSecurityDefinition("Bearer", SwaggerGenDefaults.ConfigureSecurityDefinition()); + options.OperationFilter(); }); } @@ -67,16 +73,14 @@ * Middlewares * https://learn.microsoft.com/en-us/aspnet/core/fundamentals/middleware * -------------------------------------------------------------------------- */ +app.UseSerilogRequestLogging(); + if (app.Environment.IsDevelopment()) { - // https://learn.microsoft.com/en-us/aspnet/core/tutorials/getting-started-with-swashbuckle app.UseSwagger(); app.UseSwaggerUI(); } -// https://github.com/serilog/serilog-aspnetcore -app.UseSerilogRequestLogging(); - // https://learn.microsoft.com/en-us/aspnet/core/security/enforcing-ssl app.UseHttpsRedirection(); diff --git a/test/Dotnet.Samples.AspNetCore.WebApi.Tests/Dotnet.Samples.AspNetCore.WebApi.Tests.csproj b/test/Dotnet.Samples.AspNetCore.WebApi.Tests/Dotnet.Samples.AspNetCore.WebApi.Tests.csproj index e5ba8cc..c3d41c2 100644 --- a/test/Dotnet.Samples.AspNetCore.WebApi.Tests/Dotnet.Samples.AspNetCore.WebApi.Tests.csproj +++ b/test/Dotnet.Samples.AspNetCore.WebApi.Tests/Dotnet.Samples.AspNetCore.WebApi.Tests.csproj @@ -9,16 +9,16 @@ - - runtime; build; native; contentfiles; analyzers; buildtransitive - all - runtime; build; native; contentfiles; analyzers; buildtransitive all + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + diff --git a/test/Dotnet.Samples.AspNetCore.WebApi.Tests/packages.lock.json b/test/Dotnet.Samples.AspNetCore.WebApi.Tests/packages.lock.json index 0c5e6d6..349f283 100644 --- a/test/Dotnet.Samples.AspNetCore.WebApi.Tests/packages.lock.json +++ b/test/Dotnet.Samples.AspNetCore.WebApi.Tests/packages.lock.json @@ -1511,35 +1511,35 @@ }, "Swashbuckle.AspNetCore": { "type": "Transitive", - "resolved": "8.1.0", - "contentHash": "8NJwXskWNBhX4IntitP1VP68hzhQ9Sex4mR9ZbqCuuUsaWlfrMA+yDIYDFsbHlB2NQ6Gt27AT15BWo9jxHj1tg==", + "resolved": "8.1.1", + "contentHash": "HJHexmU0PiYevgTLvKjYkxEtclF2w4O7iTd3Ef3p6KeT0kcYLpkFVgCw6glpGS57h8769anv8G+NFi9Kge+/yw==", "dependencies": { "Microsoft.Extensions.ApiDescription.Server": "6.0.5", - "Swashbuckle.AspNetCore.Swagger": "8.1.0", - "Swashbuckle.AspNetCore.SwaggerGen": "8.1.0", - "Swashbuckle.AspNetCore.SwaggerUI": "8.1.0" + "Swashbuckle.AspNetCore.Swagger": "8.1.1", + "Swashbuckle.AspNetCore.SwaggerGen": "8.1.1", + "Swashbuckle.AspNetCore.SwaggerUI": "8.1.1" } }, "Swashbuckle.AspNetCore.Swagger": { "type": "Transitive", - "resolved": "8.1.0", - "contentHash": "TRpBDdWh/6IzXHOLKVEuZSuhcdc8OIwhr8dyLnWvEh6FO4f0KiATAzr8yzHspcCFzCc/CONpvUCq4DzazpP6Og==", + "resolved": "8.1.1", + "contentHash": "h+8D5jQtnl6X4f2hJQwf0Khj0SnCQANzirCELjXJ6quJ4C1aNNCvJrAsQ+4fOKAMqJkvW48cKj79ftG+YoGcRg==", "dependencies": { "Microsoft.OpenApi": "1.6.23" } }, "Swashbuckle.AspNetCore.SwaggerGen": { "type": "Transitive", - "resolved": "8.1.0", - "contentHash": "sXAUNodmnzrJb1qmgdLY6HgTDsgagXCn6p+qygW2wP0BjcvdZZA6GdDgtuc8iQON9ekrp7m5G0Zc09lvuNhc/A==", + "resolved": "8.1.1", + "contentHash": "2EuPzXSNleOOzYvziERWRLnk1Oz9i0Z1PimaUFy1SasBqeV/rG+eMfwFAMtTaf4W6gvVOzRcUCNRHvpBIIzr+A==", "dependencies": { - "Swashbuckle.AspNetCore.Swagger": "8.1.0" + "Swashbuckle.AspNetCore.Swagger": "8.1.1" } }, "Swashbuckle.AspNetCore.SwaggerUI": { "type": "Transitive", - "resolved": "8.1.0", - "contentHash": "PYyTbYrEz1q8N8ZpCFzY5hOkvP5gv9oIml06wrrrjGRFvVXyCF/b58FRI/9mnRKRBAmWKZR5pqhmJBzvsVveSQ==" + "resolved": "8.1.1", + "contentHash": "GDLX/MpK4oa2nYC1N/zN2UidQTtVKLPF6gkdEmGb0RITEwpJG9Gu8olKqPYnKqVeFn44JZoCS0M2LGRKXP8B/A==" }, "System.ClientModel": { "type": "Transitive", @@ -1837,7 +1837,7 @@ "Serilog.Settings.Configuration": "[9.0.0, )", "Serilog.Sinks.Console": "[6.0.0, )", "Serilog.Sinks.File": "[6.0.0, )", - "Swashbuckle.AspNetCore": "[8.1.0, )" + "Swashbuckle.AspNetCore": "[8.1.1, )" } } }