How to debug a custom MSBuild Task? My Custom Task does not execute as expected

249 Views Asked by At

In Summary:

I am working on an Azure Functions project using C# and Azure Functions (out-of-proc). I have integrated Swagger documentation using Microsoft.Azure.Functions.Worker.Extensions.OpenApi extension.

To automate the process, I want to create a custom MSBuild Task that downloads the Swagger.json file hosted at localhost after each build and hot-reload. However, I am facing issues as my custom task is not executing, and no logs are being printed.

What I have tried:

  • Followed the instructions provided by Microsoft in their Documentation here to set up the custom task.
  • Tested a non-custom task using , and it did not execute as expected. To verify, I added a and confirmed that the message was displayed.

I am still entirely new to working with C#, but I am getting the hang of it. I am currently working on a project where I am building some APIs using Azure Functions (out-of-proc). I wanted to add Swagger docs to the function app. I was able to do this with this extension Microsoft.Azure.Functions.Worker.Extensions.OpenApi provided by Microsoft for integrating OpenAPI with Azure Functions Apps. I was able to follow everything and got Swagger configured. My goal is to have the Swagger documentation for the Function Apps. Then for the rest of the code, I would have regular Documentation that is generated similarly to Javadoc. I would use docfx to link the API reference doc and the Swagger documentation using docfx. However, to configure docfx, I must have the swagger.json file.

I could download the file manually after every time I am ready to publish the function app, but I think this could be done better. So I decided to see if there was a way to initiate a service with the function app after build to download the served Swagger.json, which is hosted by the extension on localhost. I could download the JSON file once, but it can change, so I tried an idea where I create a custom build task that would be able to enforce a file structure by making the required folder for the functions and then attaching it to each function file a file watcher that would monitor each function code for changes, and if there is a change, I will download the Swagger.json file. I also implemented a PollInterval background task with this task that would, after a given amount of time, execute to update the swagger.json file from Swagger UI.

My problem now is that my custom task is not running as none of the logs that should have been executed are not being printed, and when I run `msbuild -bl to inspect the build log, I see no execution of my custom task. I followed the Microsoft docs here to set everything up.

I have tested this with a blank non-custom task and cannot get it to run as expected. For example, I tried to use the <MakeDir Directories="$(Folder)" />, and every time I run the project, the build does not execute to create the directory. I added a <Message Text="I am running" /> to check if it runs, and it does. I do get the message.

Here are my .props build file

<?xml version="1.0" encoding="utf-8" ?>
<Project xmlns="http://schemas.microsoft.com/developer/msbuild/2003">
    <!--defining properties interesting for my task-->
    <PropertyGroup>
        <!--The folder where the custom task will be present. It points to inside the NuGet package. -->
        <CustomTasksFolder>$(MSBuildThisFileDirectory)..\bin\Debug\netstandard2.0</CustomTasksFolder>
        <!--Reference to the assembly which contains the MSBuild Task-->
        <CustomTasksAssembly>$(CustomTasksFolder)\FileWatcher.dll</CustomTasksAssembly> 
        <TaskName>FileWatcher</TaskName>
    </PropertyGroup>

    <!--Register our custom task-->
    <UsingTask TaskName="$(TaskName)" AssemblyFile="$(CustomTasksAssembly)"/>

    <!--Task parameters default values, this can be overridden-->
    <PropertyGroup>
        <RootFolder Condition="'$(RootFolder)' == ''">$(MSBuildProjectDirectory)</RootFolder>
        <RootFunctionsFolder Condition="'$(RootFunctionsFolder)' == ''">$(RootFolder)\Requests\</RootFunctionsFolder>
        <SwaggerUrl Condition="'$(SwaggerUrl)' == ''">https://localhost:7071/api/swagger.json</SwaggerUrl>
        <FileExtension Condition="'$(FileExtension)' == ''">json</FileExtension>
        <FileName Condition="'$(SwaggerFileName)' == ''">swagger</FileName>
        <OutputFolder Condition="'$(OutputFolder)' == ''">$(RootFolder)\OpenApi\</OutputFolder>
        <OutputFile Condition="'$(OutputFile)' == ''">$(OutputFolder)$(FileName).$(FileExtension)</OutputFile>
        <PollInterval Condition="'$(PollInterval)' == ''">60</PollInterval>
    </PropertyGroup>
</Project>

Here are my .targets build file

<?xml version="1.0" encoding="utf-8" ?>
<Project xmlns="http://schemas.microsoft.com/developer/msbuild/2003">

    <Target Name="CreateRootFunctionsFolder" BeforeTargets="Build">
        <MakeDir Directories="$(RootFunctionsFolder)" Condition="!Exists('$(RootFunctionsFolder)')" />
    </Target>
    
    <Target Name="CreateSwaggerFile" Condition="!Exists('$(OutputFile)')" BeforeTargets="Build" >
        <MakeDir Directories="$(OutputFolder)" Condition="!Exists('$(OutputFolder)')" />
        <WriteLinesToFile File="$(OutputFile)" Lines="{}" Overwrite="true" />
    </Target>

    <ItemGroup>
        <SwaggerFile Include="$(OutputFile)" />
        <FunctionFiles Include="$(RootFunctionsFolder)**\*Function.cs">
            <Recursive>true</Recursive>
        </FunctionFiles>
    </ItemGroup>

    <!--It is generated a target which is executed after the compilation-->
    <Target Name="RunningFileWatcher" Condition="@(FunctionFiles -> Count()) != 0" BeforeTargets="Build">
        <!--Calling our custom task-->
        <FileWatcher FunctionFilesToWatch="@(FunctionFiles)" SwaggerFileLocation="$(OutputFile)" SwaggerUrl="$(SwaggerUrl)" PollInterval="$(PollInterval)" />

        <Message Text="We are running baby!"/>
    </Target>

    <Target Name="Tester" AfterTargets="Build">
        <Message Text="@(FunctionFiles->Count())" />
    </Target>

    <!--The generated file is deleted after a general clean. It will force the regeneration on rebuild-->
    <Target Name="AfterClean">
        <Delete Files="$(OutputFile)" />
    </Target>
</Project>

My Custom task .cs

using Microsoft.Build.Framework;
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Text.Json;
using System.Net.Http;
using System.Net.Http.Json;
using System.Threading;

namespace FileWatcher
{
    public class FileWatcher: Microsoft.Build.Utilities.Task
    {
        [Required]
        public string SwaggerFileLocation { get; set; }

        [Required]
        public string SwaggerUrl { get; set; }

        [Required]
        public int PollInterval { get; set; }

        [Required]
        public ITaskItem[] FunctionFilesToWatch { get; set; }

        private IEnumerable<FileSystemWatcher> _watchers;

        private Thread _background;

        private bool _backgroundThreadAborted = false;

        public override bool Execute()
        {
            try
            {
                DownloadSwaggerJsonAsync().GetAwaiter().GetResult(); // For the first time

                Log.LogMessage("FileName" + SwaggerFileLocation);
                Log.LogMessage("SwaggerUrl" + SwaggerUrl);
                Log.LogMessage("PollInterval" + PollInterval);
                Log.LogMessage("File count" + FunctionFilesToWatch.Length);

                _watchers = FunctionFilesToWatch.ToList()
                    .Select(file => CreateWatcher(file.GetMetadata("FullPath")));


                AppDomain.CurrentDomain.ProcessExit += OnProcessExit;
                Console.CancelKeyPress += OnCancelKeyPress;


                _background = new Thread(() => ExecuteAfterInterval())
                {
                    IsBackground = true
                };

            }
            catch (Exception e)
            {
                Log.LogErrorFromException(e);
                _backgroundThreadAborted = true;
            }
            finally
            {
                if (!_backgroundThreadAborted)
                {
                    // executed successfully
                    _background.Start();
                }

                if (_backgroundThreadAborted) _background.Abort();
            }


            return !_backgroundThreadAborted;
        }


        private void ExecuteAfterInterval()
        {
            while (true)
            {
                System.Threading.Thread.Sleep(TimeSpan.FromSeconds(PollInterval));
                DownloadSwaggerJsonAsync().GetAwaiter().GetResult();
            }
        }

        private async System.Threading.Tasks.Task DownloadSwaggerJsonAsync()
        {
            try
            {
                using (HttpClient client = new HttpClient())
                {

                    var swaggerData = await client.GetFromJsonAsync<JsonElement>(SwaggerUrl);
                    var jsonString = JsonSerializer.Serialize(swaggerData, new JsonSerializerOptions { WriteIndented = true });
                    File.WriteAllText(SwaggerFileLocation, jsonString);
                    Console.WriteLine("Swagger.json has been downloaded and saved.");
                }
            }
            catch (Exception ex)
            {
                Console.Error.WriteLine($"Error downloading swagger.json: {ex.Message}");
            }
        }

        private FileSystemWatcher CreateWatcher(string filePath)
        {
            FileSystemWatcher watcher = new FileSystemWatcher
            {
                Path = Path.GetDirectoryName(filePath),
                NotifyFilter = NotifyFilters.LastWrite | NotifyFilters.FileName | NotifyFilters.DirectoryName,
                Filter = Path.GetFileName(filePath),
            };

            watcher.Changed += (sender, e) => OnFileChanged(e.FullPath);
            watcher.EnableRaisingEvents = true;

            Log.LogMessage($"Watching {filePath} for changes");

            return watcher;
        }

        private void OnFileChanged(string fullPath)
        {
            Console.WriteLine($"File changed: {fullPath}");
        }


        private void DisposeWatchers(IEnumerable<FileSystemWatcher> watchers)
        {
            foreach (var watcher in watchers)
            {
                watcher.Dispose();
            }
        }

        private void OnProcessExit(object sender, EventArgs e)
        {
            DisposeWatchers(_watchers);
            _background.Abort();
        }

        private void OnCancelKeyPress(object sender, ConsoleCancelEventArgs e)
        {
            DisposeWatchers(_watchers);
            _background.Abort();
            Environment.Exit(0);
        }
    }
}

The Custom Task .csproj

<Project Sdk="Microsoft.NET.Sdk">

  <PropertyGroup>
    <TargetFramework>netstandard2.0</TargetFramework>
      <Version>1.0.0</Version>
      <Title>SwaggerWatchCat</Title>
      <Authors>Joseph Steven Semgalawe</Authors>
      <Description>Watches and downloads the swagger.json for Azure.Worker.Extension.OpenApi</Description>
      <tags>Azure, Extension, Swagger, File Watcher, Download</tags>
      <PackageId>FileWatcher</PackageId>
      <GenerateDependencyFile>true</GenerateDependencyFile>
      <TargetsForTfmSpecificBuildOutput>$(TargetsForTfmSpecificBuildOutput);CopyProjectReferencesToPackage</TargetsForTfmSpecificBuildOutput>
      <DebugType>embedded</DebugType>
      <IsPackable>true</IsPackable>
  </PropertyGroup>


    <ItemGroup>
        <Content Include="build\FileWatcher.props" PackagePath="build\" />
        <Content Include="build\FileWatcher.targets" PackagePath="build\" />
    </ItemGroup>

    <Target Name="CopyProjectReferencesToPackage" DependsOnTargets="ResolveReferences">
        <ItemGroup>
            <!-- The dependencies of your MSBuild task must be packaged inside the package; they cannot be expressed as normal PackageReferences -->
            <BuildOutputInPackage Include="@(ReferenceCopyLocalPaths)" TargetPath="%(ReferenceCopyLocalPaths.DestinationSubPath)" />
        </ItemGroup>
    </Target>

    <!-- This target adds the generated deps.json file to our package output -->
    <Target Name="AddBuildDependencyFileToBuiltProjectOutputGroupOutput" BeforeTargets="BuiltProjectOutputGroup" Condition=" '$(GenerateDependencyFile)' == 'true'">

        <ItemGroup>
            <BuiltProjectOutputGroupOutput Include="$(ProjectDepsFilePath)" TargetPath="$(ProjectDepsFileName)" FinalOutputPath="$(ProjectDepsFilePath)" />
        </ItemGroup>
    </Target>
    
    
  <ItemGroup>
    <PackageReference Include="Microsoft.Build.Utilities.Core" Version="17.6.3" PrivateAssets="all" />
    <PackageReference Include="System.Net.Http" Version="4.3.4" PrivateAssets="all" />
    <PackageReference Include="System.Net.Http.Json" Version="7.0.1" PrivateAssets="all" />
    <PackageReference Include="System.Text.Json" Version="7.0.3" PrivateAssets="all"/>
  </ItemGroup>
    
    
  <ItemGroup>
    <Folder Include="build\" />
  </ItemGroup>
    
</Project>

Thanks in Advance!

1

There are 1 best solutions below

1
Brevyn Kurt On

I identified and resolved the problem by modifying the <CustomTaskFolder/> value. Specifically, I changed it from $(MSBuildThisFileDirectory)..\bin\Debug\netstandard2.0 to $(MSBuildThisFileDirectory)..\lib\netstandard2.0.

Initially, the first path worked when I directly imported the .props and .targets files into the project. However, when I packaged the Custom Task using Nuget, the folder path changed to $(MSBuildThisFileDirectory)..\lib\netstandard2.0. As a result, I switched between the two approaches as needed and added an extra property to use for development and when using Nuget.

I suspect the issue was related to the MSBuildThisFileDirectory and MSBuildThisFileName. After discussing with @Jonathan Dodds, I discovered that when a Nuget package is packed, the assembly location changes based on the project that consumes the package as a dependency. This causes the MSBuildThisFileDirectory to change to the Project file directory, which is why ...\lib is the correct folder path for the assembly target file. However, for development purposes, if you were to import the .props and .targets files, MSBuildThisFileDirectory would be the path of the Custom Task, allowing it to be accessed through the current build file in the ...bin\ folder.

I am unsure if this applies to every custom task, as it was not covered in the Tutorials, or I may have missed some details. Please feel free to correct me if I am mistaken. Although I am still learning about C# and MSBuild, this solution worked for me.

Importing the .props and .targets for development and debugging is not the only way you can refer to this from Microsoft for alternatives

PS: For debugging, refer here