.NET Templates with Optional Content
Overview
tl;dr - just show me the code (and the readme): https://github.com/dahlsailrunner/knowyourtoolset-templates
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:
And here's the same thing in Rider:
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]