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

@@ -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)));
}
}