Horizon REST API + Powershell 7: pagination and filtering (with samples)

A while ago Robin Stolpe (Twitter) asked me if it was possible to find what machines a user is assigned to in a Horizon environment. To answer this I first started messing with the soap api’s and had a really hard time to filter for the user id with the various machine related queries. When looking for the assignedUser property this was no problem but this has been deprecated and replaced by assignedUsers because of the added functionality for assigning multiple users to a machine. Instead of becoming too frustrated I decided to switch paths and user Powershell with the rest api’s.

TLDR: I have defined a broadly usable function for just about all Horizon REST GET api calls with or without filtering that also works for GET calls without any additions and for GET calls that require an Id for example. You can scroll down to the bottom to get that function and a script that uses it.

Warning: the sample code in the script & example function require PowerShell 7!

 

Filtering

One of the things I hadn’t done before with these was filtering and pagination in a more useful way than just writing the entire url out. VMware has a guide available for filtering that can be found here. This was a good way to get started but I found it easiest to skip the single searches entirely and always use the And or Or filtering types for chained filtering.

The method I am using to create the filter is to first define an ordered hashtable. Why ordered? The api calls require the Name/value pairs in a certain order and if you just add them to a regular hashtable this order will change.

$filterhashtable = [ordered]@{}

Next I add the first Name/value pair for the filtertype, this is either And or Or

$filterhashtable.add('type', 'And')

Next I add another pair with name filters and value an array. I could use .add again or just set the name like I do here:

$filterhashtable.filters = @()

The filters name array members again need to be ordered hashtable’s (as you can see I search for a user here)

$userfilter= [ordered]@{}
$userfilter.add('type','Equals')
$userfilter.add('name','name')
$userfilter.add('value',$User)

$domainfilter= [ordered]@{}
$domainfilter.add('type','Equals')
$domainfilter.add('name','domain')
$domainfilter.add('value',$Domain)

and I add both of them to the filters object

$filterhashtable.filters+=$userfilter
$filterhashtable.filters+=$domainfilter

and lets’s show what’s in the $filterhashtable

To be able to use this within the invoke-restmethod url I need to convert this to json and compress it to a single line

$filterflat = $filterhashtable | ConvertTo-Json -Compress
$filterflat

Pagination

For the pagination I needed the HAS_MORE_RECORDS property of the returned headers. If this is TRUE there are more records to be found, sadly this is not available in the classic invoke-restmethod from Powershell v5. With Powershell 7 you can add -ResponseHeadersVariable responseheader to store the headers in a variable called $responseheader. With this variable you can easily create a do while loop.

$urlstart= $ServerURL+"/rest/"+$RestMethod+"?page="
$results = [System.Collections.ArrayList]@()
$page = 1
$uri = $urlstart+$page+"&size=$pagesize"
$response = Invoke-RestMethod $uri -Method 'GET' -Headers (Get-HRHeader -accessToken $accessToken) -ResponseHeadersVariable responseheader
$response.foreach({$results.add($_)}) | out-null
if ($responseheader.HAS_MORE_RECORDS -contains "TRUE") {
    do {
        $page++
        $uri = $urlstart+$page+"&size=$pagesize"
        $response = Invoke-RestMethod $uri -Method 'GET' -Headers (Get-HRHeader -accessToken $accessToken) -ResponseHeadersVariable responseheader
        $response.foreach({$results.add($_)}) | out-null
    } until ($responseheader.HAS_MORE_RECORDS -notcontains "TRUE")
}
return $results

Please be advised that without some additional parameters this code isn’t usable yet, scroll down for something you can really use.

The function

To combine the above 2 items I have created a function that can use all of the above but is also able to do regular get calls and get calls that require an id in the url.

function Get-HorizonRestData(){
    [CmdletBinding(DefaultParametersetName='None')] 
    param(
        [Parameter(Mandatory=$true,
        HelpMessage='url to the server i.e. https://pod1cbr1.loft.lab' )]
        [string] $ServerURL,

        [Parameter(Mandatory=$true,
        ParameterSetName="filteringandpagination",
        HelpMessage='Array of ordered hashtables' )]
        [array] $filters,

        [Parameter(Mandatory=$true,
        ParameterSetName="filteringandpagination",
        HelpMessage='Type of filter Options: And, Or' )]
        [ValidateSet('And','Or')]
        [string] $Filtertype,

        [Parameter(Mandatory=$false,
        ParameterSetName="filteringandpagination",
        HelpMessage='Page size, default = 500' )]
        [int] $pagesize = 500,

        [Parameter(Mandatory=$true,
        HelpMessage='Part after the url in the swagger UI i.e. /rest/external/v1/ad-users-or-groups' )]
        [string] $RestMethod,

        [Parameter(Mandatory=$true,
        HelpMessage='Part after the url in the swagger UI i.e. /rest/external/v1/ad-users-or-groups' )]
        [PSCustomObject] $accessToken,

        [Parameter(Mandatory=$false,
        ParameterSetName="filteringandpagination",
        HelpMessage='$True for rest methods that contain pagination and filtering, default = False' )]
        [switch] $filteringandpagination,

        [Parameter(Mandatory=$false,
        ParameterSetName="id",
        HelpMessage='To be used with single id based queries like /monitor/v1/connection-servers/{id}' )]
        [string] $id
    )
    if($filteringandpagination){
        if ($filters){
            $filterhashtable = [ordered]@{}
            $filterhashtable.add('type',$filtertype)
            $filterhashtable.filters = @()
            foreach($filter in $filters){
                $filterhashtable.filters+=$filter
            }
            $filterflat=$filterhashtable | convertto-json -Compress
            $urlstart= $ServerURL+"/rest/"+$RestMethod+"?filter="+$filterflat+"&page="
        }
        else{
            $urlstart= $ServerURL+"/rest/"+$RestMethod+"?page="
        }
        $results = [System.Collections.ArrayList]@()
        $page = 1
        $uri = $urlstart+$page+"&size=$pagesize"
        $response = Invoke-RestMethod $uri -Method 'GET' -Headers (Get-HRHeader -accessToken $accessToken) -ResponseHeadersVariable responseheader
        $response.foreach({$results.add($_)}) | out-null
        if ($responseheader.HAS_MORE_RECORDS -contains "TRUE") {
            do {
                $page++
                $uri = $urlstart+$page+"&size=$pagesize"
                $response = Invoke-RestMethod $uri -Method 'GET' -Headers (Get-HRHeader -accessToken $accessToken) -ResponseHeadersVariable responseheader
                $response.foreach({$results.add($_)}) | out-null
            } until ($responseheader.HAS_MORE_RECORDS -notcontains "TRUE")
        }
    }
    elseif($id){
        $uri= $ServerURL+"/rest/"+$RestMethod+"/"+$id
        $results = Invoke-RestMethod $uri -Method 'GET' -Headers (Get-HRHeader -accessToken $accessToken) -ResponseHeadersVariable responseheader
    }
    else{
        $uri= $ServerURL+"/rest/"+$RestMethod
        $results = Invoke-RestMethod $uri -Method 'GET' -Headers (Get-HRHeader -accessToken $accessToken) -ResponseHeadersVariable responseheader
    }

    return $results
}

As you can see there are several arguments:

  • ServerURL
    • This is the url to the connection server i.e. https://server.domain
  • Filters
    • An Array of ordered hashtables as you can find in the filtering paragraph
  • filtertype
    • Sets the filter type, this needs to be And or Or
  • PageSize
    • This is optional if you want to change from the default 500 results that I have set
  • RestMethod
    • This is the RestMethod that you can copy from the Swagger URL or API Explorer.
  • AccessToken
    • This is the accesstoken you get as a result when using open-hrconnection from previous samples to authenticate (see the sample script below)
  • Filteringandpagination
    • Add this argument to use the filtering and/or pagination options
  • Id
    • Use this for REST API Get calls where an Id is required in the URI

Examples

some usable examples would be:

Get-HorizonRestData -ServerURL $url -RestMethod "/monitor/connection-servers" -accessToken $accessToken 
Get-HorizonRestData -ServerURL $url -RestMethod "/monitor/connection-servers" -accessToken $accessToken -id $connectionserverid
Get-HorizonRestData -ServerURL $url -filteringandpagination -Filtertype "And" -filters $machinefilters -RestMethod "/inventory/v1/machines" -accessToken $accessToken

Sample Script

The script below (and available on Github here) aks for credentials if you don’t supply the object, connectionserver FQDN (no url needed), user and domain to search for and returns an array of machines the user is assigned to. It uses the default functions Andrew Morgan created a long time ago and my function to use the get methods.

<#
    .SYNOPSIS
    Retreives all machines a user is assigned to

    .DESCRIPTION
    This script uses the Horizon rest api's to query the Horizon database for all machines a user is assigned to.

    .EXAMPLE
    .\find_user_assigned_desktops.ps1 -Credential $creds -ConnectionServerFQDN pod2cbr1.loft.lab -UserName "User2"

    .PARAMETER Credential
    Mandatory: No
    Type: PSCredential
    Object with credentials for the connection server with domain\username and password

    .PARAMETER ConnectionServerFQDN
    Mandatory: Yes
    Default: String
    FQDN of the connection server to connect to

    .PARAMETER User
    Mandatory: Yes
    Username of the user to look for

    .PARAMETER Domain
    Mandatory: Yes
    Domain to look in

    .NOTES
    Created by: Wouter Kursten
    First version: 02-10-2021

    .COMPONENT
    Powershell Core

#>

[CmdletBinding()]
param (
    [Parameter(Mandatory=$false,
    HelpMessage='Credential object as domain\username with password' )]
    [PSCredential] $Credential,

    [Parameter(Mandatory=$true,  HelpMessage='FQDN of the connectionserver' )]
    [ValidateNotNullOrEmpty()]
    [string] $ConnectionServerFQDN,

    [parameter(Mandatory = $true,
    HelpMessage = "Username of the user to look for.")]
    [string]$User = $false,

    [parameter(Mandatory = $true,
    HelpMessage = "Domain where the user object exists.")]
    [string]$Domain = $false
)

function Get-HRHeader(){
    param($accessToken)
    return @{
        'Authorization' = 'Bearer ' + $($accessToken.access_token)
        'Content-Type' = "application/json"
    }
}
function Open-HRConnection(){
    param(
        [string] $username,
        [string] $password,
        [string] $domain,
        [string] $url
    )

    $Credentials = New-Object psobject -Property @{
        username = $username
        password = $password
        domain = $domain
    }

    return invoke-restmethod -Method Post -uri "$url/rest/login" -ContentType "application/json" -Body ($Credentials | ConvertTo-Json)
}

function Close-HRConnection(){
    param(
        $accessToken,
        $url
    )
    return Invoke-RestMethod -Method post -uri "$url/rest/logout" -ContentType "application/json" -Body ($accessToken | ConvertTo-Json)
}

function Get-HorizonRestData(){
    [CmdletBinding(DefaultParametersetName='None')] 
    param(
        [Parameter(Mandatory=$true,
        HelpMessage='url to the server i.e. https://pod1cbr1.loft.lab' )]
        [string] $ServerURL,

        [Parameter(Mandatory=$true,
        ParameterSetName="filteringandpagination",
        HelpMessage='Array of ordered hashtables' )]
        [array] $filters,

        [Parameter(Mandatory=$true,
        ParameterSetName="filteringandpagination",
        HelpMessage='Type of filter Options: And, Or' )]
        [ValidateSet('And','Or')]
        [string] $Filtertype,

        [Parameter(Mandatory=$false,
        ParameterSetName="filteringandpagination",
        HelpMessage='Page size, default = 500' )]
        [int] $pagesize = 500,

        [Parameter(Mandatory=$true,
        HelpMessage='Part after the url in the swagger UI i.e. /external/v1/ad-users-or-groups' )]
        [string] $RestMethod,

        [Parameter(Mandatory=$true,
        HelpMessage='Part after the url in the swagger UI i.e. /external/v1/ad-users-or-groups' )]
        [PSCustomObject] $accessToken,

        [Parameter(Mandatory=$false,
        ParameterSetName="filteringandpagination",
        HelpMessage='$True for rest methods that contain pagination and filtering, default = False' )]
        [switch] $filteringandpagination,

        [Parameter(Mandatory=$false,
        ParameterSetName="id",
        HelpMessage='To be used with single id based queries like /monitor/v1/connection-servers/{id}' )]
        [string] $id
    )
    if($filteringandpagination){
        if ($filters){
            $filterhashtable = [ordered]@{}
            $filterhashtable.add('type',$filtertype)
            $filterhashtable.filters = @()
            foreach($filter in $filters){
                $filterhashtable.filters+=$filter
            }
            $filterflat=$filterhashtable | convertto-json -Compress
            $urlstart= $ServerURL+"/rest/"+$RestMethod+"?filter="+$filterflat+"&page="
        }
        else{
            $urlstart= $ServerURL+"/rest/"+$RestMethod+"?page="
        }
        $results = [System.Collections.ArrayList]@()
        $page = 1
        $uri = $urlstart+$page+"&size=$pagesize"
        $response = Invoke-RestMethod $uri -Method 'GET' -Headers (Get-HRHeader -accessToken $accessToken) -ResponseHeadersVariable responseheader
        $response.foreach({$results.add($_)}) | out-null
        if ($responseheader.HAS_MORE_RECORDS -contains "TRUE") {
            do {
                $page++
                $uri = $urlstart+$page+"&size=$pagesize"
                $response = Invoke-RestMethod $uri -Method 'GET' -Headers (Get-HRHeader -accessToken $accessToken) -ResponseHeadersVariable responseheader
                $response.foreach({$results.add($_)}) | out-null
            } until ($responseheader.HAS_MORE_RECORDS -notcontains "TRUE")
        }
    }
    elseif($id){
        $uri= $ServerURL+"/rest/"+$RestMethod+"/"+$id
        $results = Invoke-RestMethod $uri -Method 'GET' -Headers (Get-HRHeader -accessToken $accessToken) -ResponseHeadersVariable responseheader
    }
    else{
        $uri= $ServerURL+"/rest/"+$RestMethod
        $results = Invoke-RestMethod $uri -Method 'GET' -Headers (Get-HRHeader -accessToken $accessToken) -ResponseHeadersVariable responseheader
    }

    return $results
}

if($Credential){
    $creds = $credential
}
else{
    $creds = get-credential
}

$ErrorActionPreference = 'Stop'

$username=($creds.username).split("\")[1]
$domain=($creds.username).split("\")[0]
$password=$creds.password

$url = "https://$ConnectionServerFQDN"

$BSTR = [System.Runtime.InteropServices.Marshal]::SecureStringToBSTR($password) 
$UnsecurePassword = [System.Runtime.InteropServices.Marshal]::PtrToStringAuto($BSTR)

$accessToken = Open-HRConnection -username $username -password $UnsecurePassword -domain $Domain -url $url

$userfilters = @()
$userfilter= [ordered]@{}
$userfilter.add('type','Equals')
$userfilter.add('name','name')
$userfilter.add('value',$User)
$userfilters+=$userfilter
$domainfilter= [ordered]@{}
$domainfilter.add('type','Equals')
$domainfilter.add('name','domain')
$domainfilter.add('value',$Domain)
$userfilters+=$domainfilter

$userobject = Get-HorizonRestData -ServerURL $url -filteringandpagination -Filtertype "And" -filters $userfilters -RestMethod "/external/v1/ad-users-or-groups" -accessToken $accessToken

$machinefilters = @()
$machinefilter= [ordered]@{}
$machinefilter.add('type','Contains')
$machinefilter.add('name','user_ids')
$machinefilter.add('value',($userobject).id)
$machinefilters+=$machinefilter
$machines = Get-HorizonRestData -ServerURL $url -filteringandpagination -Filtertype "And" -filters $machinefilters -RestMethod "/inventory/v1/machines" -accessToken $accessToken

return $machines

I use it like this to only display the machine names

(D:\GIT\Scripts\find_user_assigned_desktops.ps1 -Credential $creds -ConnectionServerFQDN "pod1cbr1.loft.lab" -User "user1" -Domain "loft.lab").name

You see some names in the 2*** range double but that is a Desktop Pool with Multiple Assignments

Getting the full machine objects is also possible

D:\GIT\Scripts\find_user_assigned_desktops.ps1 -Credential $creds -ConnectionServerFQDN "pod1cbr1.loft.lab" -User "m_wouter" -Domain "loft.lab"

Bookmark the permalink.