Poor man display errors–part 2 – API

The idea of previous post was that I want to display the errors from a WebApplication – composed by a backend ( WebAPI .NET Core )and frontend( Blazor )

In this post I will show what modifications I must do in the API – code in .NET Core . There are 3 steps

Step1 : Intercept Errors

First I should intercept ( via a middleware ) all errors that can occur in an WebAPI.



using NLog.Web;

namespace XP.API;

public class LogErrorsMiddleware : IMiddleware
{
    static LogErrorsMiddleware()
    {
        logger = NLog.LogManager.GetCurrentClassLogger();
    }
    private static NLog.Logger logger;

    public LogErrorsMiddleware()
    {
        
    }
    public Task InvokeAsync(HttpContext context, RequestDelegate next)
    {
        try
        {
            logger.Debug("Starting "+ context.Request.Path);
            return next(context);

        }
        catch(Exception ex)
        {
            // Log the exception
            logger.Error(ex, "An error occurred while processing the request.");
            
            throw;
        }
        finally
        {
            logger.Debug("Ending " + context.Request.Path);
        }
    }
}

And I register in program.cs

builder.Services.AddTransient<LogErrorsMiddleware>();
//other statemtents
app.UseMiddleware<XP.API.LogErrorsMiddleware>();

Step2 : Add errors to memory

Now we should register the errors from the logging framework into the memory . NLog has a target for registering in memory
( https://github.com/NLog/NLog/wiki/Memory-target ) . ( Serilog has https://github.com/serilog-contrib/SerilogSinksInMemory)

So this is the nlog xml


<?xml version="1.0" encoding="utf-8" ?>
<nlog xmlns="http://www.nlog-project.org/schemas/NLog.xsd" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" autoReload="true" throwConfigExceptions="true" internalLogLevel="Info" internalLogFile="c:\temp\internal-nlog-AspNetCore.txt">

	<!-- enable asp.net core layout renderers -->
	<extensions>
		<add assembly="NLog.Web.AspNetCore"/>
	</extensions>

	<!-- the targets to write to -->
	<targets>
		<target xsi:type="Memory" name="stringData" MaxLogsCount="200" layout="${longdate}|${aspnet-user-isauthenticated}|${aspnet-user-identity}|${level:uppercase=true}|${logger}|${message:withexception=true}" />
		<!-- File Target for all log messages with basic details -->
		<target xsi:type="File" name="allfile" fileName="c:\temp\nlog-AspNetCore-all-${shortdate}.log" layout="${longdate}|${event-properties:item=EventId:whenEmpty=0}|${level:uppercase=true}|${logger}|${message} ${exception:format=tostring}" />

		<!-- File Target for own log messages with extra web details using some ASP.NET core renderers -->
		<target xsi:type="File" name="ownFile-web" fileName="c:\temp\nlog-AspNetCore-own-${shortdate}.log" layout="${longdate}|${event-properties:item=EventId:whenEmpty=0}|${level:uppercase=true}|${logger}|${message} ${exception:format=tostring}|url: ${aspnet-request-url}|action: ${aspnet-mvc-action}" />

		<!--Console Target for hosting lifetime messages to improve Docker / Visual Studio startup detection -->
		<target xsi:type="Console" name="lifetimeConsole" layout="${MicrosoftConsoleLayout}" />
	</targets>

	<!-- rules to map from logger name to target -->
	<rules>
		<!-- All logs, including from Microsoft -->
		<!--<logger name="*" minlevel="Trace" writeTo="allfile" />-->

		<!-- Suppress output from Microsoft framework when non-critical -->
		<logger name="System.*" finalMinLevel="Warn" />
		<logger name="Microsoft.*" finalMinLevel="Warn" />
		<!-- Keep output from Microsoft.Hosting.Lifetime to console for fast startup detection -->
		<logger name="Microsoft.Hosting.Lifetime*" finalMinLevel="Info" writeTo="lifetimeConsole" />
		<logger name="*" minLevel="Trace" writeTo="lifetimeConsole" />
		<logger name="*" minLevel="Error" writeTo="stringData" />
		<!--<logger name="*" minLevel="Trace" writeTo="ownFile-web" />-->
	</rules>
</nlog>

Step3 : Expose Errors

Now we should expose the API to the outside world. I have considered 2 API, one for retrieving and the other for clearing

app.MapGet("/nlog/memory/{name:alpha}/list", (string name) =>
{
    var target = LogManager.Configuration.FindTargetByName<MemoryTarget>(name);
    var logEvents = target.Logs;
    return logEvents.Select((line, nr) => new
    {
        nr = (nr+1),
        line,

    }).ToArray();
});
    app.MapGet("/nlog/memory/{name}/clear", (string name) =>
    {
        var target = LogManager.Configuration.FindTargetByName<MemoryTarget>(name);
        var logEvents = target.Logs;
        var nr = logEvents.Count;
        logEvents.Clear();
        return (nr.ToString());
    })
    //.ShortCircuit() 
    .WithTags("andrei");

In part three, we’ll explore how to call these endpoints from your Blazor frontend and display errors in a user-friendly way.

Poor Man Display Errors–part 1 – idea

Let’s be honest, debugging can be a pain. You build your beautiful new web app (built with a snazzy backend API like .NET Core and a Blazor frontend – you know the drill!), everything seems great…until it isn’t. Suddenly, you are hitting errors, but you’re stuck hunting for clues in endless log files.

Sound familiar?

Wouldn’t it be amazing to see those errors pop up in real-time, right on your web interface? Imagine knowing exactly what went wrong without leaving the comfort of your application!

So let’s assume you make an Web Application composed , as usually now , from a backend WebAPI (.NET Core) and a frontend (Blazor ). ( What I will show to you could be adapted easy to any technology / framework )

Why not see the errors in real time on the web interface .

How to do this ?

Well, a solution will be to keep the errors in memory on the backend and display on the frontend .

Now, take your favorite logging framework (nlog/log4net/ serilog / others )  and see if it supports this .

I will show the code for Nlog in the next blog post

Execute SqlServer scripts at startup – Aspire 9.1 vs Aspire 9.2

One of the usual things in Software development is creating the database with all the tables,views, stored procedures and anything related . For Aspire 9.1 with Sql Ser, this involved a coordination between some shell scripts – see the code :

var sqlserver = builder.AddSqlServer("sqlserver", paramPass, 1433)
    .WithDbGate()
// Mount the init scripts directory into the container.
.WithBindMount("./sqlserverconfig", "/usr/config")
// Mount the SQL scripts directory into the container so that the init scripts run.
.WithBindMount("../../Scripts/data/sqlserver", "/docker-entrypoint-initdb.d")
// Run the custom entrypoint script on startup.
.WithEntrypoint("/usr/config/entrypoint.sh")

The scripts where in the mount binding. Also, some .gitattributes files was required with

* text=auto
*.sh text eol=lf
*.mod text eol=lf
*.sum text eol=lf

to can execute the .sh entry point. An painfull orchestration just for create tables

For Aspire 9.2 this has been simplified a bit , because of Creation Script for database

var db = sqlserver.AddDatabase("MyDB")
    .WithCreationScript("....")

However , this is has some flaws :

1 .Executing in the timeout of an sql

2, Executing just the last one

You can find the code at SqlServerBuilderExtensions.cs :

 private static async Task CreateDatabaseAsync(SqlConnection sqlConnection, SqlServerDatabaseResource sqlDatabase, IServiceProvider serviceProvider, CancellationToken ct)
 {
     try
     {
         var scriptAnnotation = sqlDatabase.Annotations.OfType<SqlServerCreateDatabaseScriptAnnotation>().LastOrDefault();

         if (scriptAnnotation?.Script == null)
         {
             var quotedDatabaseIdentifier = new SqlCommandBuilder().QuoteIdentifier(sqlDatabase.DatabaseName);
             using var command = sqlConnection.CreateCommand();
             command.CommandText = $"IF ( NOT EXISTS ( SELECT 1 FROM sys.databases WHERE name = @DatabaseName ) ) CREATE DATABASE {quotedDatabaseIdentifier};";
             command.Parameters.Add(new SqlParameter("@DatabaseName", sqlDatabase.DatabaseName));
             await command.ExecuteNonQueryAsync(ct).ConfigureAwait(false);
         }
         else
         {
             using var reader = new StringReader(scriptAnnotation.Script);
             var batchBuilder = new StringBuilder();

             while (reader.ReadLine() is { } line)
             {
                 var matchGo = GoStatements().Match(line);

                 if (matchGo.Success)
                 {
                     // Execute the current batch
                     var count = matchGo.Groups["repeat"].Success ? int.Parse(matchGo.Groups["repeat"].Value, CultureInfo.InvariantCulture) : 1;
                     var batch = batchBuilder.ToString();

                     for (var i = 0; i < count; i++)
                     {
                         using var command = sqlConnection.CreateCommand();
                         command.CommandText = batch;
                         await command.ExecuteNonQueryAsync(ct).ConfigureAwait(false);
   

So I made my own extension,

  [GeneratedRegex(@"^\s*GO(?<repeat>\s+\d{1,6})?(\s*\-{2,}.*)?\s*$", RegexOptions.CultureInvariant | RegexOptions.IgnoreCase)]
  internal static partial Regex GoStatements();
  public static IResourceBuilder<SqlServerDatabaseResource> ExecuteSqlServerScripts(this IResourceBuilder<SqlServerDatabaseResource> db, params IEnumerable<string> sqlScripts)
  {
      var builder = db.ApplicationBuilder;
      builder.Eventing.Subscribe<ResourceReadyEvent>(async (ev, ct) =>
      {
          if (ev.Resource is not SqlServerDatabaseResource dbRes) return;
          if (db.Resource != dbRes) return;
          var cn = await dbRes.ConnectionStringExpression.GetValueAsync(ct);
          if (cn == null) return;
          using var sqlConnection = new SqlConnection();
          sqlConnection.ConnectionString = cn;
          await sqlConnection.OpenAsync(ct);
          foreach (var item in sqlScripts)
          {
              using var reader = new StringReader(item);
              var batchBuilder = new StringBuilder();

              while (reader.ReadLine() is { } line)
              {
                  var matchGo = GoStatements().Match(line);

                  if (matchGo.Success)
                  {
                      // Execute the current batch
                      var count = matchGo.Groups["repeat"].Success ? int.Parse(matchGo.Groups["repeat"].Value, CultureInfo.InvariantCulture) : 1;
                      var batch = batchBuilder.ToString();

                      for (var i = 0; i < count; i++)
                      {
                          using var command = sqlConnection.CreateCommand();
                          command.CommandText = batch;
                          //TODO: modify the timeout
                          command.CommandTimeout = 120;
                          try
                          {
                              await command.ExecuteNonQueryAsync(ct).ConfigureAwait(false);
                          }
                          catch (Exception ex)
                          {
                              //TODO: log
                              Console.WriteLine($"!!!!Error in executing {batch} : {ex.Message}");
                          }
                      }

                      batchBuilder.Clear();
                  }
                  else
                  {
                      // Prevent batches with only whitespace
                      if (!string.IsNullOrWhiteSpace(line))
                      {
                          batchBuilder.AppendLine(line);
                      }
                  }
              }

              // Process the remaining batch lines
              if (batchBuilder.Length > 0)
              {
                  using var command = sqlConnection.CreateCommand();
                  var batch = batchBuilder.ToString();
                  command.CommandText = batch;
                  //TODO: modify the timeout
                  try
                  {
                      await command.ExecuteNonQueryAsync(ct).ConfigureAwait(false);
                  }
                  catch (Exception ex)
                  {
                      //TODO: log

                      Console.WriteLine($"!!!!Error in executing {batch} : {ex.Message}");
                  }
              }
          }
      });
      return db;
  }

And can be executed like this:

var db = sqlserver.AddDatabase("MyDB")
    .WithSqlPadViewerForDB(sqlserver)
    .ExecuteSqlServerScripts(DBFiles.FilesToCreate)

NetCoreUsefullEndpoints-part 14–adding roles and claims and authorization

I have added the current user role and claims to the nuget Usefull Endpoints for .NET Core  .  The endpoints are

api/usefull/user/isInRole/{roleName}

and

api/usefull/user/claims/simple

and

api/usefull/user/authorization

For the first one the code is pretty simple

 route.MapGet("api/usefull/user/isInRole/{roleName}", (HttpContext httpContext, string roleName) =>
        {            
            return httpContext.User?.IsInRole(roleName)??false;

        }).WithTags("NetCoreUsefullEndpoints")
        .WithOpenApi();

For claims and authorization , please see code at ignatandrei/NetCoreUsefullEndpoints: Usefull Endpoints for .NET Core

Aspire 9.x add sql server database viewer

.NET Aspire is a formidable tool to visualize your components and relation between them . Today I will show you how to add a custom visualizer for SqlServer database .

The code for adding a database is pretty simple

var paramPass = builder.AddParameter("password", "P@ssw0rd");
var sqlserver = builder.AddSqlServer("sqlserver", paramPass, 1433)
    .WithDbGate()
;
var db = sqlserver.AddDatabase("NewDB")
;

 

 

The community extension, https://github.com/CommunityToolkit/Aspire, has already an extension, WithDBGate, that adds a viewer for the whole SqlServer . But I want something faster, that adds just for the database . So SqlPad , https://getsqlpad.com/ , that has also a docker container , will be enough. So this is the code

public  static class SqlServerExtensions
{
    /// <summary>
    /// 
    /// </summary>
    /// <param name="db"></param>
    /// <param name="sqlserver"></param>
    /// <returns></returns>
    public static IResourceBuilder<SqlServerDatabaseResource> WithSqlPadViewerForDB(this IResourceBuilder<SqlServerDatabaseResource> db,IResourceBuilder<SqlServerServerResource> sqlserver) 
    {
    var builder = db.ApplicationBuilder;
    
    var sqlpad = builder
.AddContainer("sqlpad", "sqlpad/sqlpad:latest")
.WithEndpoint(5600, 3000, "http")
.WithEnvironment("SQLPAD_AUTH_DISABLED", "true")
.WithEnvironment("SQLPAD_AUTH_DISABLED_DEFAULT_ROLE","Admin")
.WithEnvironment("SQLPAD_ADMIN", "admin@sqlpad.com")

.WithEnvironment("SQLPAD_ADMIN_PASSWORD", "admin")
.WithEnvironment("SQLPAD_CONNECTIONS__sqlserverdemo__name", sqlserver.Resource.Name)
.WithEnvironment("SQLPAD_CONNECTIONS__sqlserverdemo__driver", "sqlserver")
.WithEnvironment("SQLPAD_CONNECTIONS__sqlserverdemo__host", sqlserver.Resource.Name)
.WithEnvironment("SQLPAD_CONNECTIONS__sqlserverdemo__database", db.Resource.Name)
.WithEnvironment("SQLPAD_CONNECTIONS__sqlserverdemo__username", "sa")
.WithEnvironment("SQLPAD_CONNECTIONS__sqlserverdemo__password", sqlserver.Resource.PasswordParameter.Value)


.WithEnvironment("SQLPAD_CONNECTIONS__sqlserverdemo1__name", "SqlMaster")
.WithEnvironment("SQLPAD_CONNECTIONS__sqlserverdemo1__driver", "sqlserver")
.WithEnvironment("SQLPAD_CONNECTIONS__sqlserverdemo1__host", sqlserver.Resource.Name)
.WithEnvironment("SQLPAD_CONNECTIONS__sqlserverdemo1__database", "master")
.WithEnvironment("SQLPAD_CONNECTIONS__sqlserverdemo1__username", "sa")
.WithEnvironment("SQLPAD_CONNECTIONS__sqlserverdemo1__password", sqlserver.Resource.PasswordParameter.Value)
.WithParentRelationship(db)
.WaitFor(db)
.WaitFor(sqlserver)
;
    return db;
}
}

And the code for adding is

  var db = sqlserver.AddDatabase("NewDB")
  .WithSqlPadViewerForDB(sqlserver);

Andrei Ignat weekly software news(mostly .NET)

* indicates required

Please select all the ways you would like to hear from me:

You can unsubscribe at any time by clicking the link in the footer of our emails. For information about our privacy practices, please visit our website.

We use Mailchimp as our marketing platform. By clicking below to subscribe, you acknowledge that your information will be transferred to Mailchimp for processing. Learn more about Mailchimp's privacy practices here.