This is a 2 part-series.

  • Part 1: Key concepts that you should know when creating a .NET template. If you want to read it, click here
  • Part 2: How to convert a few .NET apps into .NET templates, package them together in a single NuGet pack and use them as templates within Visual Studio.

Just show me the code
As always if you don’t care about the post I have upload the source code on my Github.
Also I have upload the NuGet package to nuget.org.

Creating the MyTechRamblings.Templates package

In the following sections I will be converting 3 apps into .NET templates, package them in a NuGet pack named MyTechRamblings.Templates and showing you how to use them within Visual Studio.

The MyTechRamblings.Templates package will contain 2 solution templates and 1 project template.

Before start coding remember what I said in part 1:

  • Using custom .NET templates within Visual Studio is only available in Visual Studio version 16.8 or higher.
  • Also if you’re creating or using a solution template you need at least Visual Studio version 16.10 or higher.

1. Prerequisites:

I have develop 3 apps beforehand, that’s because in this post I will focus on the process of converting these 3 apps in templates.

The 3 apps I have built are the following ones:

  • NET 5 Web Api

    • It is an entire solution application.
    • It uses a N-layer architecture with 3 layers:
      • WebApi layer.
      • Library layer.
      • Repository layer.
    • It uses the Microsoft.Build.CentralPackageVersions MSBuild SDK. This SDK allows us to manage all the NuGet package versions in a single file. The file can be found in the /build folder.
    • The api has the following features already built-in:
      • HealthChecks
      • Swagger
      • Serilog
      • AutoMapper
      • Microsoft.Identity.Web
      • Dockerfile
    • It includes an Azure Pipelines YAML file and a GitHub Action YAML file that deploys the api into an Azure App Service.

    If you want to take a look at the api source code, click HERE

  • NET 5 Worker Service that consumes RabbitMq messages

    • It is an entire solution application.
    • The application is a BackgroundService that consumes messages from a RabbitMq server. It uses a N-layer architecture with 3 layers:
      • Worker layer.
      • Library layer.
      • Repository layer.
    • It uses the Microsoft.Build.CentralPackageVersions MSBuild SDK. This SDK allows us to manage all the NuGet package versions in a single file. The file can be found in the /build folder.
    • The service has the following features already built-in:
      • Serilog
      • AutoMapper
      • Microsoft.Extensions.Hosting.Systemd
      • Microsoft.Extensions.Hosting.WindowsServices
      • Dockerfile
    • It includes an Azure Pipelines YAML file and a GitHub Action YAML file that deploys the service into an Azure App Service.

    If you want to take a look at the worker source code, click HERE

  • NET Core 3.1 Azure Function that gets triggered by a timer

    • This one is not an entire solution, instead it is just a single project.
    • It is a NET Core 3.1 Azure Function that is triggered by a timer.
    • The function has the following features already built-in:
      • Dependency Injection
      • Logging
    • It includes an Azure Pipelines YAML file and a GitHub Action YAML file that deploys the function into Azure Functions.

    If you want to take a look at the function source code, click HERE

2. Convert the NET 5 Web Api into a template

2.1. Create the template.json file

The first step is to create the template.json file, but before start building it you need to know what you want to parameterize in the template.

After taking a look at the different features I have built on the api, I have come with a list of features that I want to parameterize.

Parameter Name Description Default value
Docker Adds or removes a Dockerfile file. true
ReadMe Adds or removes a README markdown file describing the project. true
Tests Adds or removes the tests projects from the solution. Removes an Integration Test project and a Unit Test project. true
GitHub Adds or removes a GitHub Action file from the solution. This GitHub Action is used to deploy the api into an Azure Web App. false
AzurePipelines Adds or removes an Azure pipeline YAML file from the solution. The pipeline is used to deploy the api into an Azure Web App. true
DeploymentType Specifies how you want to deploy the api. The possible values are DeployAsZip or DeployAsContainer. Depending of the value you choose the content of the deployment pipeline will vary. If you choose to not create neither a GitHub Action nor an Azure Pipeline this parameter is useless. DeployAsZip
AcrName An Azure ACR registry name. Only used if you are going to be deploying using a container. acrcponsndev
AzureSubscriptionName An Azure DevOps Service Endpoint Name. Only used if deploying with Azure Pipelines. cponsn-dev-subscription
AppServiceName The name of the Azure App Service where the app will be deployed. app-svc-demo-dev
Authorization Enables or disables the use of authorization using Microsoft.Identity.Web true
AzureAdTenantId Azure Active Directory Tenant Id. Only necessary if Authorization is enabled. 8a0671e2-3a30-4d30-9cb9-ad709b9c744a
AzureAdDomain Azure Active Directory Domain Name. Only necessary if Authorization is enabled. cpnoutlook.onmicrosoft.com
AzureAdClientId Azure Active Directory App Client Id. Only necessary if Authorization is enabled. fdada45d-8827-466f-82a5-179724a3c268
AzureAdSecret Azure Active Directory App Secret Value. Only necessary if Authorization is enabled. 1234
HealthCheck Enables or disables the use of healthchecks. true
HealthCheckPath HealthCheck api path. Only necessary if HealthCheck is enabled. /health
Swagger Enables or disables the use of Swagger. true
SwaggerPath Swagger api path. Only necessary if Swagger is enabled. api-docs
Contact The contact details to use if someone wants to contact you. Only necessary if Swagger is enabled. user@example.com
CompanyName The name of the company. Only necessary if Swagger is enabled. mytechramblings
CompanyWebsite The website of the comany. Only necessary if Swagger is enabled. www.mytechramblings.com
ApiDescription The description of the api. Only necessary if Swagger is enabled. Put your api info here.
  • If you are working with Docker just set the Docker parameter to true and a Dockerfile will be placed alongside your api.
  • If you want to add some tests in your solution set the Tests parameter to true. A /test folder containing a unit test project and a integration test project will be added inside your solution.
  • If the api is going to be deployed with Azure Devops set the AzurePipelines parameter to true. A /pipelines folder containing a YAML pipeline will be added inside your solution.
  • If the api is going to be deployed with GitHub set the GitHub parameter to true. A /.github folder containing a GitHub Action will be added inside your solution.
  • Depending of how the api is going to be deployed set the DeploymentType parameter accordingly.
    • If you are going to deploy using containers set the value to DeployAsContainer and the deployment pipeline will be updated into a container deployment pipeline.
    • If you are going to deploy using a .zip file set the value to DeployAsZip, and the deployment pipeline will be updated into an artifact deployment pipeline.
  • You can enable or disable features like HealthChecksor Swagger.
  • If you are using Authorization with Azure Active Directory, set the Authorization parameter to true and set the AzureAdTenantId, AzureAdDomain, AzureAdClientId, AzureAdSecret parameters accordingly.

As can be seen from the table below there is also a default value for each parameter. A good tip when building a template with a lot of parameters is to set the default values to your most used scenario, so you won’t need to set them every time you want to scaffold a new app.

After listing which features I wanted to parameterize, here’s the template.json file:

{
    "$schema": "http://json.schemastore.org/template",
    "author": "mytechramblings.com",
    "classifications": [
      "Cloud",
      "Web",
      "WebAPI"
    ],
    "name": "NET 5 WebApi",
    "description": "A WebApi solution.",
    "groupIdentity": "Dotnet.Custom.WebApi",
    "identity": "Dotnet.Custom.WebApi.CSharp",
    "shortName": "mtr-api",
    "defaultName": "WebApi1",
    "tags": {
      "language": "C#",
      "type": "solution"
    },
    "sourceName": "ApplicationName",
    "preferNameDirectory": true,
    "primaryOutputs": [
        { "path": "ApplicationName.sln" }
    ],
    "sources": [
        {
          "modifiers": [
                {
                    "condition": "(!Docker)",
                    "exclude":
                    [
                        "Dockerfile",
                        ".dockerignore"
                    ]
                },
                {
                    "condition": "(!ReadMe)",
                    "exclude": 
                    [
                        "README.md"
                    ]
                },
                {
                    "condition": "(!Tests)",
                    "exclude": 
                    [
                        "test/ApplicationName.Library.Impl.UnitTest/**/*",
                        "test/ApplicationName.WebApi.IntegrationTest/**/*"
                    ]
                },
                {
                    "condition": "(!GitHub)",
                    "exclude": 
                    [
                        ".github/**/*"
                    ]
                },
                {
                    "condition": "(!AzurePipelines)",
                    "exclude": 
                    [
                        "pipelines/**/*"
                    ]
                },
                {
                    "condition": "(!Swagger)",
                    "exclude": 
                    [
                      "src/ApplicationName.WebApi/Extensions/ServiceCollectionExtensions/ServiceCollectionSwaggerExtension.cs"
                    ]
                },
                {
                    "condition": "(!HealthCheck)",
                    "exclude": 
                    [
                      "src/ApplicationName.WebApi/Extensions/ServiceCollectionExtensions/ServiceCollectionHealthChecksExtension.cs",
                      "src/ApplicationName.WebApi/Extensions/ApplicationBuilderExtensions/ApplicationBuilderWriteResponseExtension.cs"
                    ]
                }
            ]
        }
    ],
    "symbols": {
        "Docker": {
            "type": "parameter",
            "datatype": "bool",
            "description": "Adds an optimised Dockerfile to add the ability to build a Docker image.",
            "defaultValue": "true"
        },
        "ReadMe": {
            "type": "parameter",
            "datatype": "bool",
            "defaultValue": "true",
            "description": "Add a README.md markdown file describing the project."
        },
        "Tests": {
            "type": "parameter",
            "datatype": "bool",
            "defaultValue": "true",
            "description": "Adds an integration project and unit test projects."
        },
        "GitHub": {
            "type": "parameter",
            "datatype": "bool",
            "description": "Adds a GitHub action continuous integration pipeline.",
            "defaultValue": "false"
        },
        "AzurePipelines": {
            "type": "parameter",
            "datatype": "bool",
            "description": "Adds an Azure Pipelines YAML.",
            "defaultValue": "true"
        },
        "DeploymentType": {
            "type": "parameter",
            "datatype": "choice",
            "choices": [
              {
                "choice": "DeployAsContainer",
                "description": "The app will be deployed as a container."
              },
              {
                "choice": "DeployAsZip",
                "description": "The app will be deployed as a zip file."
              }
            ],
            "defaultValue": "DeployAsZip",
            "description": "Select how you want to deploy the application."
          },
        "DeployContainer": {
            "type": "computed",
            "value": "(DeploymentType == \"DeployAsContainer\")"
        },
        "DeployZip": {
            "type": "computed",
            "value": "(DeploymentType == \"DeployAsZip\")"
        },
      "AcrName": {
          "type": "parameter",
          "datatype": "string",
          "defaultValue": "acrcponsndev",
          "replaces": "ACR-REGISTRY-NAME",
          "description": "An Azure ACR registry name. Only used if deploying with containers."
      },
      "AzureSubscriptionName": {
          "type": "parameter",
          "datatype": "string",
          "defaultValue": "cponsn-dev-subscription",
          "replaces": "AZURE-SUBSCRIPTION-ENDPOINT-NAME",
          "description": "An Azure Subscription Name. Only used if you are going to be deploying with Azure Pipelines."
      },
      "AppServiceName": {
          "type": "parameter",
          "datatype": "string",
          "defaultValue": "app-svc-demo-dev",
          "replaces": "APP-SERVICE-NAME",
          "description": "The name of Azure App Service."
      },
      "Authorization": {
        "type": "parameter",
        "datatype": "bool",
        "defaultValue": "true",
        "description": "Enables the use of authorization with Microsoft.Identity.Web."
      },
      "AzureAdTenantId":{
        "type": "parameter",
        "datatype": "string",
        "defaultValue": "8a0671e2-3a30-4d30-9cb9-ad709b9c744a",
        "replaces": "AAD-TENANT-ID",
        "description": "Azure Active Directory Tenant Id. Only necessary if Authorization is enabled."
      },
      "AzureAdDomain":{
        "type": "parameter",
        "datatype": "string",
        "defaultValue": "cpnoutlook.onmicrosoft.com",
        "replaces": "AAD-DOMAIN",
        "description": "Azure Active Directory Domain Name. Only necessary if Authorization is enabled."
      },
      "AzureAdClientId":{
        "type": "parameter",
        "datatype": "string",
        "defaultValue": "fdada45d-8827-466f-82a5-179724a3c268",
        "replaces": "AAD-CLIENT-ID",
        "description": "Azure Active Directory App Client Id. Only necessary if Authorization is enabled."
      },
      "AzureAdSecret":{
        "type": "parameter",
        "datatype": "string",
        "defaultValue": "1234",
        "replaces": "AAD-SECRET-VALUE",
        "description": "Azure Active Directory App Secret Value. Only necessary if Authorization is enabled."
      },
      "HealthCheck": {
        "type": "parameter",
        "datatype": "bool",
        "defaultValue": "true",
        "description": "Enables the use of healthchecks."
      },
      "HealthCheckPath": {
        "type": "parameter",
        "datatype": "string",
        "defaultValue": "/health",
        "replaces": "HEALTHCHECK-PATH",
        "description": "HealthCheck path. Only necessary if HealthCheck is enabled."
      },
      "Swagger": {
        "type": "parameter",
        "datatype": "bool",
        "defaultValue": "true",
        "description": "Enable the use of Swagger."
      },
      "SwaggerPath": {
        "type": "parameter",
        "datatype": "string",
        "defaultValue": "api-docs",
        "replaces": "SWAGGER-PATH",
        "description": "Swagger UI Path. Do not add a backslash. Only necessary if Swagger is enabled."
      },
      "Contact": {
        "type": "parameter",
        "datatype": "string",
        "defaultValue": "user@example.com",
        "replaces": "API-CONTACT",
        "description": "The contact details to use if someone wants to contact you. Only necessary if Swagger is enabled."
      },
      "CompanyName": {
        "type": "parameter",
        "datatype": "string",
        "defaultValue": "mytechramblings",
        "replaces": "COMPANY-NAME",
        "description": "The name of the company. Only necessary if Swagger is enabled."
      },
      "CompanyWebsite": {
        "type": "parameter",
        "datatype": "string",
        "defaultValue": "https://www.mytechramblings.com",
        "replaces": "COMPANY-WEBSITE",
        "description": "The website of the company. Needs to be a valid Uri. Only necessary if Swagger is enabled."
      },
      "ApiDescription": {
        "type": "parameter",
        "datatype": "string",
        "defaultValue": "Put your api info here",
        "replaces": "API-DESCRIPTION",
        "description": "The description of the WebAPI. Only necessary if Swagger is enabled."
      }
    },
    "SpecialCustomOperations": {
        "**/*.yml": {
            "operations": [
              {
                "type": "conditional",
                "configuration": {
                  "if": [ "#if" ],
                  "else": [ "#else" ],
                  "elseif": [ "#elseif" ],
                  "endif": [ "#endif" ],
                  "actionableIf": [ "##if" ],
                  "actionableElse": [ "##else" ],
                  "actionableElseif": [ "##elseif" ],
                  "actions": [ "uncomment", "reduceComment" ],
                  "trim": "true",
                  "wholeLine": "true",
                  "evaluator": "C++"
                }
              },
              {
                "type": "replacement",
                "configuration": {
                  "original": "#",
                  "replacement": "",
                  "id": "uncomment"
                }
              },
              {
                "type": "replacement",
                "configuration": {
                  "original": "##",
                  "replacement": "#",
                  "id": "reduceComment"
                }
              }
            ]
        }
    }
}

Let me make an in-depth rundown of the meaning of every value:

  • author: The author of the template.

  • classifications: A set of characteristics of the template that a user might search for it.

    • The classification items will appear as tags in the .NET CLI. You can search templates by classification using the dotnet new --tag <CLASSIFICATION_ITEM> command.
    • You can search templates by classification within Visual Studio using the Project Type dropdown.
  • name: The name of the template. That’s the name that will appear in the .NET CLI and in VS.

  • description: The description of the template. The description will appear in the .NET CLI when you ran the dotnet new <TEMPLATE_NAME> -h command. In VS it will appear in the Create new project dialog.

  • groupIdentity: The ID of the group this template belongs to.

  • identity: A unique ID for this template.

  • shortName: The short name is used for selecting the template in the .NET CLI. The shortName appears when you run dotnet new -l and it is probably the one that you will use the most when you create a new solution using the template. For example you could ceate a new solution using this api template running the dotnet new mtr-api command.
    Right now the shortName has no use in Visual Studio.

  • defaultName: The name that will be used when you create a new solution using the template if no name has been specified.

  • tags: You can add multiple tags but at least you have to specify the language tag and the type tag.

    • The language tag specifies the programming language.
    • The type tag specifies the type of the template project. The possible values are: project, solution, item.
  • sourceName: The template engine will look for any occurrence of the sourceName and replace it. It will rename files if there is an occurrence. It will also replace file content if there is an occurrence.
    I’m using ApplicationName as the sourceName and naming every .csproj with the ApplicationName prefix:

    • ApplicationName.sln
    • ApplicationName.WebApi.csproj
    • ApplicationName.Library.Contracts.csproj
    • ApplicationName.Library.Impl.csproj
    • ApplicationName.Repository.Contracts.csproj
    • ApplicationName.Repository.Impl.csproj

    When you create a new solution using this template every .csproj file and the .sln file will be renamed by the template engine from ApplicationName to the name chosen by the user.
    Also the namespace prefix inside all the .csharp files will be renamed from ApplicationName to the name chosen by the user.

  • preferNameDirectory: Indicates whether to create a directory for the template. If you set the value to false the solution will be placed in your current directory.

  • specialCustomOperations: The templating engine supports conditional operators, but it only supports them in a certain file types. If you want to use conditionals operators in another file types you need to add them here.
    In my template I want to add conditional operators on the YML files, that’s why I’m adding a custom operation that applies to all the yml files (**/*.yml)

  • sources.modifiers : The sources.modifiers allows us to include or exclude files from the solution based on a condition.

  • symbols: The symbols section is where you specify the inputs you want to parameterize and also define the behaviour of those inputs.

The symbols section and the sources.modifiers section is the meat of the template.json file.

I’m not going to try to explain the meaning of every symbol that I have placed on the symbols section, mainly because it will be quite repetitive because most of the symbols are using exactly the same strategy.
Instead of that, I’m going to explain the strategies I have used, so in the end every symbol from the symbols section will drop down in one of the strategies described in the next section.

Symbol replacement

  • Replaces a fixed string with the value of the symbol.

Example:
Here I have the symbol HealthCheckPath of type parameter. It has a default value of /health and a replace value of HEALTHCHECK-VALUE.

  "HealthCheckPath": {
    "type": "parameter",
    "datatype": "string",
    "defaultValue": "/health",
    "replaces": "HEALTHCHECK-PATH",
    "description": "HealthCheck path. Only necessary if HealthCheck is enabled."
  }

When you try to create a new solution using this template:

  • The template engine will try to find the HEALTHCHECK-PATH string anywhere in the template and if it finds it, it will be replaced either by the user input or the default value (/health).
    If you take a look at the Startup.cs, you’ll see the replaces value is hard-coded in the template.
   endpoints.MapHealthChecks("HEALTHCHECK-PATH", new HealthCheckOptions
    {
      ResponseWriter = ApplicationBuilderWriteResponseExtension.WriteResponse
    });

When the user creates a new solution, the template engine will find the magic string HEALTHCHECK-PATH and replaced it by the user input or the defaultValue.

If you take a look at the symbols section from the template.json file, you will find a lot of symbols using this very same strategy but with a different replaces value, this is because that’s the easiest way to update certain parts of the source code with user inputs.

Use conditional operators based on the symbol value

  • It allows us to skip entire chunks of code based on a symbol value.

Example:
Here I have the symbol Authorization of type bool with the default value of true.

  "Authorization": {
    "type": "parameter",
    "datatype": "bool",
    "defaultValue": "true",
    "description": "Enables the use of authorization with Microsoft.Identity.Web."
  },
  • If the user sets the value to false, the template engine needs to remove any Microsoft.Web.Identity reference from the solution.
  • If the user sets this symbol to true, the template engine needs to keep the references to the Microsoft.Web.Identity library.

If you take a look at the Startup.cs, you’ll see that all the Microsoft.Web.Identity references are being wrapped in a conditional operator.

#if Authorization
using Microsoft.AspNetCore.Authentication.JwtBearer;
using Microsoft.Identity.Web;
#endif

...

#if Authorization            
            services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
                .AddMicrosoftIdentityWebApi(Configuration, "AzureAd");
#endif

#if Authorization
            app.UseAuthentication();
            app.UseAuthorization();
#endif

The Microsoft.Web.Identity references are only being kept if the symbol Authorization is set to true.

But that’s not enough, we need to remove the Microsoft.Web.Identity references from the appsettings.json file too.

{
  //#if (Authorization)  
  "AzureAd": {
    "Instance": "https://login.microsoftonline.com/",
    "TenantId": "AAD-TENANT-ID",
    "Domain": "AAD-DOMAIN",
    "ClientId": "AAD-CLIENT-ID",
    "ClientSecret": "AAD-SECRET-VALUE",
    "TokenValidationParameters": {
      "ValidateIssuer": true,
      "ValidIssuer": "https://login.microsoftonline.com/AAD-TENANT-ID/v2.0",
      "ValidateAudience": true,
      "ValidAudiences": [ "AAD-CLIENT-ID" ]
    }
  }
  //#endif
}

And also from the api controller:

#if Authorization
    [Authorize]
#endif
    [ApiVersion("1.0")]
    [ApiController]
    [ProducesResponseType(typeof(ErrorResult),  StatusCodes.Status400BadRequest)]
    [ProducesResponseType(typeof(ErrorResult),  StatusCodes.Status500InternalServerError)]
    [Route("v{version:apiVersion}/[controller]")]
    [Produces("application/json")]
    public class MyServiceController : ControllerBase
    {
    ...
    }

Use a symbol value to compute another one

  • You can set the value of a symbol using the value of aonther one.

Example:

The DeploymentType symbol is of type choice and with its value you can set the value of the DeployContainer symbol and the DeployZip symbol.

 "DeploymentType": {
    "type": "parameter",
    "datatype": "choice",
    "choices": [
      {
        "choice": "DeployAsContainer",
        "description": "The app will be deployed as a container."
      },
      {
        "choice": "DeployAsZip",
        "description": "The app will be deployed as a zip file."
      }
    ],
    "defaultValue": "DeployAsZip",
    "description": "Select how you want to deploy the application."
  },
  "DeployContainer": {
    "type": "computed",
    "value": "(DeploymentType == \"DeployAsContainer\")"
  },
  "DeployZip": {
    "type": "computed",
    "value": "(DeploymentType == \"DeployAsZip\")"
  },

The DeployContainer symbol and the DeployZip symbol are used to tailor the deployment pipeline.

  • If the user sets the DeploymentType symbol to DeployAsContainer, then the DeployContainer symbol is set to true.
    The DeployContainer symbol value is used to create a deployment pipeline that will build and deploy a docker image.
  • If the user sets the DeploymentType symbol to DeployAsZip, then the DeployZip symbol is set to true.
    The DeployZip symbol value is used to create a deployment pipeline that will build and deploy a zipped artifact.

Take a look at how the Azure Pipelines YAML file uses the DeployAsZip and DeployAsContainer symbol value to add a specific pipeline type.

trigger:
- master

pool: 
  vmImage: 'ubuntu-latest'

variables:
  - name: buildConfiguration
    value: 'Release'
  - name: azureSubscription
    value: 'AZURE-SUBSCRIPTION-ENDPOINT-NAME'
  - name: appServiceName
    value: 'APP-SERVICE-NAME'
#if (DeployContainer)
  - name: registryName
    value: 'ACR-REGISTRY-NAME'
#endif

#if (DeployContainer)
steps:
  - task: AzureCLI@2
    displayName: AZ ACR Login
    inputs:
      azureSubscription: $(azureSubscription)
      scriptType: 'bash'
      scriptLocation: 'inlineScript'
      inlineScript: 'az acr login --name $(registryName)'

  - task: AzureCLI@2
    displayName: AZ ACR Build
    inputs:
      azureSubscription: $(azureSubscription)
      scriptType: 'bash'
      scriptLocation: 'inlineScript'
      inlineScript: 'az acr build -t ApplicationName:$(Build.BuildId) -t ApplicationName:latest -r $(registryName) -f Dockerfile .'
      useGlobalConfig: true
      workingDirectory: '$(Build.SourcesDirectory)'

  - task: AzureWebAppContainer@1
    displayName: Deploy to App Service
    inputs:
      azureSubscription: '$(azureSubscription)'
      appName: '$(appServiceName)'
      containers: '$(registryName).azurecr.io/ApplicationName:latest'
#endif

#if (DeployZip)
steps:
- task: DotNetCoreCLI@2
  displayName: Restore
  inputs:
    command: 'restore'
    projects: '**/*.csproj'

- task: DotNetCoreCLI@2
  displayName: Build
  inputs:
    command: 'build'
    projects: '**/*.csproj'
    arguments: '--configuration $(buildConfiguration) --no-restore'

- task: DotNetCoreCLI@2
  displayName: Test
  inputs:
    command: 'test'
    projects: '**/*UnitTest.csproj'
    arguments: '--configuration $(buildConfiguration) --no-restore'

- task: DotNetCoreCLI@2
  displayName: Publish
  inputs:
    command: publish
    publishWebProjects: false
    projects: '**/ApplicationName.WebApi.csproj'
    arguments: '--configuration $(BuildConfiguration) --output $(Build.ArtifactStagingDirectory)'
    zipAfterPublish: True

- task: AzureWebApp@1
  displayName: Deploy Azure Web App
  inputs:
    azureSubscription: '$(azureSubscription)'
    appName: '$(appServiceName)'
    appType: 'webApp'
    package: $(Build.ArtifactStagingDirectory)/**/*.zip
#endif

Take a look at how the GitHub Action YAML file uses the DeployAsZip and DeployAsContainer symbol value to add a specific pipeline type.

name: .NET api deploy to Azure App Service

on:
  push:
    branches: [ master ]
  pull_request:
    branches: [ master ]

env:
  AZURE_WEBAPP_NAME: APP-SERVICE-NAME   
#if (DeployContainer)
  CONTAINER_REGISTRY: ACR-REGISTRY-NAME.azurecr.io 
#endif

#if (DeployContainer)
jobs:
  build-and-deploy-to-dev:
    runs-on: ubuntu-latest
    environment: dev
    steps:
    # Checkout the repo
    - uses: actions/checkout@master

    # Authenticate to Azure
    - name: 'Azure authentication'
      uses: azure/login@v1
      with:
        creds: ${{ secrets.AZURE_CREDENTIALS }}
 
    # Authenticate to ACR
    - name: 'ACR authentication'
      uses: azure/docker-login@v1
      with:
        login-server: ${{ env.CONTAINER_REGISTRY }}
        username: ${{ secrets.REGISTRY_USERNAME }}
        password: ${{ secrets.REGISTRY_PASSWORD }} 

    # Build and push the Docker image
    - name: 'Docker Build & Push to ACR'
      run: |
        docker build . -t ${{ env.CONTAINER_REGISTRY }}/ApplicationName:${{ github.sha }}
        docker push ${{ env.CONTAINER_REGISTRY }}/ApplicationName:${{ github.sha }}         

    # Deploy to Azure
    - name: 'Deploy to Azure Web App for Container'
      uses: azure/webapps-deploy@v2
      with: 
        app-name: ${{ env.AZURE_WEBAPP_NAME }} 
        images: ${{ env.CONTAINER_REGISTRY }}/ApplicationName:${{ github.sha }}
#endif

#if (DeployZip)
jobs:
  build-and-deploy:
    runs-on: ubuntu-latest
    environment: dev
    steps:
      # Checkout the repo
      - uses: actions/checkout@master
      
      # Setup .NET Core SDK
      - name: 'Setup .NET Core'
        uses: actions/setup-dotnet@v1
        with:
          dotnet-version: '5.0.x'
      
      # Run dotnet build and publish
      - name: 'dotnet build and publish'
        run: |
          dotnet restore
          dotnet build --configuration Release
          dotnet publish -c Release -o ${{ github.workspace }}/.output
                    
      # Deploy to Azure Web apps
      - name: 'Run Azure webapp deploy action using publish profile credentials'
        uses: azure/webapps-deploy@v2
        with: 
          app-name: ${{ env.AZURE_WEBAPP_NAME }} # Replace with your app name
          publish-profile: ${{ secrets.AZURE_WEBAPP_PUBLISH_PROFILE  }} # Define secret variable in repository settings as per action documentation
          package: ${{ github.workspace }}/.output
#endif

Excluding files based on a symbol value

  • The source.modifiers section can be combined with the symbols section to include or exclude existing files based on a symbol value.

Example 1:

The Dockerfile symbol is of type parameter and the default value is true.

  "Docker": {
      "type": "parameter",
      "datatype": "bool",
      "description": "Adds an optimised Dockerfile to add the ability to build a Docker image.",
      "defaultValue": "true"
  }
  • If the symbol value is set to false, the template engine needs to remove the Dockerfile and the .dockerignore files from the solution.
  • If the symbol value is set to true, the template engine needs to keep both files.

To achieve the desired behaviour, you can add the following object inside the source.modifiers array.
The Docker symbol value is used as the condition to exclude both files.

  {
      "condition": "(!Docker)",
      "exclude":
      [
          "Dockerfile",
          ".dockerignore"
      ]
  }

The template engine will remove the Dockerfile and the .dockerignore files, if the value of the Docker symbol equals to false.

Example 2:

This is a more interesting example. The Test symbol is of type parameter and the default value is true

  "Tests": {
      "type": "parameter",
      "datatype": "bool",
      "defaultValue": "true",
      "description": "Adds an integration and unit test projects."
  }
  • If the symbol value is set to false, the template engine needs to:
    • Remove the folder that contains the unit test project from the solution.
    • Remove the folder that contains the integration test project from the solution.
    • Remove the test project references from the .sln file.
  • If the symbol value is set to true, the template engines needs to keep the test projects.

To achieve the desired behaviour, you can add the following object in the source.modifiers array.

  {
      "condition": "(!Tests)",
      "exclude": 
      [
          "test/ApplicationName.Library.Impl.UnitTest/**/*",
          "test/ApplicationName.WebApi.IntegrationTest/**/*"
      ]
  },

The Tests symbol value is used as the condition to exclude both test folders.
Also the Tests symbol is used to remove the test project references from the .sln file via a conditional operator.

#if (Tests)
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Test", "Test", "{0323DDF8-0F80-4DF1-8F12-C37353534337}"
EndProject
#endif
...
#if (Tests)
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "ApplicationName.WebApi.IntegrationTest", "test\ApplicationName.WebApi.IntegrationTest\ApplicationName.WebApi.IntegrationTest.csproj", "{205B0B75-C24F-40E4-BA69-46AE61CF9C20}"
EndProject
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "ApplicationName.Library.Impl.UnitTest", "test\ApplicationName.Library.Impl.UnitTest\ApplicationName.Library.Impl.UnitTest.csproj", "{C22AA50D-81CC-4A9B-9926-7AB8445246A9}"
EndProject
#endif

2.2. Create the dotnetcli.host.json file

If your template needs some command line parameters you can customize them by adding a dotnetcli.host.json file inside the .template.config folder.

In the dotnetcli.host.json file you can specify the short and long names for each of the command line parameters.

This step is not mandatory and in my case I have quite a sizeable list of parameters to customize, but nonetheless I prefer to customize each parameter name as I see fit.
Here’s how the dotnetcli.host.json looks:

{
    "$schema": "http://json.schemastore.org/dotnetcli.host",
    "symbolInfo":
    {
        "Docker": {
            "longName": "dockerfile",
            "shortName": "d"
        },
        "ReadMe": {
            "longName": "readme",
            "shortName": "r"
        },
        "Tests": {
            "longName": "tests",
            "shortName": "t"
        },
        "GitHub": {
            "longName": "github",
            "shortName": "ga"
        },
        "AzurePipelines": {
            "longName": "azure-pipelines",
            "shortName": "ap"
        },
        "DeploymentType": {
            "longName": "deploy-type",
            "shortName": "dt"
        },
        "AcrName": {
            "longName": "acr-name",
            "shortName": "acr"
        },
        "AzureSubscriptionName": {
            "longName": "azure-subscription",
            "shortName": "az"
        },
        "AppServiceName": {
            "longName": "app-service-name",
            "shortName": "asn"
        },
        "Authorization": {
            "longName": "authorization",
            "shortName": "auth"
        },
        "AzureAdTenantId":{
            "longName": "aad-tenant-id",
            "shortName": "at"
        },
        "AzureAdDomain":{
            "longName": "aad-domain-name",
            "shortName": "ad"
        },
        "AzureAdClientId":{
            "longName": "aad-client-id",
            "shortName": "ac"
        },
        "AzureAdSecret":{
            "longName": "aad-secret-value",
            "shortName": "as"
        },
        "HealthCheck": {
            "longName": "healthcheck",
            "shortName": "hck"
        },
        "HealthCheckPath": {
            "longName": "healthcheck-path",
            "shortName": "hp"
        },
        "Swagger": {
            "longName": "swagger",
            "shortName": "s"
        },
        "SwaggerPath": {
            "longName": "swagger-path",
            "shortName": "sp"
        },
        "Contact": {
            "longName": "contact-mail",
            "shortName": "cm"
        },
        "CompanyName": {
            "longName": "company-name",
            "shortName": "cn"
        },
        "CompanyWebsite": {
            "longName": "company-website",
            "shortName": "cw"
        },
        "ApiDescription": {
            "longName": "api-description",
            "shortName": "desc"
        }
    }
}

Remember that there are default values defined in the template.json file for each parameter, so you don’t have to specify each and every one of the command line parameters if you don’t need to.

If you want to create a solution using the default values an override only a couple of parameters, you could totally do it.
For example, this command: dotnet new mtr-api --github true --azure-pipelines false will create an api solution using the default values for every command line parameter except the github and azure-pipelines parameters.

2.3. Create the ide.host.json file

If your template needs some command line parameters and you want to use it within Visual Studio you need to add an ide.host.json file inside the .template.config folder.

This file will be used to show the command line parameters inside a project dialog when you try to create a new project.

api-vs-dialog

Also you can customize your templates appearance in the Visual Studio template list with an icon. If it is not provided, a default icon will be associated with your project template.

Here’s how the ide.host.json for the api looks like:

{
    "$schema": "http://json.schemastore.org/vs-2017.3.host",
    "order": 0,
    "icon": "icon.png",
    "symbolInfo": [
      {       
        "id": "Docker",
        "name": 
        {
          "text": "Adds a Dockerfile."
        },
        "isVisible": true      
      },
      {       
        "id": "ReadMe",
        "name": 
        {
          "text": "Adds a Readme."
        },
        "isVisible": true      
      },
      {
        "id": "Tests",
        "name": 
        {
          "text": "Adds a Unit Test project and a Integration Test project."
        },
        "isVisible": true
      },
      {       
        "id": "GitHub",
        "name": 
        {
          "text": "Adds a GitHub action to deploy the application."
        },
        "isVisible": true      
      },
      {       
        "id": "AzurePipelines",
        "name": 
        {
          "text": "Adds an Azure Pipeline YAML to deploy the application."
        },
        "isVisible": true      
      },
      {       
        "id": "DeploymentType",
        "isVisible": true      
      },
      {       
        "id": "AcrName",
        "name": 
        {
          "text": "Name of the ACR Registry, only used if deploying with container."
        },
        "isVisible": true      
      },
      {       
        "id": "AzureSubscriptionName",
        "name": 
        {
          "text": "An Azure subscription service endpoint name, only used if deploying with Azure DevOps."
        },
        "isVisible": true      
      },
      {       
        "id": "AppServiceName",
        "name": 
        {
          "text": "The name of Azure App Service."
        },
        "isVisible": true      
      },
      {       
        "id": "Authorization",
        "name": 
        {
          "text": "Enable the use of authorization with Microsoft.Identity.Web."
        },
        "isVisible": true      
      },
      {       
        "id": "AzureAdTenantId",
        "name": 
        {
          "text": "Azure Active Directory Tenant Id. Only necessary if Authorization is enabled."
        },
        "isVisible": true      
      },
      {       
        "id": "AzureAdDomain",
        "name": 
        {
          "text": "Azure Active Directory Domain Name. Only necessary if Authorization is enabled."
        },
        "isVisible": true      
      },
      {       
        "id": "AzureAdClientId",
        "name": 
        {
          "text": "Azure Active Directory App Client Id. Only necessary if Authorization is enabled."
        },
        "isVisible": true      
      },
      {       
        "id": "AzureAdSecret",
        "name": 
        {
          "text": "Azure Active Directory App Secret Value. Only necessary if Authorization is enabled."
        },
        "isVisible": true      
      },
      {       
        "id": "HealthCheck",
        "name": 
        {
          "text": "Enable the use of healthchecks."
        },
        "isVisible": true      
      },
      {       
        "id": "HealthCheckPath",
        "name": 
        {
          "text": "HealthCheck path. Only necessary if HealthCheck is enabled."
        },
        "isVisible": true      
      },
      {       
        "id": "Swagger",
        "name": 
        {
          "text": "Enable the use of Swagger."
        },
        "isVisible": true      
      },
      {       
        "id": "SwaggerPath",
        "name": 
        {
          "text": "Swagger UI Path. Only necessary if Swagger is enabled."
        },
        "isVisible": true      
      },
      {       
        "id": "Contact",
        "name": 
        {
          "text": "The contact details to use if someone wants to contact you. Only necessary if Swagger is enabled."
        },
        "isVisible": true      
      },
      {       
        "id": "CompanyName",
        "name": 
        {
          "text": "The name of the company. Only necessary if Swagger is enabled."
        },
        "isVisible": true      
      },
      {       
        "id": "CompanyWebsite",
        "name": 
        {
          "text": "The website of the company. Needs to be a valid Uri. Only necessary if Swagger is enabled."
        },
        "isVisible": true      
      },
      {       
        "id": "ApiDescription",
        "name": 
        {
          "text": "The description of the WebAPI. Only necessary if Swagger is enabled."
        },
        "isVisible": true      
      }
    ]
}

3. Convert the remaining 2 apps into templates

  • We still need to convert the Worker Service application and the Azure Function into a .NET template.

The conversion process is exactly the same as the one described for the webapi, so I’m not going to bother writing about it or this post is going to drag on forever.

In both case the template.json file is quite similar, if you’re interested in the end result:

  • Here`s the link to the template.json file for the Worker Service application.
  • Here’s the link to the template.json file for the Azure Function.

4. Create the MyTechRamblings.Templates NuGet package

In Part 1 I told you that there are a couple of ways for creating a template pack:

  • Using a .csproj file and the dotnet pack command.
  • Using a .nuspec file and the nuget pack command from nuget.exe.

I’m using a .nuspec file. It looks like this:

<?xml version="1.0" encoding="utf-8"?>
<package>
  <metadata>
    <id>MyTechRamblings.Templates</id>
    <version>0.5.0</version>
    <description>This nuget is an example about how to pack multiple dotnet templates in a single NuGet package and use it within Visual Studio or the .NET CLI.</description>
    <authors>Carlos Pons (www.mytechramblings.com)</authors>
    <title>MyTechRamblings Dotnet Templates</title>
    <copyright>Copyright 2021: www.mytechramblings.com. All right Reserved</copyright>
    <requireLicenseAcceptance>false</requireLicenseAcceptance>
    <license type="expression">MIT</license>
    <tags>.NET WebApi Rabbit Function Templates</tags>
    <projectUrl>https://github.com/karlospn/MyTechRamblings.Templates</projectUrl>
    <repository type="git" url="https://github.com/karlospn/MyTechRamblings.Templates.git" branch="main"  />
    <language>en-US</language>
	<packageTypes>
      <packageType name="Template" />
    </packageTypes>
  </metadata>
  <files>
    <file src="**" exclude="**\bin\**\*;**\obj\**\*;**\*.user;**\*.lock.json;_rels\**\*;package\**\*" />
  </files>
</package>

5. Publish the package to nuget.org

I don’t want to create and publish the package manually.
Instead of that, I have created a Powershell script that fetches the nuget executable from the nuget.org website, bundles everything together and creates the pack using the nuget pack command.

Set-StrictMode -Version Latest
$templateName = "template"
$templatePath =     "./$templateName/mtr"
$contentDirectory = "./$templateName/mtr/content"
$nugetPath = "./$templateName/nuget.exe"
$nugetOut =  "./$templateName/nuget"
$nugetUrl = "https://dist.nuget.org/win-x86-commandline/v5.9.1/nuget.exe"


Write-Output "Copy WebApiNet5 template"
Copy-Item -Path "./src/WebApiNet5Template" -Recurse -Destination "$contentDirectory/WebApiNet5Template" -Container

Write-Output "Copy HostedServiceNet5RabbitConsumer template"
Copy-Item -Path "./src/HostedServiceNet5RabbitConsumerTemplate" -Recurse -Destination "$contentDirectory/HostedServiceNet5RabbitConsumerTemplate" -Container

Write-Output "Copy AzureFunctionTimerProjectTemplate template"
Copy-Item -Path "./src/AzureFunctionTimerProjectTemplate" -Recurse -Destination "$contentDirectory/AzureFunctionTimerProjectTemplate" -Container

Write-Output "Copy nuspec"
Copy-item -Force -Recurse "MyTechRamblings.Templates.nuspec" -Destination $templatePath

Write-Output "Download nuget.exe from $nugetUrl"
Invoke-WebRequest -Uri $nugetUrl -OutFile $nugetPath

Write-Output "Pack nuget"
$cmdArgList = @( "pack", "$templatePath\MyTechRamblings.Templates.nuspec",
				 "-OutputDirectory", "$nugetOut", "-NoDefaultExcludes")
& $nugetPath $cmdArgList 

And also I have created a GitHub Action that executes the script and pushes the .nupkg into the nuget.org feed.

name: push the template package to nuget.org

on:
  push:
    branches: [ main ]
  pull_request:
    branches: [ main ]

  workflow_dispatch:

jobs:
  build:
    runs-on: windows-latest
    steps: 
      - uses: actions/checkout@v2
      - name: Setup .NET Core
        uses: actions/setup-dotnet@v1
        with:
          dotnet-version: 5.0.x
      - name: Create nupkg
        run: .\create-nuget.ps1
        shell: powershell
      - name: Push to Nuget.org
        run: dotnet nuget push "**/*.nupkg" -k ${{ secrets.NUGET_API_KEY }} -s https://api.nuget.org/v3/index.json --skip-duplicate

6. Install and use the MyTechRamblings.Templates with the .NET CLI

To Install the package you can run this command:

  • dotnet new -i MyTechRamblings.Templates::0.5.0

The command will try to fetch the NuGet from nuget.org and install it.

Or if you have the .nupkg in your local filesystem, you can ran this command:

  • dotnet new -i .\MyTechRamblings.Templates.0.5.0.nupkg

After installing the templates pack you should run the dotnet new -l command and verify that the 3 templates appear on the list (mtr-api, mtr-rabbit-worker, mtr-az-func-timer).

dotnet-list-templates

Now you can create a new solution running any of these commands:

  • dotnet new mtr-api
  • dotnet new mtr-rabbit-worker
  • dotnet new mtr-az-func-timer

This commands will create a solution using the default values. If you want to set a specific parameter execute the dotnet new command with the -h flag to list every command line parameter available.

7. Use the MyTechRamblings.Templates within Visual Studio

Be sure to enable the following option in Visual Studio: Tools > Options > Preview Features > Show all .NET Core templates in the New Project dialog.
And remember:

  • The MyTechRamblings.Templates package contains a couple of solution templates ( the api template and the worker template). If you want to use them you need at least Visual Studio version 16.10 or higher.

Try to create a new project withing Visual Studio and the new templates should appear in the Create a new project dialog.
If the templates doesn’t show up, just search for them in the search box.

list-templates

If you select any of the templates a dialog will show up and it will contain every parameter that you have specified in the ide.host.json.

  • WebAPI Visual Studio dialog:

api-vs-dialog

  • Worker Service Visual Studio dialog:

worker-vs-dialog

  • Azure function Visual Studio dialog:

function-vs-dialog

Some useful links