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
1 change: 1 addition & 0 deletions .codacy.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
17 changes: 10 additions & 7 deletions .github/dependabot.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
6 changes: 3 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
@@ -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

Expand All @@ -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

Expand All @@ -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

Expand Down
Binary file added assets/images/Swagger.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
1 change: 1 addition & 0 deletions codecov.yml
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,7 @@ ignore:
- '**/LICENSE'
- '**/README.md'

- .*\/Configurations\/.*
- .*\/Data\/.*
- .*\/Enums\/.*
- .*\/Mappings\/.*
Expand Down
Binary file removed docs/Swagger.png
Binary file not shown.
Original file line number Diff line number Diff line change
@@ -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
{
/// <summary>
/// Adds the Bearer security requirement only to operations that have [Authorize] attributes.
/// </summary>
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<AuthorizeAttribute>().Any()
|| context
.MethodInfo.DeclaringType?.GetCustomAttributes(true)
.OfType<AuthorizeAttribute>()
.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<OpenApiSecurityRequirement>();
operation.Security.Add(
new OpenApiSecurityRequirement
{
{
new OpenApiSecurityScheme
{
Reference = new OpenApiReference
{
Id = "Bearer",
Type = ReferenceType.SecurityScheme
}
},
Array.Empty<string>()
}
}
);
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
using System.Reflection;
using Microsoft.OpenApi.Models;

namespace Dotnet.Samples.AspNetCore.WebApi.Configurations;

/// <summary>
/// Provides centralized configuration methods for Swagger/OpenAPI docs.
/// Includes XML comments path resolution and security (JWT Bearer) setup.
/// </summary>
public static class SwaggerGenDefaults
{
/// <summary>
/// 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.
/// </summary>
/// <returns>Full file path to the XML documentation file.</returns>
public static string ConfigureXmlCommentsFilePath()
{
return Path.Combine(
AppContext.BaseDirectory,
$"{Assembly.GetExecutingAssembly().GetName().Name}.xml"
);
}

/// <summary>
/// Configures the OpenAPI security definition for JWT Bearer authentication.
/// This will show the padlock icon in Swagger UI and allow users to
/// authenticate.
/// </summary>
/// <returns>An <see cref="OpenApiSecurityScheme"/> describing the Bearer token format.</returns>
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}"
};
}

/// <summary>
/// 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.
/// </summary>
/// <returns>An <see cref="OpenApiSecurityRequirement"/> referencing the Bearer definition.</returns>
public static OpenApiSecurityRequirement ConfigureSecurityRequirement()
{
return new OpenApiSecurityRequirement
{
{
new OpenApiSecurityScheme
{
Reference = new OpenApiReference
{
Id = "Bearer",
Type = ReferenceType.SecurityScheme
}
},
Array.Empty<string>()
}
};
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@
namespace Dotnet.Samples.AspNetCore.WebApi.Controllers;

[ApiController]
[Route("[controller]")]
[Route("players")]
[Produces("application/json")]
public class PlayerController(
IPlayerService playerService,
Expand All @@ -32,19 +32,19 @@
[ProducesResponseType<PlayerResponseModel>(StatusCodes.Status201Created)]
[ProducesResponseType(StatusCodes.Status400BadRequest)]
[ProducesResponseType(StatusCodes.Status409Conflict)]
public async Task<IResult> PostAsync([FromBody] PlayerRequestModel player)
{
var validation = await validator.ValidateAsync(player);

if (!validation.IsValid)
{
var errors = validation
.Errors.Select(error => new { error.PropertyName, error.ErrorMessage })
.ToArray();

logger.LogWarning("POST /players validation failed: {@Errors}", errors);
return TypedResults.BadRequest(errors);
}

Check warning on line 47 in src/Dotnet.Samples.AspNetCore.WebApi/Controllers/PlayerController.cs

View check run for this annotation

Codeac.io / Codeac Code Quality

CodeDuplication

This block of 12 lines is too similar to src/Dotnet.Samples.AspNetCore.WebApi/Controllers/PlayerController.cs:169

if (await playerService.RetrieveBySquadNumberAsync(player.SquadNumber) != null)
{
Expand Down Expand Up @@ -94,13 +94,12 @@
}

/// <summary>
/// Retrieves a Player by its ID
/// Retrieves a Player by its internal Id (GUID)
/// </summary>
/// <param name="id">The ID of the Player</param>
/// <param name="id">The internal Id (GUID) of the Player</param>
/// <response code="200">OK</response>
/// <response code="404">Not Found</response>
[Authorize(Roles = "Admin")]
[ApiExplorerSettings(IgnoreApi = true)]
[Authorize]
[HttpGet("{id:Guid}", Name = "RetrieveById")]
[ProducesResponseType<PlayerResponseModel>(StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
Expand All @@ -125,7 +124,7 @@
/// <param name="squadNumber">The Squad Number of the Player</param>
/// <response code="200">OK</response>
/// <response code="404">Not Found</response>
[HttpGet("squadNumber/{squadNumber:int}", Name = "RetrieveBySquadNumber")]
[HttpGet("{squadNumber:int}", Name = "RetrieveBySquadNumber")]
[ProducesResponseType<PlayerResponseModel>(StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<IResult> GetBySquadNumberAsync([FromRoute] int squadNumber)
Expand All @@ -134,15 +133,15 @@
if (player != null)
{
logger.LogInformation(
"GET /players/squad/{SquadNumber} retrieved: {@Player}",
"GET /players/{SquadNumber} retrieved: {@Player}",
squadNumber,
player
);
return TypedResults.Ok(player);
}
else
{
logger.LogWarning("GET /players/squad/{SquadNumber} not found", squadNumber);
logger.LogWarning("GET /players/{SquadNumber} not found", squadNumber);
return TypedResults.NotFound();
}
}
Expand All @@ -167,19 +166,19 @@
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<IResult> PutAsync(
[FromRoute] int squadNumber,
[FromBody] PlayerRequestModel player
)
{
var validation = await validator.ValidateAsync(player);
if (!validation.IsValid)
{
var errors = validation
.Errors.Select(error => new { error.PropertyName, error.ErrorMessage })
.ToArray();

logger.LogWarning(
"PUT /players/{squadNumber} validation failed: {@Errors}",
squadNumber,

Check warning on line 181 in src/Dotnet.Samples.AspNetCore.WebApi/Controllers/PlayerController.cs

View check run for this annotation

Codeac.io / Codeac Code Quality

CodeDuplication

This block of 12 lines is too similar to src/Dotnet.Samples.AspNetCore.WebApi/Controllers/PlayerController.cs:35
errors
);
return TypedResults.BadRequest(errors);
Expand Down
26 changes: 15 additions & 11 deletions src/Dotnet.Samples.AspNetCore.WebApi/Program.cs
Original file line number Diff line number Diff line change
@@ -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;
Expand All @@ -23,13 +23,16 @@
* Logging
* -------------------------------------------------------------------------- */
Log.Logger = new LoggerConfiguration().ReadFrom.Configuration(builder.Configuration).CreateLogger();

/* Serilog ------------------------------------------------------------------ */
builder.Host.UseSerilog();

/* -----------------------------------------------------------------------------
* Services
* -------------------------------------------------------------------------- */
builder.Services.AddControllers();

/* Entity Framework Core ---------------------------------------------------- */
builder.Services.AddDbContextPool<PlayerDbContext>(options =>
{
var dataSource = Path.Combine(AppContext.BaseDirectory, "Data", "players-sqlite3.db");
Expand All @@ -44,20 +47,23 @@
builder.Services.AddScoped<IPlayerRepository, PlayerRepository>();
builder.Services.AddScoped<IPlayerService, PlayerService>();
builder.Services.AddMemoryCache();

/* AutoMapper --------------------------------------------------------------- */
builder.Services.AddAutoMapper(typeof(PlayerMappingProfile));

/* FluentValidation --------------------------------------------------------- */
builder.Services.AddScoped<IValidator<PlayerRequestModel>, 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<OpenApiInfo>());
options.IncludeXmlComments(
Path.Combine(
AppContext.BaseDirectory,
$"{Assembly.GetExecutingAssembly().GetName().Name}.xml"
)
);
options.IncludeXmlComments(SwaggerGenDefaults.ConfigureXmlCommentsFilePath());
options.AddSecurityDefinition("Bearer", SwaggerGenDefaults.ConfigureSecurityDefinition());
options.OperationFilter<AuthorizeCheckOperationFilter>();
});
}

Expand All @@ -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();

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,16 +9,16 @@
</PropertyGroup>

<ItemGroup>
<PackageReference Include="coverlet.collector" Version="6.0.4">
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
<PrivateAssets>all</PrivateAssets>
</PackageReference>
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.13.0" />
<PackageReference Include="xunit" Version="2.9.3" />
<PackageReference Include="xunit.runner.visualstudio" Version="3.0.2">
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
<PrivateAssets>all</PrivateAssets>
</PackageReference>
<PackageReference Include="coverlet.collector" Version="6.0.4">
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
<PrivateAssets>all</PrivateAssets>
</PackageReference>
<PackageReference Include="FluentAssertions" Version="8.2.0" />
<PackageReference Include="Moq" Version="4.20.72" />
</ItemGroup>
Expand Down
26 changes: 13 additions & 13 deletions test/Dotnet.Samples.AspNetCore.WebApi.Tests/packages.lock.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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, )"
}
}
}
Expand Down
Loading