Showing posts with label PowerShell. Show all posts
Showing posts with label PowerShell. Show all posts

Thursday, January 20, 2022

Invoking Azure DevOps Hidden REST API

GEMS

Azure DevOps has a great REST API, but every now and then there are some things you can do in the user-interface that you can’t do from the REST API.

But did you know Azure DevOps has a hidden API?

Wait, Hidden API?

Technically, it’s not really a hidden API. Like most modern web applications, Azure DevOps exposes a set of REST endpoints to their user-interface that is different than the official REST API. These endpoints follow a REST convention and use the same authentication scheme, so you can use them interchangeably from a non-interactive script.

While the Azure DevOps REST API is versioned, documented and supported by Microsoft, these endpoints are not so there’s no guarantee that they won’t change in the future, so buyer beware. Here be dragons, etc.

If you’re okay with this and you’re open to some experimentation, here’s how you can go about finding these endpoints and including them in your scripts.

Use the Inspector

It should come as no surprise that you need to use your browser’s Developer Tools to inspect the DOM and the Network Traffic while using the feature you’re interested in.

As an example, there isn’t a great API that can give you the list of builds and their status by stage. You can query the list of Builds and then use Timeline API for each build to get the outcome of the stages, but it’s really heavy and chatty. However, the user-interface for build history however easily shows us the build history and the stage status. So let’s use see how the user-interface gets its data:

  1. Navigate to the pipeline and open the Developer Tools (CTRL + SHIFT + I)
  2. Switch to the Network panel
  3. In the Filter, select Fetch/XHR
  4. Slowly scroll down until you see new requests being submitted from the page

Few things to note:

  • Requests are POST
  • Endpoint is /<organization>/_apis/Contribution/HierarchyQuery/project/<project-id>
  • The payload of the request contains a contributionIds and details that represent the current context
{
  "contributionIds": [ "ms.vss-build-web.runs-data-provider"],
  "dataProviderContext": {
    "properties": {
      "continuationToken":"2021-10-08T15:53:40.2073297Z",
      "definitionId":"1324",
      "sourcePage": {
        "url":"https://dev.azure.com/myOrganization/myProject/_build?definitionId=1234",
        "routeId":"ms.vss-build-web.pipeline-details-route",
        "routeValues":{
          "project":"myProject",
          "viewname":"details",
          "controller":"ContributedPage",
          "action":"Execute",
          "serviceHost":"<guid>"
        }
      }
    }
  }
}
  • The response object has data-providers object that corresponds to the contributionIds value specified above.
{
  "data-providers": {
    "ms.vss-build-web.runs-data-provider" : {
      "continuationToken": "<date-time>",
      "runs": [
        {
          "run": {},
          "stages": []
        }
      ]
    }
  }
}

Using the above syntax, you could in theory replicate any UI-based function by sniffing the contributionId and payload.

Magic Parameters

Now, here’s the fun part. I haven’t tested this trick for all endpoints, but it works for the majority that I’ve tried. Take any endpoint in Azure DevOps and tack on the following querystring parameters: __rt=fps&__ver=2.0

If we stick with our example of viewing Build History for a YAML Pipeline. We can take this url:

  • https://dev.azure.com/<organization>/<project>/_build?definition=<build-defintion>

Then tack on our magic parameters:

  • https://dev.azure.com/<organization>/<project>/_build?definition=<build-defintion>&__rt=fps&__ver=2.0

And immediately, you’ll notice that JSON is rendered instead of the UI. This JSON has a lot of data in it, but if we narrow on the details of the ms.vss-build-web.runs-data-provider you’ll notice the same data as what’s returned from the POST above:

{
  "fps": {
    "data-providers": {
      "data": {
        "ms.vss-build-web.runs-data-provider": {
          "continuationToken": "<date-time>",
            "runs": [
              {
                "run": [],
                "stages": []
              }
            ]
          }
        }
      }
    }
  }
}

Paging Results

The Azure DevOps UI automatically limits the amount of data to the roughly the first 50 records, so if you need to fetch all the data you need to combine the two approaches:

  • Query the endpoint with GET __rt=fps value to obtain the first set of data and the continuationToken
  • Subsequent requests use POST and pass in the continuationToken with the body.

A wrapper for Invoke-RestMethod

Just like the title of this post says, we’re going to access this API from PowerShell.

So let’s start with the following wrapper around Invoke-RestMethod that contains the logic of communicating with either of the two approaches listed above:

function Invoke-AzureDevOpsRestMethod
{
  param(
    [Parameter(Mandatory)]
    [string]$Organization,
    
    [Parameter(Mandatory)]
    [string]$ProjectName,
    
    [Parameter()]
    [ValidateSet("GET","POST","PATCH","DELETE")]
    [string]$Method = "GET",
    
    [Parameter(Mandatory)]
    [string]$AccessToken,
    
    [Parameter()]
    [string]$ApiPath = "",
    
    [Parameter()]
    [hashtable]$QueryStringParameters = [hashtable]@{},
    
    [Parameter()]
    [string]ApiVersion = "6.0-preview",
    
    [Parameter()]
    [string]$ContentType = "application/json",
    
    [Parameter()]
    [psobject]$Body
  )
  
  $QueryStringParameters.Add("api-version", $ApiVersion)
  $queryParams = $QueryStringParameters.GetEnumerator() |
                 ForEach-Object { "{0}={1}" -f $_.Name, $_.Value} |
                 Join-String -Separator "&"
  
  if ([string]::IsNullOrEmpty($ApiPath)) {
    $uriFormat = "https://dev.azure.com/{0}/_apis/Contribution/HierarchyQuery/project/{1}"
  } else {
    $uriFormat = "https://dev.azure.com/{0}/{1}/{2}?{3}"
  }
  
  $uri = $uriFormat -f $Organization, $ProjectName, $ApiPath, $queryParams
  $uri = [Uri]::EscapeUriString($uri)
  
  $header = @{ Authorization = "Basic {0}" -f [Convert]::ToBase64String([Text.Encoding]::ASCI.GetBytes(":$($AccessToken)")) }
  
  $invokeArgs = @{
     Method = $Method
     Uri = $uri
     Headers = $header
  }
  
  if ($Method -ne "GET" -and $null -ne $Body) {
    $json = ConvertTo-Json $Body -Depth 10 -Compress
    $invokeArgs.Add("Body", $json)
    $invokeArgs.Add("ContentType", $ContentType)
  }
  
  Invoke-RestMethod @invokeArgs
}

Error Handling and Retry Logic

No REST API is guaranteed to return results 100% of the time. You’ll want to accomodate for intermittent network issues, minor service interruptions or request throttling.

Let’s further improve upon this implementation by adding some error handling and retry-logic by leveraging some retry logic that is built into the Invoke-RestMethod cmdlet. When the MaximumRetryCount parameter is specified, Invoke-RestMethod will retry the request if the status code is 304, or between 400-599.

This should work for the majority of throttling issues but it also means that unauthorized requests (403) will be attempted multiple times. In my opinion, if your credentials are wrong then it won’t matter how many times you try, so repeating these requests will ultimately just make your script slower. For the purposes of this post, I’m willing to live with that but if that’s not for you, you might want to roll your own retry loop.

$maxRetries = 3
$retryInterval = 10

try
{
  Invoke-RestMethod @invokeArgs -RetryIntervalSec $retryInterval -MaximumRetryCount $maxRetries
}
catch
{
  if ($null -ne $_.Exception -and $null -ne $_.Exception.Response) {
    $statusCode = $_.Exception.Response.StatusCode
    $message    = $_.Exception.Response.Message
    Write-Information "Error invoking REST method. Status Code: $statusCode : $message"
    Write-Verbose "Exception Type: $($_.Exception.GetType())"
    Write-Verbose "Exception StackTrace: $(_$.Exception.StackTrace)"
  }
   
  Write-Information "Failed to complete REST method after $maxRetries attempts."
  throw
}

Putting it all Together

Let’s flush this out by continuing with a psuedo-example of getting YAML Build History. The initial request fetches the most recent builds with a GET request, and the loops using the POST method on the PageContribution endpoint until the continuation token is empty.

function Get-AzurePipelineHistory
{
  param(
    [string]$Organization,
    [string]$Project,
    [string]$PipelineId,
    [string]$AccessToken
  )
  
  $commonArgs = @{ Organization=$Organization; Project=$Project; AccessToken=$AccessToken }
  $apiPath = "_build"
  $pipelineArgs = @{
     definitionId = $PipelineId
     "__rt" = "fps"
     "__ver" = "2.0"
  }
  
  $results = @()
  
  $continuationToken = $null
  
  do {
  
    if ($null -eq $continuationToken) {
        
      # Fetch initial result
      $apiResult = Invoke-AzDevOpsRestMethod -Method "GET" @commonArgs -ApiPath $apiPath -QueryParameters $queryArgs
        
      $pipelineRuns = $apiResult.fps.dataProviders.data."ms.vss-build-web.runs-data-provider"
        
    } else {
     
      $requestBody = @{
        contributionIds = @("ms.vss-build-web.runs-data-provider")
        dataProviderContext =
          @{
            properties = @{
            continuationToken = $continuationToken
            definitionId = $PipelineId
            sourcePage = @{
              routeId = "ms.vss-build-web.pipeline-details-route"
              routeValues = @{
                project = $ProjectName
                viewname = "details"
                controller = "ContributedPage"
                action = "Execute"
              }
            }
          }
        }
      }
       
      # fetch additional results
      $apiResult = Invoke-AzDevOpsRestMethod -Method "POST" @commonArgs -Body $requestBody
       
      $pipelineRuns = $apiResult.dataProviders."ms.vss-build-web.runs-data-provider"
    }
     
    # hang onto the continuation token to fetch additional data
    $continuationToken = $pipelineRuns.continuationToken
     
    # append results
    $results += $pipelineRuns.runs
  
  } until ($null -eq $continuationToken)
  
  return $results
}

The end result is a concatenated collection of all the runs for this pipeline. It’s worth noting that if you have a very long list of pipeline runs, you’re going to have a lot of data to sift through. This can definitely be optimized and my next post will dig deeper into that.

That’s it for now. Happy coding.

Saturday, March 06, 2021

Using the Azure CLI to Call Azure DevOps REST API

Suppose the Azure DevOps REST API that you want to call isn't in the list of az cli supported commands. Does this mean your script needs to toggle between az cli and invoking REST endpoints? Fear not, there's actually a built in az devops command "az devops invoke" that can call any Azure DevOps REST API endpoint.

The az devops invoke command is fairly easy to use, but the trick is discovering the command-line arguments you need to provide to pull it off. This post will walk you through that.

Table of Contents

Obtain a List of Available Endpoints

The az devops invoke command is neat alternative to using the REST API, but understanding what command-line arguments you'll need isn't obvious.

Let's start by finding out which endpoints are available by calling az devops invoke with no arguments and pipe this to a file for reference:

az devops invoke > az_devops_invoke.json 

This will take a few moments to produce. I've got a full listing of endpoints located here.

The list of endpoints are grouped by 'Area' and have a unique 'resourceName' and 'routeTemplate'. Here's an snippet:

 ... 
{ 
   "area": "boards", 
   "id": 
   "7f9949a0-95c2-4c29-9efd-c7f73fb27a63", 
   "maxVersion": 5.1, 
   "minVersion": 5.0, 
   "releasedVersion": "0.0", 
   "resourceName": "items", 
   "resourceVersion": 1, 
   "routeTemplate": "{project}/_apis/{area}/boards/{board}/{resource}/{*id}" 
},
{ 
   "area": "build", 
   "id": "5a21f5d2-5642-47e4-a0bd-1356e6731bee", 
   "maxVersion": 6.0, 
   "minVersion": 2.0, 
   "releasedVersion": "5.1", 
   "resourceName": "workitems", 
   "resourceVersion": 2, 
   "routeTemplate": "{project}/_apis/{area}/builds/{buildId}/{resource}" 
}
...

You can also use the JMESPath query syntax to reduce the list:

az devops invoke --query "[?area == 'build']"

Interesting note: If you study the source code for the az devops cli extension, you'll notice that all commands in the devops extension are using this same list as the underlying communication mechanism.

Finding the right endpoint

Finding the desired API in the list of endpoints might take a bit of research. All of the endpoints are grouped by 'area' and then 'resourceName'. I find that the 'area' keyword lines up fairly close with the API documentation, but you'll have to hunt through the endpoint list until you find the 'routeTemplate' that matches the API you're interested in.

Let's use the Get Latest Build REST API as an example. It's REST endpoint is defined as:

GET https://dev.azure.com/{organization}/{project}/_apis/build/latest/{definition}?api-version=6.0-preview.1

The routeTemplate is parameterized such that area and resource parameters correspond to the area and resourceName in the object definition.

From this, we hunt through all the 'build' endpoints until we find this matching endpoint:

{
  "area": "build",
  "id": "54481611-01f4-47f3-998f-160da0f0c229",
  "maxVersion": 6.0,
  "minVersion": 5.0,
  "releasedVersion": "0.0",
  "resourceName": "latest",
  "resourceVersion": 1,
  "routeTemplate": "{project}/_apis/{area}/{resource}/{definition}"
}

Invoking endpoints

Once you've identified the endpoint from the endpoint list, next you need to map the values from the route template to the command-line.

The mapping between command-line arguments and the routeTemplate should be fairly obvious. The values for "{area}" and "{resource}" are picked up from their corresponding command-line arguments, and the remaining arguments must be supplied as name-value pairs with the --route-parameters argument.

Using our Get Latest Build example, "{project}" and "{definition}" are provided on the command line like this:

az devops invoke `
     --area build `
     --resource latest `
     --organization https://dev.azure.com/myorgname `
     --route-parameters `
          project="MyProject" `
          definition=1234 `
     --api-version=6.0-preview

Adding query-string parameters

We can further extend this example by specifying query string parameters using the --query-parameters argument. In this example, we can get the latest build for a specific branch by specifying the branchName parameter:

az devops invoke `
     --area build --resource latest `
     --organization https://dev.azure.com/myorgname `
     --route-parameters project="MyProject" defintion=1234 `
     --query-parameters `
          branchName=develop `
     --api-version=6.0-preview

Note that while the CLI will validate route-parameters, it does not complain if you specify a query-string parameter that is misspelled or not supported.

Specifying the API Version

One of the challenges is knowing which API version to use. Frankly, I've had the most luck by specifying the latest version (eg 6.0-preview). As a general rule, the releasedVersion in the endpoint list should indicate which version to use, which is constrained by the 'maxVersion'.

If the releaseVersion is set to "0.0", then the preview flag is required.

Providing a JSON Body

To provide a JSON body for PUT and POST requests, you'll need to provide a JSON file using the --in-file and --httpMethod parameters.

For example, cancelling a build:

# Write the JSON body to disk
'{ "status": "Cancelling"}' | Out-File -FilePath .\body.json

# PATCH the build with a cancelling status
az devops invoke `
     --area build `
     --resource builds `
     --organization https://dev.azure.com/myorgname `
     --route-parameters `
          project="MyProject" `
          buildId=2345 `
     --in-file .\body.json `
     --http-method patch

Known Issues

By design, you would assume that the area and resourceNames in the list of endpoints are intended to be unique, but unfortunately this isn't the case. Again, referring to the source code of the extension, when trying to locate the endpoints by area + resource it appears to be a first-past-the-post scenario where only the first closest match is considered.

In this scenario, it would be helpful if we could specify the endpoint id from the command-line but this isn't supported yet.

To see the duplicates (it's not a small list):

$endpoints = az devops invoke | Select-Object -skip 1 | ConvertFrom-Json
$endpoints | Group-Object -Property area,resourceName | Where-Object { $_.Count -gt 1 }

The important thing to realize is that this list isn't unique to the az devops extension, it's actually a global list which is exposed from Azure DevOps. Perhaps how this list is obtained is something I'll blog about later.

Wrapping Up

While there are still somethings that are easier to do using the REST API, the Azure DevOps CLI offers a built-in capability to invoke the majority of the underlying APIs, though the biggest challenge is finding the right endpoint to use.

My personal preference is to start with the Azure DevOps CLI because I can jump in and start developing without having to worry about authentication headers, etc. I can also combine the results JMESPath filtering.