Different yet the same. Getting information from a TFS Request (Soap XML)

This is the second part of a series on how to implement a validation plugin using ITeamFoundationRequestFilter.

You can go to the first part by clicking the link below:

https://conradoclarkdeveloper.com/2015/03/02/iteamfoundationrequestfilter-part1-tfs

In this post I’ll explain how to intercept a work item creation request and prevent the operation from executing.


What’s in the request ?


After creating a simple plugin using an ITeamFoundationRequestFilter implementation, you are now able to intercept requests to TFS.

I recommend using a software such as Fiddler to capture requests and observe what they’re like. In our case, we’re going to investigate what goes on when we create a WorkItem on Visual Studio and on Web Access. I’ll also be using Fiddler since it’s well known and easy to use.

One thing I’m inclined to say is: Try to setup Fiddler on your own machine instead of the server. I had a lot of headaches before by not being able to capture the requests locally. Anyway there’s plenty of information around the web about how to setup Fiddler.

Right now, your plugin class should be something around these lines:

using System;
using System.Web;
using Microsoft.TeamFoundation.Framework.Server;

namespace MyNamespace
{
    public class MyPlugin: ITeamFoundationRequestFilter
    {
        public void BeginRequest(TeamFoundationRequestContext requestContext)
        {
        }

        public void EndRequest(TeamFoundationRequestContext requestContext)
        {
        }

        public void EnterMethod(TeamFoundationRequestContext requestContext)
        {
        }

        public void LeaveMethod(TeamFoundationRequestContext requestContext)
        {
        }

        public void RequestReady(TeamFoundationRequestContext requestContext)
        {
        }
    }
}

As a basic example, we’re going to create a Work Item of type Task with some basic information.

Task01 Task02

Then check on Fiddler (or your tool of choice) the intercepted request.

This is what I’ve gotten on the body of the request:

<?xml version="1.0" encoding="utf-8"?>
<soap:Envelope xmlns:soap="http://www.w3.org/2003/05/soap-envelope"
  xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xmlns:xsd="http://www.w3.org/2001/XMLSchema">
  <soap:Header>
    <RequestHeader xmlns="http://schemas.microsoft.com/TeamFoundation/2005/06/WorkItemTracking/ClientServices/03">
      <Id>uuid:1b112b23-e692-43d5-97bb-272beaf2b2b0</Id>
    </RequestHeader>
  </soap:Header>
  <soap:Body>
    <Update xmlns="http://schemas.microsoft.com/TeamFoundation/2005/06/WorkItemTracking/ClientServices/03">
      <package>
        <Package xmlns="">
          <InsertWorkItem ObjectType="WorkItem" TempID="2">
            <InsertText FieldName="System.Description" FieldDisplayName="Description">Description Text</InsertText>
            <Columns>
              <Column Column="System.AreaId" Type="Number">
                <Value>1</Value>
              </Column>
              <Column Column="System.IterationId" Type="Number">
                <Value>1</Value>
              </Column>
              <Column Column="System.WorkItemType" Type="String">
                <Value>Task</Value>
              </Column>
              <Column Column="System.State" Type="String">
                <Value>To Do</Value>
              </Column>
              <Column Column="System.ChangedBy" Type="String">
                <Value>Conrado Adevany Clarke</Value>
              </Column>
              <Column Column="System.CreatedDate" Type="ServerDateTime" />
              <Column Column="System.CreatedBy" Type="String">
                <Value>Conrado Adevany Clarke</Value>
              </Column>
              <Column Column="System.Reason" Type="String">
                <Value>New task</Value>
              </Column>
              <Column Column="System.Title" Type="String">
                <Value>My Task</Value>
              </Column>
              <Column Column="System.AssignedTo" Type="String">
                <Value>Conrado Adevany Clarke</Value>
              </Column>
              <Column Column="Microsoft.VSTS.Scheduling.RemainingWork" Type="Double">
                <Value>10</Value>
              </Column>
              <Column Column="Microsoft.VSTS.Common.BacklogPriority" Type="Double">
                <Value>1</Value>
              </Column>
              <Column Column="Microsoft.VSTS.Common.Activity" Type="String">
                <Value>Development</Value>
              </Column>
            </Columns>
            <ComputedColumns>
              <ComputedColumn Column="System.PersonId" />
              <ComputedColumn Column="System.RevisedDate" />
              <ComputedColumn Column="System.ChangedDate" />
              <ComputedColumn Column="System.CreatedDate" />
            </ComputedColumns>
          </InsertWorkItem>
        </Package>
      </package>
    </Update>
  </soap:Body>
</soap:Envelope>

And the response:

<?xml version="1.0" encoding="utf-8"?>
<soap:Envelope xmlns:soap="http://www.w3.org/2003/05/soap-envelope"
  xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xmlns:xsd="http://www.w3.org/2001/XMLSchema">
  <soap:Body>
    <UpdateResponse xmlns="http://schemas.microsoft.com/TeamFoundation/2005/06/WorkItemTracking/ClientServices/03">
      <result>
        <UpdateResults xmlns="">
          <InsertWorkItem ID="13647" Number="0" TempID="2" Revision="1">
            <ComputedColumns>
              <ComputedColumn Column="System.PersonId" Type="Number">
                <Value>1803</Value>
              </ComputedColumn>
              <ComputedColumn Column="System.RevisedDate" Type="DateTime">
                <Value>9999-01-01T00:00:00.000</Value>
              </ComputedColumn>
              <ComputedColumn Column="System.ChangedDate" Type="DateTime">
                <Value>2015-03-04T15:34:12.247</Value>
              </ComputedColumn>
              <ComputedColumn Column="System.CreatedDate" Type="DateTime">
                <Value>2015-03-04T15:34:12.247</Value>
              </ComputedColumn>
            </ComputedColumns>
          </InsertWorkItem>
        </UpdateResults>
      </result>
      <dbStamp>B2B1F7B2-EB6F-4B3A-B874-0648872D5704</dbStamp>
    </UpdateResponse>
  </soap:Body>
</soap:Envelope>

Reading the request


Note that I’m omitting <metadata> from the request and response on purpose. It’s quite large and won’t have any use for us right now. With this now we are able to parse the XML data to get our values, and make our validations:

public void RequestReady(TeamFoundationRequestContext requestContext)
{
    if (!requestContext.ServiceHost.HostType.HasFlag(TeamFoundationHostType.ProjectCollection)) return;

    // Request is for ClientService.asmx -> SOAP (XML)
    if (HttpContext.Current.Request.Url.EndsWith("WorkItemTracking/v4.0/ClientService.asmx"))
    {
        string content = ReadHttpContextInputStream(HttpContext.Current.Request.InputStream);
        var soapDocument = XDocument.Parse(content);
        // Insert code here
    }
}

// little method to read the input stream
private string ReadHttpContextInputStream(Stream stream)
{
    string requestContent = "";
    using (var memoryStream = new MemoryStream())
    {
        byte[] buffer = new byte[1024 * 4];
        int count = 0;
        while ((count = stream.Read(buffer, 0, buffer.Length)) > 0)
        {
            memoryStream.Write(buffer, 0, count);
        }
        memoryStream.Seek(0, SeekOrigin.Begin);
        // don't forget to go to the beginning of the stream, you want it intact so tfs can process it
        stream.Seek(0, SeekOrigin.Begin);
        requestContent = Encoding.UTF8.GetString(memoryStream.GetBuffer());
    }
    return requestContent;
}

But before we get into that, I highlighted two lines of code:

3 – Each and every request we’re going to use has this HostType (TeamFoundationHostType.ProjectCollection). This is our initial filter, as we’re working through operations inside a Project Collection.

6 – This is our second filter, we just want requests that are going to be processed by the “ClientService.asmx” service. If you take a close look at the url we’re requesting to, it should be something along these lines:

http://(server-ip):8080/tfs/(project-collection)/WorkItemTracking/v4.0/ClientService.asmx.

This service contains most methods responsible for updating, creating and querying work items. We’re calling specifically this method in particular:

[SoapHeader("requestHeader", Direction=SoapHeaderDirection.In)]
[WebMethod]
public virtual void Update(XmlElement package, out XmlElement result, MetadataTableHaveEntry[] metadataHave, out string dbStamp, out Payload metadata)

Identified by this xml element:

<Update xmlns="http://schemas.microsoft.com/TeamFoundation/2005/06/WorkItemTracking/ClientServices/03">

Now that we know that, let’s place our next filter there:

public void RequestReady(TeamFoundationRequestContext requestContext)
{
    if (!requestContext.ServiceHost.HostType.HasFlag(TeamFoundationHostType.ProjectCollection)) return;

    // Request is for ClientService.asmx -> SOAP (XML)
    if (HttpContext.Current.Request.Url.EndsWith("ClientService.asmx"))
    {
        string content = ReadHttpContextInputStream(HttpContext.Current.Request.InputStream);
        var soapDocument = XDocument.Parse(content);
        var updateName = XName.Get("Update",
                    "http://schemas.microsoft.com/TeamFoundation/2005/06/WorkItemTracking/ClientServices/03");

        var updateElement = soapDocument.Descendants(updateName).FirstOrDefault();
        // Isn't it update? If not, don't proceed.
        if (updateElement == null) return;
    }
}

Ok. Now to make the simplest example possible, I’ll prevent myself from creating work items by validating my user name in the CreatedBy field:

var updateElement = soapDocument.Descendants(updateName).FirstOrDefault();
// Isn't it update? If not, don't proceed.
if (updateElement == null) return;

string createdBy = updateElement.Descendants("Column")                              // All "Column" elements
                                .Where(c=>c.Attributes("Column")                    // Which have at least 1 "Column" attribute
                                           .Any(a=>a.Value == "System.CreatedBy"))  // Whose value equals to "System.CreatedBy"
                                .Elements("Value")                                  // Select all its elements
                                .Select(e=>e.Value)                                 // Get their value
                                .FirstOrDefault();                                  // Get the first one found

if (createdBy == "Conrado Adevany Clarke")
{
     //Code here
}

Thou shall not pass!


Next, we must actually prevent the request from going further. How’d we do that?

By inspecting the Microsoft.TeamFoundation.Framework.Server.dll assembly again, (you can use Reflector, JustDecompile, or the decompiler tool of your choice) we can see there’s a specific exception that completes the request preemptively, inside the Module_PostAuthenticateRequest method. (TeamFoundationModule class)

RequestFilterException

Also note the try clause (hey, it’s out RequestReady method). RequestFilterException is an abstract class though, so we need to create our own child exception:

public class InvalidUserException: RequestFilterException
{
    public InvalidUserException(string message, Exception ex = null)
        : base(message, ex)
    {
    }
}

Then we can throw it:

if (createdBy == "Conrado Adevany Clarke")
{
    throw new InvalidUserException("Conrado is not allowed to create work items.");
}

There’s a difference though. The message is not shown on that pretty yellow strip. It’s a MessageBox instead, but it gets the job done.


What’s there to know?


The example I wrote could be done with permissions instead, but that’s not the point. What if you needed to prevent work items to be created on holidays? Or disallow people from one specific group from creating work items assigned to another specific group? Or even prohibit the creation of work items without a specific template in the description text?

However, all that glitters is NOT gold. When you create a work item from Web Access, you’ll notice ClientService is not called. We’re going to deal with this caveat on the next post.

3 thoughts on “Different yet the same. Getting information from a TFS Request (Soap XML)

  1. Alex Valchuk says:

    Hi, thanks for such comprehensive explanation.

    I have a problem.
    I have a goal to cancel a change of a status in TFS in case a work item does not fit particular rule.
    Though my filter intercepts several actions after I change the status it never receives “Update” in the body.

    I would be really appreciated for any hint about possible reason of it

    Like

    • Hello Alex. Thanks for reading.

      If I understood it correctly, you’re saying that this particular status field is not showing up in the Request XML after you’ve changed it. If that’s the case, I think this is intended design, as the requests made to update work items are differential, so only the changed fields will be sent.

      If you need to check this particular status field everytime you save the work item, you can use something like “requestContext.GetService()” and use the “GetWorkItemById” method to retrieve the Work Item with the original values.

      Like

  2. Alex Valchuk says:

    Hi Conrado,

    Thanks for your answer. But the issue is that the filter never receives “Update” in the RequestXML despite the fact I make the change:

    <Update xmlns
    ….

    For some reason the filter just misses it.

    Like

Leave a reply to Alex Valchuk Cancel reply