.NET Templates with Optional Content

Overview

tl;dr - just show me the code (and the readme): https://github.com/dahlsailrunner/knowyourtoolset-templates

Updates to Templates from a Previous Post

This post builds upon templates that were described in an earlier post of mine. That post has some background information on templates that you may find useful. This post takes that a bit further into additional usefulness.

The End Result

The updated template that this post describes is an Aspire solution for an API with a database and JWT bearer token authentication.

It includes a choice for the database (PostgreSQL, SQL Server, and SQLite).

It also includes an optional UI that uses the Backend For Frontend (BFF) security pattern with the Duende BFF packages. The only choice for the UI at the time I'm writing this is Angular or None, but you could easily include React, Vue, Blazor, or whatever.

The solution is immediately runnable (just hit F5) and includes some sample data and working authentication with the demo instance of the Duende IdentityServer.

Here's an image of the choices in the Visual Studio "new project" flow:

visual studio::img-med

And here's the same thing in Rider:

rider::img-med

You can also create projects using this template from the command line.

The following will create an Aspire project with an API that uses PostgreSQL as its database and an Angular UI that will call that API via the BFF.

1dotnet new kyt-aspire -D postgres -U angular -o My.Project

The following will create an Aspire project with just an API and a SQLite database (no UI or BFF will be included):

1dotnet new kyt-aspire -D sqlite -o My.Project

Conditional Content for Templates

The foundation of conditional content in .NET templates is giving the user one or more choices when using the template to create a project.

Enabling the choice of database in the template was done with the following in symbols section of the .template.config/template.json file:

 1"Database": {
 2    "type": "parameter",
 3    "description": "Database provider to be used (choose one).",
 4    "datatype": "choice",
 5    "allowMultipleValues": false,
 6    "enableQuotelessLiterals": true,
 7    "choices": [
 8    {
 9        "choice": "postgres",
10        "description": "Uses PostgreSQL as the database provider.",
11        "displayName": "PostgreSQL"
12    },
13    {
14        "choice": "sqlserver",
15        "description": "Uses SQL Server as the database provider.",
16        "displayName": "SQL Server"
17    },
18    {
19        "choice": "sqlite",
20        "description": "Uses SQLite as the database provider (no traces available).",
21        "displayName": "SQLite"
22    }
23    ],
24    "defaultValue": "postgres"
25},

That content will give the user a choice of database, but it doesn't mean the code will actually reflect their choice. To do that, the easiest way I've found was to define "computed symbols" for each choice that is a boolean value reflecting the state of that option.

In the same symbols section, I added this content:

 1"POSTGRESQL": {
 2    "type": "computed",
 3    "value": "Database == postgres"
 4},
 5"MSSQL": {
 6    "type": "computed",
 7    "value": "Database == sqlserver"
 8},
 9"SQLITE": {
10    "type": "computed",
11    "value": "Database == sqlite"
12},

The above defines, for example, a POSTGRESQL symbol that will be true if PostgreSQL has been selected as the database choice.

Updating Source Code to Support Choices

The computed boolean symbols (like POSTGRESQL above) can be used in the template code just like compile-time symbols.

Here's an example that will add the appropriate AppDbContext to the dependency injection container during startup:

 1#if POSTGRESQL
 2        builder.AddNpgsqlDbContext<AppDbContext>("DbConn", configureDbContextOptions: opts =>
 3        {
 4            opts.ConfigureWarnings(warnings => warnings.Ignore(RelationalEventId.PendingModelChangesWarning));
 5            opts.UseQueryTrackingBehavior(QueryTrackingBehavior.NoTracking);
 6        });
 7#endif
 8#if MSSQL
 9        builder.AddSqlServerDbContext<AppDbContext>("DbConn", configureDbContextOptions: opts =>
10        {
11            opts.ConfigureWarnings(warnings => warnings.Ignore(RelationalEventId.PendingModelChangesWarning));
12            opts.UseQueryTrackingBehavior(QueryTrackingBehavior.NoTracking);
13        });
14#endif
15#if SQLITE
16        var dbPath = Path.Join(Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), 
17                                "KnowYourToolset.BackEnd.sqlite");
18        builder.Services.AddDbContext<AppDbContext>(opts => opts
19            .UseSqlite($"Data Source={dbPath}")
20            .ConfigureWarnings(warnings => warnings.Ignore(RelationalEventId.PendingModelChangesWarning))
21            .UseQueryTrackingBehavior(QueryTrackingBehavior.NoTracking));
22#endif

With code like that above in your various .cs files, the instantiated template will only include the code appropriate for the database choice.

Updating Other Files to Support Choices

The above approach works well for .cs files, but doesn't work for Markdown files, .csproj files, or .sln files.

For those types of files you can use syntax like the following example from a .csproj file to include the correct project references:

1<!--#if (SQLITE)-->
2<PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite" Version="9.0.0" />
3<!--#endif-->
4<!--#if (POSTGRESQL)-->
5<PackageReference Include="Aspire.Npgsql.EntityFrameworkCore.PostgreSQL" Version="9.0.0" />
6<!--#endif-->
7<!--#if (MSSQL)-->
8<PackageReference Include="Aspire.Microsoft.EntityFrameworkCore.SqlServer" Version="9.0.0" />
9<!--#endif-->

The same syntax will work for Markdown files and solution files.

Excluding Entire Folders Conditionally

If someone chose "None" as the UI, then we don't want to include the ui-with-bff folder or its contents at all.

That can be accomplished with the following content in the template.json file:

 1"sources": [
 2  {
 3    "modifiers": [
 4      {
 5        "condition": "!(ANGULAR)",
 6        "exclude": ["**/ui-with-bff/**"]
 7      }
 8    ]
 9  }
10],

You can use the approach this for files, directories, or both.

Running the Template Project to Verify

I like to have the template itself in a runnable state, and that wouldn't be the case with all of the conditionals above.

Happily, you can add Debug build constants in the .csproj files where it makes sense:

1<PropertyGroup Condition="'$(Configuration)|$(Platform)'=='Debug|AnyCPU'">
2    <DefineConstants>$(DefineConstants);POSTGRESQL</DefineConstants>
3</PropertyGroup>

To run the template solution locally, you would need to make similar changes in the AppHost and API .csproj files. For example, if you used MSSQL,ANGULAR as the constants in both projects that would run the solution with SQL Server as the database and include the Angular UI with BFF.

Omitting those constants from projects when created can use what we've already reviewed:

Set up a computed symbol in the template.json file that is simply true, then use a conditional in the .csproj files:

1<!--#if (!IsFromTemplate) -->
2<PropertyGroup Condition="'$(Configuration)|$(Platform)'=='Debug|AnyCPU'">
3    <DefineConstants>$(DefineConstants);POSTGRESQL</DefineConstants>
4</PropertyGroup>
5<!--#endif-->

EF Migrations

This is the clumsiest part of this approach. When developing the templates, I used the compile constants to pick a database - say I started with MSSQL to use SQL Server.

Then I would use the following command to generate the EF Core migration for the intial schema:

1dotnet ef migrations add Initial -o Data/Migrations

When I had that in place, I would take the SQL Server-specific content and wrap it in #if MSSQL / #endif lines.

Then I would save the three migration files somewhere else, and repeat the process for another database. That let me create a single set of migration files that would support any database choice.

Setting AppHost as Startup Project

I wanted developers to be able to simply run the solution after instantiation, without having to choose a startup project or any other friction.

The first "trick" here was to make the AppHost project the first one listed in the solution (.sln) file.

The next trick only applied to the condition when the Angular UI was included.

I added this content to the .csproj file for the BFF:

1<PropertyGroup>
2  <!-- File with mtime of last successful npm install -->
3  <NpmInstallStampFile>../node_modules/.install-stamp</NpmInstallStampFile>
4</PropertyGroup>
5<Target Name="NpmInstall" BeforeTargets="BeforeBuild" Inputs="../package.json"
6        Outputs="$(NpmInstallStampFile)" Condition="$(Configuration) == Debug">
7  <Exec Command="npm install" />
8  <Touch Files="$(NpmInstallStampFile)" AlwaysCreate="true" />
9</Target>

This content will execute npm install during a Debug build (I didn't want it executing during a Release build or a publish activity), and it will only execute if the package.json file is newer than the "stamp file" created by this process.

So it will execute the first time you run the solution, and then after that only if you change any package dependencies in package.json.

Opening Readme and Instructions (Visual Studio)

I wanted to have the Readme and Instructions docs open in Visual Studio when the KnowYourToolset Aspire template was used to create a solution.

To do that, I added the following content to the template.json:

 1"primaryOutputs": [
 2    {
 3        "path": "KnowYourToolset.BackEnd.sln"
 4    },
 5    {
 6        "condition": "(HostIdentifier != \"dotnetcli\")",
 7        "path": "readme.md"
 8    },
 9    {
10        "condition": "(HostIdentifier != \"dotnetcli\")",
11        "path": "instructions.md"
12    }
13],
14"postActions": [
15    {
16        "condition": "(HostIdentifier != \"dotnetcli\")",
17        "description": "Opens the readme in the editor",
18        "manualInstructions": [],
19        "actionId": "84C0DA21-51C8-4541-9940-6CA19AF04EE6",
20        "args": {
21        "files": "1;2"
22        },
23        "continueOnError": true
24    }
25]

More Information