This is a followup from my last article on Candor Mvc bootstrap. This will show the first steps in moving a portion of an Mvc project out into a NuGet package. The NuGet package will include the controller and the associated view models and views. When you install the package it will transform the namespace to that of the target project.
Since this package is content and not a library, all of the files to be installed with the package need to be copied into a ‘Content’ folder under the root of the package folder. Since this is not where files are within the MVC project, we need to create a separate folder to contain the files used to generate the NuGet package. Besides we will also need to transform the files from the actual application to change the namespace.
A good way to find a controller in a project is to look in the project file, and to do that we need to use XPath. Here is a snippet of the project file we need to copy files from. This snippet includes some files I do not want in the NuGet package. I do not want the AccountController.generated.cs file since T4MVC generates it automatically. I also do not want the views in the shared folder.
file: Candor.Web.Mvc.Bootstrap.csproj (partial)
<?xml version="1.0" encoding="utf-8"?> <Project ToolsVersion="4.0" DefaultTargets="Build" xmlns="http://schemas.microsoft.com/developer/msbuild/2003"> <!--snip--> <ItemGroup> <Compile Include="AccountController.generated.cs"> <DependentUpon>T4MVC.tt</DependentUpon> </Compile> <Compile Include="Controllers\AccountController.cs" /> <Compile Include="Models\Account\ChangePasswordViewModel.cs" /> <Compile Include="Models\Account\ForgotPasswordViewModel.cs" /> <Compile Include="Models\Account\LoginViewModel.cs" /> <Compile Include="Models\Account\RegisterViewModel.cs" /> <!--snip--> </ItemGroup> <ItemGroup> <Content Include="Views\Shared\_LoginPartial.cshtml" /> <Content Include="Views\Shared\_Layout.cshtml" /> <Content Include="Views\Account\Login.cshtml" /> <Content Include="Views\Account\Register.cshtml" /> <Content Include="Views\Account\ChangePassword.cshtml" /> <Content Include="Views\Account\ChangePasswordSuccess.cshtml" /> <Content Include="Views\Account\ForgotPassword.cshtml" /> <!--snip--> </ItemGroup> <!--snip--> </Project>
I’ll begin by creating a reusable targets file that can be included in a number of MSBuild files that I want to extract out different controllers of a project into different packages. Then I’ll create an MSBuild for my use case that uses those targets and passed them the names of the items to copy out.
MSBuild has a built in task called XmlPeek that allows you to do an XPath query on the project file and store the matches in a list to be used by another task. The MSBuild file below has a target for copying the controller using two tasks. The first task is an XmlPeek to get the item from the project file. The first part of the expression notes the hierarchy of nodes to get to the ‘Compile’ node, then the ‘Include’ attribute filter specifies we only want a ‘Compile’ node that has an exact attribute value. The exact value varies based on the ‘ControllerBaseName’ property defined in an MSBuild file that invokes this target.
The copy task iterates over all the matched nodes from the XmlPeek task as you can see with the ‘Peeked->’ syntax. ‘Peeked’ was defined as a property output of the XmlPeek task. The destination of the copy ‘TargetDir’ is a property defined in the MSBuild file that invokes this target.
file: CopyController.targets (rev 1)
<?xml version="1.0" encoding="utf-8"?> <Project ToolsVersion="4.0" DefaultTargets="CopyController" xmlns="http://schemas.microsoft.com/developer/msbuild/2003"> <Target Name="CopyController"> <!-- Get the controller --> <XmlPeek Namespaces="&lt;Namespace Prefix='msb' Uri='http://schemas.microsoft.com/developer/msbuild/2003'/&gt;" XmlInputPath="$(ProjectPath)" Query="/msb:Project/msb:ItemGroup/msb:Compile[@Include='Controllers\$(ControllerBaseName)Controller.cs']/@Include"> <Output TaskParameter="Result" ItemName="Peeked" /> </XmlPeek> <!-- Copy the controller --> <Copy SourceFiles="@(Peeked->'$(ProjectDir)%(RelativeDir)%(Filename)%(Extension)')" DestinationFiles="@(Peeked->'$(TargetDir)%(RelativeDir)%(Filename)%(Extension)')"/> </Target> </Project>
Now create an MSBuild project file that will use the CopyController targets file using the ‘Import’ directive shown at the bottom of the file. That target has some required property values it needed to operate, so those are defined in the ‘PropertyGroup’ shown below. At the top of the file the ‘DefaultTargets’ attribute defines what needs to be done when this file is built. This MSBuild file executes the main task of the targets file that is included.
file: Build.Candor.Mvc.Security.nuspec.proj
<?xml version="1.0" encoding="utf-8"?> <Project ToolsVersion="4.0" DefaultTargets="CopyController" xmlns="http://schemas.microsoft.com/developer/msbuild/2003"> <PropertyGroup> <ProjectDir>..\Candor.Web.Mvc.Bootstrap\</ProjectDir> <ProjectName>Candor.Web.Mvc.Bootstrap.csproj</ProjectName> <ProjectPath>$(ProjectDir)$(ProjectName)</ProjectPath> <TargetDir>Content\</TargetDir> <ControllerBaseName>Account</ControllerBaseName> <RootNamespaceToReplace>CandorMvcApplication</RootNamespaceToReplace> </PropertyGroup> <Import Project="CopyController.targets"/> </Project>
Now to expand on the targets file created above, this next revision will also copy the models and views required by the controller. Fortunately MVC is convention based, so the associated models and views are in a specific folder named the same as the controller name (minus the word ‘Controller’).
This file is similar to revision 1, except that now it duplicates the target in the old file. Each component of MVC has it’s own target. It is possible to do this with one target by updating the XPath expression to have an OR statement (a | symbol) between each expression, however I left each separate in case I need to do a special action for each group of files during the copy operation. It also makes the file more reusable by other MSBuild files that may only want views, or models, or just the controller.
file: CopyController.targets (rev 2)
<?xml version="1.0" encoding="utf-8"?> <Project ToolsVersion="4.0" DefaultTargets="CopyController" xmlns="http://schemas.microsoft.com/developer/msbuild/2003"> <Target Name="CopyController" DependsOnTargets="LogParams;CopyControllerFile;CopyModelFiles;CopyViewFiles"> </Target> <Target Name="CopyControllerFile"> <!-- Get the controller --> <XmlPeek Namespaces="&lt;Namespace Prefix='msb' Uri='http://schemas.microsoft.com/developer/msbuild/2003'/&gt;" XmlInputPath="$(ProjectPath)" Query="/msb:Project/msb:ItemGroup/msb:Compile[@Include='Controllers\$(ControllerBaseName)Controller.cs']/@Include"> <Output TaskParameter="Result" ItemName="Peeked" /> </XmlPeek> <!-- Copy the controller --> <Copy SourceFiles="@(Peeked->'$(ProjectDir)%(RelativeDir)%(Filename)%(Extension)')" DestinationFiles="@(Peeked->'$(TargetDir)%(RelativeDir)%(Filename)%(Extension)')"/> </Target> <Target Name="CopyModelFiles"> <!-- Get the models --> <XmlPeek Namespaces="&lt;Namespace Prefix='msb' Uri='http://schemas.microsoft.com/developer/msbuild/2003'/&gt;" XmlInputPath="$(ProjectPath)" Query="/msb:Project/msb:ItemGroup/msb:Compile[starts-with(@Include, 'Models\$(ControllerBaseName)\')]/@Include"> <Output TaskParameter="Result" ItemName="Peeked" /> </XmlPeek> <!-- Copy the models --> <Copy SourceFiles="@(Peeked->'$(ProjectDir)%(RelativeDir)%(Filename)%(Extension)')" DestinationFiles="@(Peeked->'$(TargetDir)%(RelativeDir)%(Filename)%(Extension)')"/> </Target> <Target Name="CopyViewFiles"> <!-- Get the views --> <XmlPeek Namespaces="&lt;Namespace Prefix='msb' Uri='http://schemas.microsoft.com/developer/msbuild/2003'/&gt;" XmlInputPath="$(ProjectPath)" Query="/msb:Project/msb:ItemGroup/msb:Content[starts-with(@Include, 'Views\$(ControllerBaseName)\')]/@Include"> <Output TaskParameter="Result" ItemName="Peeked" /> </XmlPeek> <!-- Copy the views --> <Copy SourceFiles="@(Peeked->'$(ProjectDir)%(RelativeDir)%(Filename)%(Extension)')" DestinationFiles="@(Peeked->'$(TargetDir)%(RelativeDir)%(Filename)%(Extension)')"/> </Target> <!-- This is optional logging of parameter values. Helpful when you get an error on the build command prompt. --> <Target Name="LogParams"> <Message Text="The ProjectDir is '$(ProjectDir)'"/> <Message Text="The ProjectName is '$(ProjectName)'"/> <Message Text="The ProjectPath is '$(ProjectPath)'"/> <Message Text="The TargetDir is '$(TargetDir)'"/> </Target> </Project>
Notice how this file has evolved. The CopyController task is now a logical top level task, and a new task was created for copying the controller; also new tasks were created for copying the models and views. The CopyController task executes the other tasks in order as defined in the ‘DependsOnTargets’ attribute. The MSBuild file that includes it does not have to change to call a new top level task, so in essense this target file is backwards compatible with the prior version. This is something to keep in mind as you evolve your own reusable targets files.
As it stands the source code will not adopt the namespace of any project in which the eventual NuGet package is installed. For that the code file must contain Project Property notation where replacements belong, and the file extension must end with ‘.pp’ for NuGet to transform the file during installation into a project.
The change to make the extension end with ‘.pp’ is as simple as updating the DestinationFiles attribute of the Copy task to hardcode a ‘.pp’ at the end of the file name. We still want the normal file extension before the ‘.pp’.
The transformation of the file is a little more complex. However, MSBuild Community tasks has a useful FileUpdate task available for use. This task can match text in files with a regular expression and then replace those values with any replacement text that is either static or it can include captures from the regex. I already have MSBuild Community tasks in my solution for building out the nuget packages in the publish of shared libraries in the solution. The targets file can import the community tasks using a relative folder. If you prefer you can put them in the same folder as the targets file.
Another change made to this version was to change the ‘ItemName’ of the XmlPeel tasks to be different for each of the targets. This is because the ‘ItemName’ becomes a project level variable reusable amoung all other targets that execute after it during the build. Prior to this change, each XmlPeek task was adding to the ‘Peeked’ item variable rather than overwriting it. So when the views copy task was run it had included the controller and models XmlPeek task output as well. Make sure you make ‘ItemName’ attribute values in different tasks unique enough so that they are not accidentally reused by another target included in the same project file.
file: CopyController.targets (rev 3)
<?xml version="1.0" encoding="utf-8"?> <Project ToolsVersion="4.0" DefaultTargets="CopyController" xmlns="http://schemas.microsoft.com/developer/msbuild/2003"> <Target Name="CopyController" DependsOnTargets="LogParams;CopyControllerFile;CopyModelFiles;CopyViewFiles"> </Target> <Target Name="CopyControllerFile"> <!-- Get the controller --> <XmlPeek Namespaces="&lt;Namespace Prefix='msb' Uri='http://schemas.microsoft.com/developer/msbuild/2003'/&gt;" XmlInputPath="$(ProjectPath)" Query="/msb:Project/msb:ItemGroup/msb:Compile[@Include='Controllers\$(ControllerBaseName)Controller.cs']/@Include"> <Output TaskParameter="Result" ItemName="ControllerPeeked" /> </XmlPeek> <!-- Copy the controller --> <Copy SourceFiles="@(ControllerPeeked->'$(ProjectDir)%(RelativeDir)%(Filename)%(Extension)')" DestinationFiles="@(ControllerPeeked->'$(TargetDir)%(RelativeDir)%(Filename)%(Extension).pp')"> <Output TaskParameter="CopiedFiles" ItemName="ControllerFilesToUpdate" /> </Copy> <!-- Replace the namespace with the $rootnamespace$ project property --> <FileUpdate Files="@(ControllerFilesToUpdate)" Regex="$(RootNamespaceToReplace)" ReplacementText="$rootnamespace$" /> </Target> <Target Name="CopyModelFiles"> <!-- Get the models --> <XmlPeek Namespaces="&lt;Namespace Prefix='msb' Uri='http://schemas.microsoft.com/developer/msbuild/2003'/&gt;" XmlInputPath="$(ProjectPath)" Query="/msb:Project/msb:ItemGroup/msb:Compile[starts-with(@Include, 'Models\$(ControllerBaseName)\')]/@Include"> <Output TaskParameter="Result" ItemName="ModelPeeked" /> </XmlPeek> <!-- Copy the models --> <Copy SourceFiles="@(ModelPeeked->'$(ProjectDir)%(RelativeDir)%(Filename)%(Extension)')" DestinationFiles="@(ModelPeeked->'$(TargetDir)%(RelativeDir)%(Filename)%(Extension).pp')"> <Output TaskParameter="CopiedFiles" ItemName="ModelFilesToUpdate" /> </Copy> <!-- Replace the namespace with the $rootnamespace$ project property --> <FileUpdate Files="@(ModelFilesToUpdate)" Regex="$(RootNamespaceToReplace)" ReplacementText="$rootnamespace$" /> </Target> <Target Name="CopyViewFiles"> <!-- Get the views --> <XmlPeek Namespaces="&lt;Namespace Prefix='msb' Uri='http://schemas.microsoft.com/developer/msbuild/2003'/&gt;" XmlInputPath="$(ProjectPath)" Query="/msb:Project/msb:ItemGroup/msb:Content[starts-with(@Include, 'Views\$(ControllerBaseName)\')]/@Include"> <Output TaskParameter="Result" ItemName="ViewPeeked" /> </XmlPeek> <!-- Copy the views --> <Copy SourceFiles="@(ViewPeeked->'$(ProjectDir)%(RelativeDir)%(Filename)%(Extension)')" DestinationFiles="@(ViewPeeked->'$(TargetDir)%(RelativeDir)%(Filename)%(Extension).pp')"> <Output TaskParameter="CopiedFiles" ItemName="ViewFilesToUpdate" /> </Copy> <!-- Replace the namespace with the $rootnamespace$ project property --> <FileUpdate Files="@(ViewFilesToUpdate)" Regex="$(RootNamespaceToReplace)" ReplacementText="$rootnamespace$" /> </Target> <Target Name="LogParams"> <Message Text="The ProjectDir is '$(ProjectDir)'"/> <Message Text="The ProjectName is '$(ProjectName)'"/> <Message Text="The ProjectPath is '$(ProjectPath)'"/> <Message Text="The TargetDir is '$(TargetDir)'"/> </Target> <Import Project="$(MSBuildCommunityTasksPath)\MSBuild.Community.Tasks.Targets"/> </Project>
This targets file expects another property defined in the project file that uses it called ‘MSBuildCommunityTasksPath’. Here is the updated MSBuild file.
file: Build.Candor.Mvc.Security.nuspec.proj (rev 2)
<?xml version="1.0" encoding="utf-8"?> <Project ToolsVersion="4.0" DefaultTargets="CopyController" xmlns="http://schemas.microsoft.com/developer/msbuild/2003"> <PropertyGroup> <ProjectDir>..\Candor.Web.Mvc.Bootstrap\</ProjectDir> <ProjectName>Candor.Web.Mvc.Bootstrap.csproj</ProjectName> <ProjectPath>$(ProjectDir)$(ProjectName)</ProjectPath> <TargetDir>Content\</TargetDir> <ControllerBaseName>Account</ControllerBaseName> <RootNamespaceToReplace>CandorMvcApplication</RootNamespaceToReplace> <MSBuildCommunityTasksPath>$(MSBuildProjectDirectory)\..\Build\OpenSource\MSBuildCommunityTasks</MSBuildCommunityTasksPath> </PropertyGroup> <Import Project="CopyController.targets"/> </Project>
Here is what the MSBuild output looks like.
c:\Users\micha_000\Documents\GitHub\candor-common\Candor.Web.Mvc.Bootstrap.Secur ity>msbuild Build.Candor.Mvc.Security.nuspec.proj Microsoft (R) Build Engine version 4.0.30319.17929 [Microsoft .NET Framework, version 4.0.30319.18010] Copyright (C) Microsoft Corporation. All rights reserved. Build started 2/11/2013 12:02:35 AM. Project "c:\Users\micha_000\Documents\GitHub\candor-common\Candor.Web.Mvc.Bootstrap.Security\Build.Candor.Mvc.Security.nuspec.proj" on node 1 (default targets). LogParams: The ProjectDir is '..\Candor.Web.Mvc.Bootstrap\' The ProjectName is 'Candor.Web.Mvc.Bootstrap.csproj' The ProjectPath is '..\Candor.Web.Mvc.Bootstrap\Candor.Web.Mvc.Bootstrap.csproj' The TargetDir is 'Content\' CopyControllerFile: Copying file from "..\Candor.Web.Mvc.Bootstrap\Controllers\AccountController.cs" to "Content\Controllers\AccountController.cs.pp". Updating File "Content\Controllers\AccountController.cs.pp". Replaced matches with "$rootnamespace$". CopyModelFiles: Copying file from "..\Candor.Web.Mvc.Bootstrap\Models\Account\ChangePasswordViewModel.cs" to "Content\Models\Account\ChangePasswordViewModel.cs.pp". Copying file from "..\Candor.Web.Mvc.Bootstrap\Models\Account\ForgotPasswordViewModel.cs" to "Content\Models\Account\ForgotPasswordViewModel.cs.pp". Copying file from "..\Candor.Web.Mvc.Bootstrap\Models\Account\LoginViewModel.cs" to "Content\Models\Account\LoginViewModel.cs.pp". Copying file from "..\Candor.Web.Mvc.Bootstrap\Models\Account\RegisterViewModel.cs" to "Content\Models\Account\RegisterViewModel.cs.pp". Updating File "Content\Models\Account\ChangePasswordViewModel.cs.pp". Replaced matches with "$rootnamespace$". Updating File "Content\Models\Account\ForgotPasswordViewModel.cs.pp". Replaced matches with "$rootnamespace$". Updating File "Content\Models\Account\LoginViewModel.cs.pp". Replaced matches with "$rootnamespace$". Updating File "Content\Models\Account\RegisterViewModel.cs.pp". Replaced matches with "$rootnamespace$". CopyViewFiles: Copying file from "..\Candor.Web.Mvc.Bootstrap\Views\Account\Login.cshtml" to "Content\Views\Account\Login.cshtml.pp". Copying file from "..\Candor.Web.Mvc.Bootstrap\Views\Account\Register.cshtml" to "Content\Views\Account\Register.cshtml.pp". Copying file from "..\Candor.Web.Mvc.Bootstrap\Views\Account\ChangePassword.cshtml" to "Content\Views\Account\ChangePassword.cshtml.pp". Copying file from "..\Candor.Web.Mvc.Bootstrap\Views\Account\ChangePasswordSuccess.cshtml" to "Content\Views\Account\ChangePasswordSuccess.cshtml.pp". Copying file from "..\Candor.Web.Mvc.Bootstrap\Views\Account\ForgotPassword.cshtml" to "Content\Views\Account\ForgotPassword.cshtml.pp". Updating File "Content\Views\Account\Login.cshtml.pp". Replaced matches with "$rootnamespace$". Updating File "Content\Views\Account\Register.cshtml.pp". Replaced matches with "$rootnamespace$". Updating File "Content\Views\Account\ChangePassword.cshtml.pp". Replaced matches with "$rootnamespace$". Updating File "Content\Views\Account\ChangePasswordSuccess.cshtml.pp". Replaced matches with "$rootnamespace$". Updating File "Content\Views\Account\ForgotPassword.cshtml.pp". Replaced matches with "$rootnamespace$". Done Building Project "c:\Users\micha_000\Documents\GitHub\candor-common\Candor.Web.Mvc.Bootstrap.Security\Build.Candor.Mvc.Security.nuspec.proj" (default targets). Build succeeded. 0 Warning(s) 0 Error(s) Time Elapsed 00:00:00.10
The NuGet specification file is easy enough to create from the directions on the NuGet documentation site. Just type ‘nuget spec’ on the command line in the same folder as the targets and proj files above, then edit all the fields as needed. NuGet must be in your path for it to work.
<?xml version="1.0"?> <package xmlns="http://schemas.microsoft.com/packaging/2010/07/nuspec.xsd"> <metadata> <id>Candor.Web.Mvc.Security</id> <version>0.6.0.30129</version> <authors>Michael Lang</authors> <owners>Michael Lang</owners> <projectUrl>https://github.com/michael-lang/candor-common</projectUrl> <requireLicenseAcceptance>false</requireLicenseAcceptance> <description>An MVC AccountController with corresponding view models, and views. When installed the added files will adopt your project's root namespace.</description> <tags>candor mvc security</tags> <dependencies> <dependency id="Candor.Security" version="0.6.0.30129" /> <dependency id="Candor.Web.Mvc" version="0.6.0.30129" /> </dependencies> </metadata> </package>
All that is left is building your package, testing it in a new MVC application, and the publishing it to NuGet.org or your own private repository. You can find directions to these steps on the NuGet.org site, as mentioned in the resources below.
Creating and Publishing a Package
http://docs.nuget.org/docs/creating-packages/creating-and-publishing-a-package
Configuration File and Source Code Transformations
http://docs.nuget.org/docs/creating-packages/Configuration-File-and-Source-Code-Transformations
For a detailed look at the basics of MSBuild project files, I recommend you read ‘How to copy projects files using MSBuild. Step by step explanation.’
http://blog.avangardo.com/2010/11/how-copy-projects-files-using-msbuild/
MSDN – ProjectProperties Properties (replacement tokens available for code transformations)
http://msdn.microsoft.com/en-us/library/vslangproj.projectproperties_properties(VS.80).aspx
Candor Security Mvc Bootstrap (code packaged up by this article)
http://candordeveloper.com/2013/02/08/candor-security-mvc4-bootstrap/
3 Comments