Initial endpoint configuration with authentication

This commit is contained in:
2024-08-17 16:38:40 +02:00
parent e579d76560
commit a708ed25e7
23 changed files with 587 additions and 57 deletions

View File

@@ -1,19 +0,0 @@
<Project Sdk="Microsoft.NET.Sdk.Web">
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<UserSecretsId>4bf893d3-0c16-41ec-8b46-2768d841215d</UserSecretsId>
<DockerDefaultTargetOS>Linux</DockerDefaultTargetOS>
<DockerfileContext>..\..</DockerfileContext>
<RootNamespace>Vegasco.Api</RootNamespace>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="8.0.7" />
<PackageReference Include="Microsoft.VisualStudio.Azure.Containers.Tools.Targets" Version="1.20.1" />
<PackageReference Include="Swashbuckle.AspNetCore" Version="6.4.0" />
</ItemGroup>
</Project>

View File

@@ -1,6 +0,0 @@
using Vegasco.Api;
WebApplication.CreateBuilder(args)
.ConfigureServices()
.ConfigureRequestPipeline()
.Run();

View File

@@ -1,29 +0,0 @@
namespace Vegasco.Api;
internal static class StartupExtensions
{
internal static WebApplication ConfigureServices(this WebApplicationBuilder builder)
{
builder.Services.AddEndpointsApiExplorer();
builder.Services.AddSwaggerGen();
builder.Services.AddHealthChecks();
return builder.Build();
}
internal static WebApplication ConfigureRequestPipeline(this WebApplication app)
{
if (app.Environment.IsDevelopment())
{
app.UseSwagger();
app.UseSwaggerUI();
}
app.UseHttpsRedirection();
app.MapHealthChecks("/health");
return app;
}
}

View File

@@ -0,0 +1,31 @@
using FluentValidation;
namespace Vegasco.WebApi.Authentication;
public class JwtOptions
{
public const string SectionName = "JWT";
public string Audience { get; set; } = "";
public string Authority { get; set; } = "";
public string Issuer { get; set; } = "";
public string? NameClaimType { get; set; }
}
public class JwtOptionsValidator : AbstractValidator<JwtOptions>
{
public JwtOptionsValidator()
{
RuleFor(x => x.Audience)
.NotEmpty();
RuleFor(x => x.Authority)
.NotEmpty();
RuleFor(x => x.Issuer)
.NotEmpty();
}
}

10
src/WebApi/Cars/Car.cs Normal file
View File

@@ -0,0 +1,10 @@
namespace Vegasco.WebApi.Cars;
public class Car
{
public Guid Id { get; set; } = Guid.NewGuid();
public string Name { get; set; } = "";
public Guid UserId { get; set; }
}

View File

@@ -0,0 +1,38 @@
using FluentValidation;
using FluentValidation.Results;
using Vegasco.WebApi.Common;
namespace Vegasco.WebApi.Cars;
public static class CreateCar
{
public record Request(string Name);
public record Response(Guid Id, string Name);
public static RouteHandlerBuilder MapEndpoint(IEndpointRouteBuilder builder)
{
return builder
.MapPost("cars", Handler)
.WithTags("Cars");
}
public class Validator : AbstractValidator<Request>
{
public Validator()
{
RuleFor(x => x.Name)
.NotEmpty();
}
}
public static async Task<IResult> Handler(Request request, IEnumerable<IValidator<Request>> validators)
{
List<ValidationResult> failedValidations = await validators.ValidateAllAsync(request);
if (failedValidations.Count > 0)
{
return Results.BadRequest(new HttpValidationProblemDetails(failedValidations.ToCombinedDictionary()));
}
return Results.Ok();
}
}

View File

@@ -0,0 +1,11 @@
namespace Vegasco.WebApi.Common;
public static class Constants
{
public const string AppOtelName = "Vegasco.Api";
public static class Authorization
{
public const string RequireAuthenticatedUserPolicy = "RequireAuthenticatedUser";
}
}

View File

@@ -0,0 +1,124 @@
using Asp.Versioning;
using FluentValidation;
using Microsoft.AspNetCore.Authentication.JwtBearer;
using Microsoft.Extensions.Options;
using OpenTelemetry.Trace;
using System.Diagnostics;
using Vegasco.WebApi.Authentication;
using Vegasco.WebApi.Endpoints;
using Vegasco.WebApi.Endpoints.OpenApi;
namespace Vegasco.WebApi.Common;
public static class DependencyInjectionExtensions
{
/// <summary>
/// Adds all the WebApi related services to the Dependency Injection container.
/// </summary>
/// <param name="services"></param>
public static void AddWebApiServices(this IServiceCollection services)
{
services
.AddMiscellaneousServices()
.AddOpenApi()
.AddApiVersioning()
.AddOtel()
.AddAuthenticationAndAuthorization();
}
private static IServiceCollection AddMiscellaneousServices(this IServiceCollection services)
{
services.AddResponseCompression();
services.AddValidatorsFromAssemblies(
[
typeof(IWebApiMarker).Assembly
], ServiceLifetime.Singleton);
services.AddHealthChecks();
services.AddEndpointsFromAssemblyContaining<IWebApiMarker>();
return services;
}
private static IServiceCollection AddOpenApi(this IServiceCollection services)
{
services.ConfigureOptions<ConfigureSwaggerGenOptions>();
services.AddEndpointsApiExplorer();
services.AddSwaggerGen();
return services;
}
private static IServiceCollection AddApiVersioning(this IServiceCollection services)
{
services.AddApiVersioning(o =>
{
o.DefaultApiVersion = new ApiVersion(1);
o.ApiVersionReader = new UrlSegmentApiVersionReader();
o.ReportApiVersions = true;
})
.AddApiExplorer(o =>
{
o.GroupNameFormat = "'v'V";
o.SubstituteApiVersionInUrl = true;
});
return services;
}
private static IServiceCollection AddOtel(this IServiceCollection services)
{
Activity.DefaultIdFormat = ActivityIdFormat.W3C;
ActivitySource activitySource = new(Constants.AppOtelName);
services.AddSingleton(activitySource);
services.AddOpenTelemetry()
.WithTracing(t =>
{
t.AddAspNetCoreInstrumentation()
.AddHttpClientInstrumentation()
.AddOtlpExporter()
.AddSource(activitySource.Name);
})
.WithMetrics();
return services;
}
private static IServiceCollection AddAuthenticationAndAuthorization(this IServiceCollection services)
{
services.AddOptions<JwtOptions>()
.BindConfiguration(JwtOptions.SectionName)
.ValidateFluently()
.ValidateOnStart();
var jwtOptions = services.BuildServiceProvider().GetRequiredService<IOptions<JwtOptions>>();
services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
.AddJwtBearer(JwtBearerDefaults.AuthenticationScheme, o =>
{
o.Authority = jwtOptions.Value.Authority;
o.TokenValidationParameters.ValidAudience = jwtOptions.Value.Audience;
o.TokenValidationParameters.ValidateAudience = true;
o.TokenValidationParameters.ValidIssuer = jwtOptions.Value.Issuer;
o.TokenValidationParameters.ValidateIssuer = true;
if (!string.IsNullOrWhiteSpace(jwtOptions.Value.NameClaimType))
{
o.TokenValidationParameters.NameClaimType = jwtOptions.Value.NameClaimType;
}
});
services.AddAuthorizationBuilder()
.AddPolicy(Constants.Authorization.RequireAuthenticatedUserPolicy, p => p
.RequireAuthenticatedUser()
.AddAuthenticationSchemes(JwtBearerDefaults.AuthenticationScheme));
return services;
}
}

View File

@@ -0,0 +1,3 @@
namespace Vegasco.WebApi.Common;
public interface IWebApiMarker;

View File

@@ -0,0 +1,62 @@
using Asp.Versioning.ApiExplorer;
using Microsoft.AspNetCore.Localization;
using System.Globalization;
using Vegasco.WebApi.Endpoints;
namespace Vegasco.WebApi.Common;
internal static class StartupExtensions
{
internal static WebApplication ConfigureServices(this WebApplicationBuilder builder)
{
builder.Configuration.AddEnvironmentVariables("Vegasco_");
builder.Services.AddWebApiServices();
WebApplication app = builder.Build();
return app;
}
internal static WebApplication ConfigureRequestPipeline(this WebApplication app)
{
app.UseRequestLocalization(o =>
{
o.SupportedCultures =
[
new CultureInfo("en")
];
o.SupportedUICultures = o.SupportedCultures;
CultureInfo defaultCulture = o.SupportedCultures[0];
o.DefaultRequestCulture = new RequestCulture(defaultCulture);
});
app.UseHttpsRedirection();
app.MapHealthChecks("/health");
app.UseAuthentication();
app.UseAuthorization();
app.MapEndpoints();
if (app.Environment.IsDevelopment())
{
app.UseSwagger();
app.UseSwaggerUI(o =>
{
// Create a Swagger endpoint for each API version
IReadOnlyList<ApiVersionDescription> apiVersions = app.DescribeApiVersions();
foreach (ApiVersionDescription apiVersionDescription in apiVersions)
{
string url = $"/swagger/{apiVersionDescription.GroupName}/swagger.json";
string name = apiVersionDescription.GroupName.ToUpperInvariant();
o.SwaggerEndpoint(url, name);
}
});
}
return app;
}
}

View File

@@ -0,0 +1,94 @@
using FluentValidation;
using FluentValidation.Results;
using Microsoft.Extensions.Options;
namespace Vegasco.WebApi.Common;
public static class ValidatorExtensions
{
/// <summary>
/// Asynchronously validates an instance of <typeparamref name="T"/> against all <see cref="IValidator{T}"/> instances in <paramref name="validators"/>.
/// </summary>
/// <typeparam name="T"></typeparam>
/// <param name="validators"></param>
/// <param name="instance"></param>
/// <returns>The failed validation results.</returns>
public static async Task<List<ValidationResult>> ValidateAllAsync<T>(this IEnumerable<IValidator<T>> validators, T instance)
{
var validationTasks = validators
.Select(validator => validator.ValidateAsync(instance))
.ToList();
await Task.WhenAll(validationTasks);
List<ValidationResult> failedValidations = validationTasks
.Select(x => x.Result)
.Where(x => !x.IsValid)
.ToList();
return failedValidations;
}
public static Dictionary<string, string[]> ToCombinedDictionary(this IEnumerable<ValidationResult> validationResults)
{
// Use a hash set to avoid duplicate error messages.
Dictionary<string, HashSet<string>> combinedErrors = [];
foreach (var error in validationResults.SelectMany(x => x.Errors))
{
if (!combinedErrors.TryGetValue(error.PropertyName, out HashSet<string>? value))
{
value = ([error.ErrorMessage]);
combinedErrors[error.PropertyName] = value;
continue;
}
value.Add(error.ErrorMessage);
}
return combinedErrors.ToDictionary(x => x.Key, x => x.Value.ToArray());
}
public static OptionsBuilder<T> ValidateFluently<T>(this OptionsBuilder<T> builder)
where T : class
{
builder.Services.AddTransient<IValidateOptions<T>>(serviceProvider =>
{
var validators = serviceProvider.GetServices<IValidator<T>>() ?? [];
return new FluentValidationOptions<T>(builder.Name, validators);
});
return builder;
}
}
internal class FluentValidationOptions<TOptions> : IValidateOptions<TOptions>
where TOptions : class
{
private readonly IEnumerable<IValidator<TOptions>> _validators;
public string? Name { get; set; }
public FluentValidationOptions(string? name, IEnumerable<IValidator<TOptions>> validators)
{
Name = name;
_validators = validators;
}
public ValidateOptionsResult Validate(string? name, TOptions options)
{
if (name is not null && name != Name)
{
return ValidateOptionsResult.Skip;
}
ArgumentNullException.ThrowIfNull(options);
var failedValidations = _validators.ValidateAllAsync(options).Result;
if (failedValidations.Count == 0)
{
return ValidateOptionsResult.Success;
}
return ValidateOptionsResult.Fail(failedValidations.SelectMany(x => x.Errors.Select(x => x.ErrorMessage)));
}
}

View File

@@ -0,0 +1,39 @@
using Asp.Versioning.Builder;
using Asp.Versioning.Conventions;
using Microsoft.Extensions.DependencyInjection.Extensions;
using Vegasco.WebApi.Cars;
using Vegasco.WebApi.Common;
namespace Vegasco.WebApi.Endpoints;
public static class EndpointExtensions
{
public static IServiceCollection AddEndpointsFromAssemblyContaining<T>(this IServiceCollection services)
{
var assembly = typeof(T).Assembly;
ServiceDescriptor[] serviceDescriptors = assembly
.DefinedTypes
.Where(type => type is { IsAbstract: false, IsInterface: false } &&
type.IsAssignableTo(typeof(IEndpoint)))
.Select(type => ServiceDescriptor.Transient(typeof(IEndpoint), type))
.ToArray();
services.TryAddEnumerable(serviceDescriptors);
return services;
}
public static void MapEndpoints(this IEndpointRouteBuilder builder)
{
ApiVersionSet apiVersionSet = builder.NewApiVersionSet()
.HasApiVersion(1.0)
.Build();
RouteGroupBuilder versionedApis = builder.MapGroup("/v{apiVersion:apiVersion}")
.WithApiVersionSet(apiVersionSet)
.RequireAuthorization(Constants.Authorization.RequireAuthenticatedUserPolicy);
CreateCar.MapEndpoint(versionedApis);
}
}

View File

@@ -0,0 +1,6 @@
namespace Vegasco.WebApi.Endpoints;
public interface IEndpoint
{
void MapEndpoint(IEndpointRouteBuilder builder);
}

View File

@@ -0,0 +1,56 @@
using Asp.Versioning.ApiExplorer;
using Microsoft.AspNetCore.Identity;
using Microsoft.Extensions.Options;
using Microsoft.OpenApi.Models;
using Swashbuckle.AspNetCore.SwaggerGen;
namespace Vegasco.WebApi.Endpoints.OpenApi;
/// <summary>
/// Registers each api version as its own swagger document.
/// </summary>
/// <param name="versionDescriptionProvider"></param>
public class ConfigureSwaggerGenOptions(
IApiVersionDescriptionProvider versionDescriptionProvider)
: IConfigureNamedOptions<SwaggerGenOptions>
{
private readonly IApiVersionDescriptionProvider _versionDescriptionProvider = versionDescriptionProvider;
public void Configure(SwaggerGenOptions options)
{
foreach (ApiVersionDescription description in _versionDescriptionProvider.ApiVersionDescriptions)
{
OpenApiSecurityScheme securityScheme = new()
{
Name = "Bearer",
In = ParameterLocation.Header,
Type = SecuritySchemeType.Http,
Scheme = "bearer",
Reference = new OpenApiReference
{
Id = IdentityConstants.BearerScheme,
Type = ReferenceType.SecurityScheme
}
};
options.AddSecurityDefinition(securityScheme.Reference.Id, securityScheme);
options.AddSecurityRequirement(new OpenApiSecurityRequirement
{
{ securityScheme, Array.Empty<string>() }
});
OpenApiInfo openApiInfo = new()
{
Title = "Vegasco API",
Version = description.ApiVersion.ToString()
};
options.SwaggerDoc(description.GroupName, openApiInfo);
}
}
public void Configure(string? name, SwaggerGenOptions options)
{
Configure(options);
}
}

View File

@@ -0,0 +1,6 @@
namespace Vegasco.WebApi.Endpoints.OpenApi;
public static class SwaggerDocConstants
{
}

6
src/WebApi/Program.cs Normal file
View File

@@ -0,0 +1,6 @@
using Vegasco.WebApi.Common;
WebApplication.CreateBuilder(args)
.ConfigureServices()
.ConfigureRequestPipeline()
.Run();

28
src/WebApi/WebApi.csproj Normal file
View File

@@ -0,0 +1,28 @@
<Project Sdk="Microsoft.NET.Sdk.Web">
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<UserSecretsId>4bf893d3-0c16-41ec-8b46-2768d841215d</UserSecretsId>
<DockerDefaultTargetOS>Linux</DockerDefaultTargetOS>
<DockerfileContext>..\..</DockerfileContext>
<RootNamespace>Vegasco.WebApi</RootNamespace>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Asp.Versioning.Http" Version="8.1.0" />
<PackageReference Include="Asp.Versioning.Mvc.ApiExplorer" Version="8.1.0" />
<PackageReference Include="FluentValidation.DependencyInjectionExtensions" Version="11.9.2" />
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="8.0.7" />
<PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="8.0.7" />
<PackageReference Include="Microsoft.VisualStudio.Azure.Containers.Tools.Targets" Version="1.21.0" />
<PackageReference Include="OpenTelemetry" Version="1.9.0" />
<PackageReference Include="OpenTelemetry.Exporter.OpenTelemetryProtocol" Version="1.9.0" />
<PackageReference Include="OpenTelemetry.Extensions.Hosting" Version="1.9.0" />
<PackageReference Include="OpenTelemetry.Instrumentation.AspNetCore" Version="1.9.0" />
<PackageReference Include="OpenTelemetry.Instrumentation.Http" Version="1.9.0" />
<PackageReference Include="Swashbuckle.AspNetCore" Version="6.7.0" />
</ItemGroup>
</Project>