5 Commits 6bf9c1ad1b ... 63028cc695

Auteur SHA1 Bericht Datum
  Lukas Angerer 63028cc695 Developer exception page and HSTS handling 1 jaar geleden
  Lukas Angerer aabd3bb3eb Environment variable toggle for Swagger UI 1 jaar geleden
  Lukas Angerer 8bab836af9 Implemented validation aspect 1 jaar geleden
  Lukas Angerer 6a655e4963 Fixed module order and updated settings 1 jaar geleden
  Lukas Angerer f4bb0ac0aa JSON and improved Swagger server aspects and new status endpoing 1 jaar geleden

+ 29 - 3
WebTemplate/AppServer.cs

@@ -1,25 +1,37 @@
 using WebTemplate.ServerAspects.Auth;
+using WebTemplate.ServerAspects.Controllers;
 using WebTemplate.ServerAspects.Cors;
+using WebTemplate.ServerAspects.Json;
 using WebTemplate.ServerAspects.Spa;
 using WebTemplate.ServerAspects.Swagger;
+using WebTemplate.ServerAspects.Validation;
+using WebTemplate.Status;
 
 namespace WebTemplate;
 
 public class AppServer
 {
+    /// <summary>
+    /// These are all the modules that are loaded. The order is _somewhat_ important since in many situations it is
+    /// relevant which configuration or middleware is applied first and the modules are applied in the order in
+    /// which they appear here.
+    /// </summary>
     private readonly IList<IAppConfigurationModule> _modules = new List<IAppConfigurationModule>
     {
+        new JsonModule(),
         new AuthModule(),
         new CorsModule(),
-        new SpaRoutingModule(),
         new SwaggerModule(),
-        //new ApiControllersModule(),
+        new SpaRoutingModule(),
+        new ValidationModule(),
+        new StatusEndpointModule(),
+        new ControllersModule(),
     };
 
     public void Start(string[] args)
     {
         var builder = WebApplication.CreateBuilder(args);
-        //builder.Configuration.AddJsonFile()
+        SetupConfiguration(builder.Configuration, builder.Environment);
 
         foreach (var appConfigurationModule in _modules)
         {
@@ -29,6 +41,15 @@ public class AppServer
         var app = builder.Build();
         app.UseHttpsRedirection();
 
+        if (app.Environment.IsDevelopment())
+        {
+            app.UseDeveloperExceptionPage();
+        }
+        else
+        {
+            app.UseHsts();
+        }
+
         foreach (var appConfigurationModule in _modules)
         {
             appConfigurationModule.ConfigureApplication(app);
@@ -36,4 +57,9 @@ public class AppServer
 
         app.Run();
     }
+
+    private void SetupConfiguration(ConfigurationManager configuration, IWebHostEnvironment env)
+    {
+        configuration.AddJsonFile($"~/{env.ApplicationName}.json", optional: true);
+    }
 }

+ 7 - 4
WebTemplate/Program.cs

@@ -1,6 +1,9 @@
-var builder = WebApplication.CreateBuilder(args);
-var app = builder.Build();
 
-app.MapGet("/", () => "Hello World!");
+using WebTemplate;
 
-app.Run();
+var server = new AppServer();
+server.Start(args);
+
+// After launching the application, you can access
+// * https://localhost:7169/swagger/
+// * https://localhost:7169/v1/status

+ 2 - 0
WebTemplate/Properties/launchSettings.json

@@ -5,6 +5,7 @@
       "commandName": "Project",
       "dotnetRunMessages": true,
       "launchBrowser": true,
+      "launchUrl": "http://localhost:5292/v1/status",
       "applicationUrl": "http://localhost:5292",
       "environmentVariables": {
         "ASPNETCORE_ENVIRONMENT": "Development"
@@ -14,6 +15,7 @@
       "commandName": "Project",
       "dotnetRunMessages": true,
       "launchBrowser": true,
+      "launchUrl": "https://localhost:7169/v1/status",
       "applicationUrl": "https://localhost:7169;http://localhost:5292",
       "environmentVariables": {
         "ASPNETCORE_ENVIRONMENT": "Development"

+ 0 - 13
WebTemplate/ServerAspects/Config/ConfigModule.cs

@@ -1,13 +0,0 @@
-namespace WebTemplate.ServerAspects.Config;
-
-public class ConfigModule : IAppConfigurationModule
-{
-    public void ConfigureServices(IServiceCollection services, IConfigurationRoot config)
-    {
-        //config.
-    }
-
-    public void ConfigureApplication(WebApplication app)
-    {
-    }
-}

+ 8 - 0
WebTemplate/ServerAspects/Controllers/ApiSettings.cs

@@ -0,0 +1,8 @@
+namespace WebTemplate.ServerAspects.Controllers;
+
+public class ApiSettings
+{
+    public const string SectionName = "ApiSettings";
+    
+    // Add your API settings here
+}

+ 15 - 0
WebTemplate/ServerAspects/Controllers/ControllersModule.cs

@@ -0,0 +1,15 @@
+namespace WebTemplate.ServerAspects.Controllers;
+
+public class ControllersModule : IAppConfigurationModule
+{
+    public void ConfigureServices(IServiceCollection services, IConfigurationRoot config)
+    {
+        services.AddControllers();
+        services.Configure<ApiSettings>(config.GetSection(ApiSettings.SectionName));
+    }
+
+    public void ConfigureApplication(WebApplication app)
+    {
+        app.MapControllers();
+    }
+}

+ 20 - 0
WebTemplate/ServerAspects/Json/JsonModule.cs

@@ -0,0 +1,20 @@
+using System.Text.Json.Serialization;
+
+namespace WebTemplate.ServerAspects.Json;
+
+public class JsonModule : IAppConfigurationModule
+{
+    public void ConfigureServices(IServiceCollection services, IConfigurationRoot config)
+    {
+        services.ConfigureHttpJsonOptions(opts =>
+        {
+            // Convert enums as their string values instead of integer values
+            var enumConverter = new JsonStringEnumConverter();
+            opts.SerializerOptions.Converters.Add(enumConverter);
+        });
+    }
+
+    public void ConfigureApplication(WebApplication app)
+    {
+    }
+}

+ 40 - 0
WebTemplate/ServerAspects/Swagger/StringEnumSchemaFilter.cs

@@ -0,0 +1,40 @@
+using System.ComponentModel;
+using System.Reflection;
+using Microsoft.OpenApi.Any;
+using Microsoft.OpenApi.Models;
+using Swashbuckle.AspNetCore.SwaggerGen;
+
+namespace WebTemplate.ServerAspects.Swagger;
+
+/// <summary>
+/// This is a custom schema filter that updates the OpenApi definition of enums to specify their _string_ values
+/// instead of their numeric values and also adds a description from a <see cref="DescriptionAttribute"/> to each
+/// value if that attribute is present on the enum.
+/// </summary>
+// ReSharper disable once ClassNeverInstantiated.Global
+public class StringEnumSchemaFilter : ISchemaFilter
+{
+    public void Apply(OpenApiSchema schema, SchemaFilterContext context)
+    {
+        var type = context.Type;
+        if (type.IsEnum)
+        {
+            schema.Type = "string";
+            schema.Format = null;
+            schema.Enum = Enum.GetNames(type).Select(name => new OpenApiString(name)).Cast<IOpenApiAny>().ToList();
+            var fields = type.GetFields(BindingFlags.Public | BindingFlags.Static);
+
+            var description = new StringWriter();
+            foreach (var field in fields)
+            {
+                var descriptionAttribute = field.GetCustomAttribute<DescriptionAttribute>();
+                if (descriptionAttribute != null)
+                {
+                    description.WriteLine($"* `{field.Name}` - {descriptionAttribute.Description}");
+                }
+            }
+
+            schema.Description = description.ToString();
+        }
+    }
+}

+ 27 - 2
WebTemplate/ServerAspects/Swagger/SwaggerModule.cs

@@ -1,3 +1,5 @@
+using System.Reflection;
+
 namespace WebTemplate.ServerAspects.Swagger;
 
 public class SwaggerModule : IAppConfigurationModule
@@ -6,12 +8,35 @@ public class SwaggerModule : IAppConfigurationModule
 	{
 		// Learn more about configuring Swagger/OpenAPI at https://aka.ms/aspnetcore/swashbuckle
 		services.AddEndpointsApiExplorer();
-		services.AddSwaggerGen();
+		services.AddSwaggerGen(swaggerGen =>
+		{
+			swaggerGen.SupportNonNullableReferenceTypes();
+			swaggerGen.EnableAnnotations();
+			// The StringEnumSchemaFilter changes the representation of enums from numbers to strings in
+			// the OpenApi document. This assumes that the JSON serializer has ben configured to use the
+			// JsonStringEnumConverter - the two play hand-in-hand.
+			swaggerGen.SchemaFilter<StringEnumSchemaFilter>();
+			
+			// This requires the <GenerateDocumentationFile> property to be set to true in the
+			// .csrpoj file
+			var xmlFilename = $"{Assembly.GetExecutingAssembly().GetName().Name}.xml";
+			var xmlPath = Path.Combine(AppContext.BaseDirectory, xmlFilename);
+			if (File.Exists(xmlPath))
+			{
+				swaggerGen.IncludeXmlComments(xmlPath);
+			}
+		});
 	}
 
 	public void ConfigureApplication(WebApplication app)
 	{
-		if (app.Environment.IsDevelopment())
+		// OpenApi documents and the Swagger UI should generally not be exposed in production environments based
+		// on the assumption that production environments are _publicly_ accessible. That is not always the case
+		// and for somewhat protected scenarios, exposing the API documentation can be very helpful which is why
+		// we are not using the default "IsDevelopment" check, but instead provide an environment variable that
+		// can be used to disable Swagger when necessary. Undocumented APIs are just not that useful.
+		//if (app.Environment.IsDevelopment())
+		if (Environment.GetEnvironmentVariable("SVC_DISALBE_SWAGGER") != "1")
 		{
 			app.UseSwagger();
 			app.UseSwaggerUI();

+ 39 - 0
WebTemplate/ServerAspects/Validation/ValidationFilter.cs

@@ -0,0 +1,39 @@
+using FluentValidation;
+
+namespace WebTemplate.ServerAspects.Validation;
+
+/// <summary>
+/// Since the FluentValidation.AspNetCore package is no longer supported and the documentation under
+/// https://docs.fluentvalidation.net/en/latest/aspnet.html?highlight=asp.net%20core#using-a-filter suggests using
+/// a custom filter, this is what we are doing.
+///
+/// The code is heavily inspired by a "coding short" video by Shawn Wildermuth but customized a bit.
+/// https://www.youtube.com/watch?v=_S-r6SxLGn4
+/// </summary>
+/// <typeparam name="T">The object type that should be validated.</typeparam>
+public class ValidationFilter<T> : IEndpointFilter
+{
+    public async ValueTask<object?> InvokeAsync(EndpointFilterInvocationContext ctx, EndpointFilterDelegate next)
+    {
+        var validator = ctx.HttpContext.RequestServices.GetService<IValidator<T>>();
+        if (validator is null)
+        {
+            return Results.Problem($"No validator found for type {typeof(T).Name}");
+        }
+        
+        var entity = ctx.Arguments
+            .OfType<T>()
+            .FirstOrDefault(a => a?.GetType() == typeof(T));
+        if (entity is not null)
+        {
+            var validation = await validator.ValidateAsync(entity);
+            if (validation.IsValid)
+            {
+                return await next(ctx);
+            }
+            return Results.ValidationProblem(validation.ToDictionary());
+        }
+        
+        return Results.Problem("Could not find type to validate");
+    }
+}

+ 24 - 0
WebTemplate/ServerAspects/Validation/ValidationModule.cs

@@ -0,0 +1,24 @@
+using FluentValidation;
+
+namespace WebTemplate.ServerAspects.Validation;
+
+/// <summary>
+/// For minimal APIs, you can just add the <see cref="ValidationFilter{T}"/> to any mapped endpoint by doing something
+/// like
+/// <code>
+///   .AddEndpointFilter&lt;ValidationFilter&lt;MyRequest&gt;&gt;()
+/// </code>
+/// </summary>
+public class ValidationModule : IAppConfigurationModule
+{
+    public void ConfigureServices(IServiceCollection services, IConfigurationRoot config)
+    {
+        // If you add a validator as described in https://github.com/FluentValidation/FluentValidation?tab=readme-ov-file
+        // that validator should automatically be registered with this.
+        services.AddValidatorsFromAssemblyContaining(typeof(ValidationModule));
+    }
+
+    public void ConfigureApplication(WebApplication app)
+    {
+    }
+}

+ 17 - 0
WebTemplate/Status/EnvironmentInfo.cs

@@ -0,0 +1,17 @@
+namespace WebTemplate.Status;
+
+public record EnvironmentInfo
+{
+    public string ApplicationName { get; }
+    public string EnvironmentName { get; }
+    public string ContentRootPath { get; }
+    public string WebRootPath { get; }
+    
+    public EnvironmentInfo(IWebHostEnvironment env)
+    {
+        ApplicationName = env.ApplicationName;
+        EnvironmentName = env.EnvironmentName;
+        ContentRootPath = env.ContentRootPath;
+        WebRootPath = env.WebRootPath;
+    }
+}

+ 10 - 0
WebTemplate/Status/ServiceStatus.cs

@@ -0,0 +1,10 @@
+namespace WebTemplate.Status;
+
+/// <summary>
+/// Overall service status result.
+/// </summary>
+/// <param name="Status">The value "OK" if everything is OK. Otherwise this service will not actually return a 200
+/// HTTP result</param>
+/// <param name="Version">Detailed version information</param>
+/// <param name="Environment">Collection of environment variables. Only enabled in _Development_ mode</param>
+public record ServiceStatus(string Status, VersionInfo Version, EnvironmentInfo Environment);

+ 24 - 0
WebTemplate/Status/StatusEndpointModule.cs

@@ -0,0 +1,24 @@
+namespace WebTemplate.Status;
+
+public class StatusEndpointModule : IAppConfigurationModule
+{
+    public void ConfigureServices(IServiceCollection services, IConfigurationRoot config)
+    {
+    }
+
+    public void ConfigureApplication(WebApplication app)
+    {
+        app.MapGet("/v1/status", () => new ServiceStatus("OK", new VersionInfo(), new EnvironmentInfo(app.Environment)))
+            .WithName("StatusService")
+            .WithOpenApi(operation =>
+            {
+                operation.Description =
+                    """
+                    Returns the overall service status, including version and environment information. This can be used
+                    as a health check endpoint.
+                    """;
+
+                return operation;
+            });
+    }
+}

+ 19 - 0
WebTemplate/Status/VersionInfo.cs

@@ -0,0 +1,19 @@
+namespace WebTemplate.Status;
+
+public record VersionInfo
+{
+    public string AssemblyVersion { get; }
+    public string Version { get; }
+    public string Commit { get; }
+    public string Branch { get; }
+    public bool IsDirty { get; }
+    
+    public VersionInfo()
+    {
+        AssemblyVersion = typeof(VersionInfo).Assembly.GetName().Version?.ToString() ?? "<unknown>";
+        Version = ThisAssembly.Git.Tag;
+        Commit = ThisAssembly.Git.Sha;
+        Branch = ThisAssembly.Git.Branch;
+        IsDirty = ThisAssembly.Git.IsDirty;
+    }
+}

+ 9 - 0
WebTemplate/WebTemplate.csproj

@@ -4,12 +4,21 @@
     <TargetFramework>net8.0</TargetFramework>
     <Nullable>enable</Nullable>
     <ImplicitUsings>enable</ImplicitUsings>
+    <GenerateDocumentationFile>true</GenerateDocumentationFile>
+    <NoWarn>$(NoWarn);1591</NoWarn>
   </PropertyGroup>
 
   <ItemGroup>
+    <PackageReference Include="FluentValidation" Version="11.9.0" />
+    <PackageReference Include="FluentValidation.DependencyInjectionExtensions" Version="11.9.0" />
+    <PackageReference Include="GitInfo" Version="3.3.4">
+      <PrivateAssets>all</PrivateAssets>
+      <IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
+    </PackageReference>
     <PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="8.0.1" />
     <PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="8.0.1" />
     <PackageReference Include="Swashbuckle.AspNetCore" Version="6.5.0" />
+    <PackageReference Include="Swashbuckle.AspNetCore.Annotations" Version="6.5.0" />
   </ItemGroup>
 
 </Project>

+ 4 - 4
WebTemplate/appsettings.json

@@ -7,13 +7,13 @@
   },
   "AllowedHosts": "*",
   "Auth":{
-    "Authority": "https://dev-2ls6voifhbt37usw.eu.auth0.com/",
-    "Audience": "https://runners.larcanum.net",
+    "Authority": "https://my-authority.net/",
+    "Audience": "https://my-application.net",
     "PolicyClaims": {
-      "Tracks": ["manage:tracks"]
+      "Sample": ["sample"]
     }
   },
   "Cors": {
-    "Origins": ["http://localhost:4200", "https://gpx.studio"]
+    "Origins": ["http://localhost:4200"]
   }
 }

+ 16 - 0
WebTemplate/wwwroot/index.html

@@ -0,0 +1,16 @@
+<!DOCTYPE html>
+
+<html lang="en">
+<head>
+    <meta charset="utf-8">
+
+    <title>Default Page</title>
+    <meta name="description" content="Description">
+</head>
+
+<body>
+<h1>Default Page</h1>
+<p>Nothing to see here.</p>
+</body>
+
+</html>