Setup Plugin – Part 2

Setup Plugin – Part 2

Last time I laid out the justification for a library to generate UI screens, for editing the setup of a Blazor website. This time, I’ll dive deeper into the overall design and start laying out my code.

One of the things on my wish list, from the last article, was the ability to load our new library, at runtime. For that reason, I’ll be creating a new Blazor plugin. To do that, I’ll start by creating a typical .NET Razor class library, using .NET 5.0 and Visual Studio. I’ll then add a reference to the following libraries:

  • CG.Blazor.Plugins – for the plugin support we’ll need.
  • CG.Blazor.Forms – for the dynamic form generation we’ll need.
  • CG.Alerts – for the notification capabilities we’ll need.

After that, I’ll create a class named Module, like this:

public class Module : ModuleBase
{
    public override void ConfigureServices(
        IServiceCollection serviceCollection,
        IConfiguration configuration
        )
    {
        // Validate the parameters before attempting to use them.
        Guard.Instance().ThrowIfNull(serviceCollection, nameof(serviceCollection))
            .ThrowIfNull(configuration, nameof(configuration));

        // Configure (and unprotect) the plugin options.
        serviceCollection.ConfigureOptions<PluginOptions>(
            DataProtector.Instance(), // <-- default data protector.
            configuration,
            out var options
            );

        // We'll need the model.
        serviceCollection.AddSetupConfiguration(
            Type.GetType(options.ModelType, true)
            );

        // We'll rewrite urls, as the need arises.
        serviceCollection.AddSingleton<SetupModeRule>();
            
        // We'll raise alerts, as the need arises.
        serviceCollection.AddAlertServices(options =>
        {
            options.AddCustomAlertType<SetupChangedAlert>();
        });

        // We'll generate our own forms.
        serviceCollection.AddFormGeneration();
    }

    public override void Configure(
        IApplicationBuilder app,
        IWebHostEnvironment env
        )
    {
        // Validate the parameters before attempting to use them.
        Guard.Instance().ThrowIfNull(app, nameof(app))
            .ThrowIfNull(env, nameof(env));

        // Get the plugin options.
        var pluginOptions = app.ApplicationServices.GetRequiredService<
            IOptions<PluginOptions>
            >();

        // Should we wire up a url rewriter rule?
        if (pluginOptions.Value.RedirectPages)
        {
            // Get the setup rule.
            var rule = app.ApplicationServices.GetRequiredService<
                SetupModeRule
                >();

            // Add the rule to the framework.
            app.UseRewriter(
                new RewriteOptions()
                    .Add(rule)
                );
        }

        // Start the alert services.
        app.UseAlertServices(env);
    }
}

As we can see, Module derives from ModuleBase, which is part of CG.Blazor.Plugins and is the base for all plugin module types. A module class, like this, is a stand-in for the Startup class that is, traditionally, part of a typical Blazor application. Since our plugin can’t ever reach Startup, we place our start up related code here, in Module, and the CG.Blazor.Plugins library takes care of making sure the code gets called, at runtime.

The first method is called ConfigureServices and it does exactly what it’s Startup counterpart would, namely, registering types with the DI container. In our case, we call ConfigureOptions to read in our plugin’s options, bind them to a PluginOptions model, then unprotect any encrypted properties on that model. The resulting model is then validated and registered, with the DI container, as a singleton service.

The next thing we do is call the AddSetupConfiguration method, which registers whatever type was specified, for a model, in the plugin’s configuration settings. The result of the call to AddSetupConfiguration is that the model data will be read from our private JSON file, bound to an instance of the model, then any sensitive properties will be un-protected, then the model will be validated, and finally, the model will be registered with the DI container, as a singleton service.

The next thing we do is register the SetupModelRule type, with the DI container, as a singleton service. SetupModeRule is still a work in progress but just know that I’m trying to figure out how to ensure that, if the website is ever run without a setup file, that the UI will redirect to the setup page and stay there until the user fills out the form and saves it. That way, there’s never a chance that an installation of our website could run with a half-baked setup. Like I say though, that part is still a work in progress …

The next thing we do is call AddAlertServices. That method registers an alert service for our plugin. We also register a custom alert, called SetupChangedAlert, which is what we’ll raise whenever a user saves a change to the setup. Without the use of alerts, the calling application would have to either manually watch the setup file, or, poll for changes to the file. Both of those options suck, quite frankly. Alerts are a much better alternative.

Finally, we call AddFormGeneration. That method registers the dynamic form generation engine inside the CG.Blazor.Forms library. That’s the service we’ll use to generate our setup UI, at runtime.

The next method, on Module, is called Configure, and it does exactly what it’s Startup counterpart does, which is call any startup or pipeline logic needed by our code. In our case, we start by grabbing an instance of the PluginOptions we registered earlier, then we check to see if the RedirectPages flag is set. If it is, we then create an instance of the SetupModeRule type I spoke of earlier, then we direct Blazor to use that URL rewrite rule with a call to UseRewriter. The intention here is, if the flag is set, the plugin will do it’s best to force the user to fill out and save the setup before it let’s normal operations begin. If not, it allows the website to come up normally, even if there might not be a valid setup, yet.

Like I mentioned before, the redirection idea is still a work in progress. I simply show the code here, in this walkthrough, to be complete. Hopefully, by the time I publish this article, I will have either solved my issues with it, or removed it from the library.

Now that we have a plugin library with a Module class to get everything registered and started, the next thing we’ll need is the setup page itself. Let’s go look at that next. Here is the markup for the page:

@page "/setup"

<div class="setup-wrapper">
    <div class="container" style="max-width: 640px;">
        @if (false == string.IsNullOrEmpty(Error))
        {
            <p class="pb-2 px-8 alert alert-danger" role="alert">@Error</p>
        }

        @if (false == string.IsNullOrEmpty(Information))
        {
            <pre class="pb-2 px-8 alert alert-success" role="alert">@Information</pre>
        }
        <DynamicForm Model="@Model" 
                     OnValidSubmit="OnValidSubmit" />
    </div>
</div>

Aside from some DIVs to provide better styling options, and show any information or errors, the heart of the page is the call to DynamicForm. That component is part of the CG.Blazor.Forms library, and is how we dynamically generate the actual form, at runtime. Note that we are binding the Model parameter to the Model property, and we are using the OnValidSubmit callback when the user clicks the submit button. Let’s go look at that, and everything else, on the code-behind:

public partial class Setup
{
    [Inject]
    protected IOptions<PluginOptions> PluginOptions { get; set; }

    [Inject]
    protected ILogger<Setup> Logger { get; set; }

    [Inject]
    protected IServiceProvider ServiceProvider { get; set; }

    [Inject] 
    protected IDataProtectionProvider DataProtectionProvider { get; set; }

    [Inject]
    protected IAlertService AlertService { get; set; }

    protected object Model { get; set; }
    protected string Error { get; set; }
    protected string Information { get; set; }

    protected async Task OnValidSubmit(EditContext editContext)
    {
        try
        {
            // No information, no error.
            Error = string.Empty;
            Information = string.Empty;

            // Make a quick clone of the model.
            var protectedModel = Model.QuickClone();

            // Protect any secrets in the model.
            DataProtector.Instance().ProtectProperties(
                protectedModel
                );

            // Serialize the model to JSON.
            var json = JsonSerializer.Serialize(
                protectedModel,
                new JsonSerializerOptions()
                {
                    WriteIndented = true // Make purdy JSON.
                });

            // Write out the JSON.
            await File.WriteAllTextAsync(
                "appsetup.json", 
                json
                ).ConfigureAwait(false);

            // Tell the UI what we did.
            Information = "Setup was changed.\r\n" +
                "NOTE: The alert was raised to restart the host.\r\n";

            // Give the UI time to update.
            await Task.Delay(
                2000
                ).ConfigureAwait(false);

            // Tell the world what we just did.
            await AlertService.RaiseAsync<SetupChangedAlert>()
                .ConfigureAwait(false);
        }
        catch (Exception ex)
        {
            // Alert the UI.
            Error = $"Failed to save changes to the setup!";

            // Tell the world what happened.
            Logger.LogError(
                ex,
                "Failed to save changes to the setup! " +
                "See internal exception(s) for more detail."
                );
        }
    }

	protected override void OnInitialized()
	{
        try
        {
            // No information, no error.
            Error = string.Empty;

            // Get the type for the model.
            var modelType = Type.GetType(
                PluginOptions.Value.ModelType,
                true
                );

            // Get the model.
            Model = ServiceProvider.GetRequiredService(
                modelType
                );

            // Give the base class a chance.
            base.OnInitialized();
        }
        catch (Exception ex)
        {
            // Alert the UI.
            Error = $"Failed to initialize!";

            // Log the error.
            Logger.LogError(
                ex,
                "Failed to initialize! " +
                "See internal exception(s) for more detail."
                );
        }
    }
}

I think most of the properties speak for themselves – just services we’ll need to implement the setup page.

The first method we’ll look at is OnInitialized. The first thing we do there is clear any previous error information that might be left in the Error property. Probably overkill, now that I think about it, since OnInitialized is only called once, but, pfft, whatever.

We then get the model type, that is specified in the PluginOptions. That is the type of POCO model the caller wants us to use for generating the UI.

Next, we get an instance of the model using the service provider. Recall that we previously registered that model type in the ConfigureServices method of the Module class. That’s how we can magically call up an instance of the model now, when we need it. Once we have the model instance we assigned it to the Model property. That, in turn, binds it to the DynamicForm component, on our markup. That means, when the form is eventually generated, our model will be bound to the various controls on that form, and everything will just update without fuss. It also means, when the user brings up the setup page, the controls will automatically show the contents of our private setup file.

The next method we’ll look at is OnValidSubmit. This is probably the most interesting part of the plugin, in my opinion. We start by clearing any previous information or errors, just in case there was any old stuff in those properties. Next, we do a QuickClone of the model to get a version that we can encrypt. Next we encrypt any sensitive properties using the call to ProtectProperties. Then we serialize the object to JSON. Then we write the JSON to our private setup file. Then we update the Information property, to let the caller know we saved the changes. Then we delay a bit, to give the user time to see the message we just displayed. Then we raise the SetupChangedAlert alert.

The end result of all that is, we will have encrypted any sensitive parts of the setup, serialize it to JSON, written it to our private setup file, and notified anyone listening that we saved the changes.

The only other class I want to cover in this article is the PluginOptions class, which looks like this:

public class PluginOptions : OptionsBase
{
    public string ModelType { get; set; }
    public bool RedirectPages { get; set; }
}

So as we can see, the options for the plugin currently include the POCO model type, and a flag for the redirection that I spoke about earlier. The class derives from my OptionsBase class, which is part of the CG.Options library and is part of how I do things like validate properties.

I’m going to stop here because I’m about to venture into the code that listens for the SetupChangedAlert alert, and restarts the host whenever the setup changes. I’ll pick all that up next time;

Photo by frank mckenna on Unsplash