Invoke-ADORestAPI.ps1

function Invoke-ADORestAPI
{
    <#
    .Synopsis
        Invokes the ADO Rest API
    .Description
        Invokes the Azure DevOps REST API
    .Example
        # Uses the Azure DevOps REST api to get builds from a project
        $org = 'StartAutomating'
        $project = 'PSDevOps'
        Invoke-ADORestAPI "https://dev.azure.com/$org/$project/_apis/build/builds/?api-version=5.1"
    .Link
        Invoke-RestMethod
    #>

    [OutputType([PSObject])]
    [CmdletBinding(DefaultParameterSetName='Uri')]
    param(
    # The REST API Url
    [Parameter(Mandatory,Position=0,ValueFromPipelineByPropertyName,ParameterSetName='Uri')]
    [Alias('Url')]
    [uri]
    $Uri,

    <#
Specifies the method used for the web request. The acceptable values for this parameter are:
 - Default
 - Delete
 - Get
 - Head
 - Merge
 - Options
 - Patch
 - Post
 - Put
 - Trace
    #>

    [Parameter(ValueFromPipelineByPropertyName,ParameterSetName='Uri')]
    [ValidateSet('GET','DELETE','HEAD','MERGE','OPTIONS','PATCH','POST', 'PUT', 'TRACE')]
    [string]
    $Method = 'GET',

    # Specifies the body of the request.
    # If this value is a string, it will be passed as-is
    # Otherwise, this value will be converted into JSON.
    [Parameter(ValueFromPipelineByPropertyName,ParameterSetName='Uri')]
    [Object]
    $Body,

    # Parameters provided as part of the URL (in segments or a query string).
    [Parameter(ValueFromPipelineByPropertyName,ParameterSetName='Uri')]
    [Alias('UrlParameters')]
    [Collections.IDictionary]
    $UrlParameter = @{},

    # Additional parameters provided after the URL.
    [Parameter(ValueFromPipelineByPropertyName,ParameterSetName='Uri')]
    [Alias('QueryParameters')]
    [Collections.IDictionary]
    $QueryParameter = @{},

    # Specifies the content type of the web request.
    # If this parameter is omitted and the request method is POST, Invoke-RestMethod sets the content type to application/x-www-form-urlencoded. Otherwise, the content type is not specified in the call.
    [Parameter(ValueFromPipelineByPropertyName,ParameterSetName='Uri')]
    [string]
    $ContentType = 'application/json',

    # Specifies the headers of the web request. Enter a hash table or dictionary.
    [Parameter(ValueFromPipelineByPropertyName,ParameterSetName='Uri')]
    [System.Collections.IDictionary]
    [Alias('Header')]
    $Headers,

    # A Personal Access Token
    [Parameter(ValueFromPipelineByPropertyName)]
    [Alias('PAT')]
    [string]
    $PersonalAccessToken,

    # Specifies a user account that has permission to send the request. The default is the current user.
    # Type a user name, such as User01 or Domain01\User01, or enter a PSCredential object, such as one generated by the Get-Credential cmdlet.
    [Parameter(ValueFromPipelineByPropertyName)]
    [pscredential]
    [Management.Automation.CredentialAttribute()]
    $Credential,

    # Indicates that the cmdlet uses the credentials of the current user to send the web request.
    [Parameter(ValueFromPipelineByPropertyName)]
    [Alias('UseDefaultCredential')]
    [switch]
    $UseDefaultCredentials,

    # A continuation token. This is appended as a query parameter, and can be used to continue a request.
    # Invoke-ADORestAPI will call recursively invoke itself until a response does not have a ContinuationToken
    [Parameter(ValueFromPipelineByPropertyName,ParameterSetName='Uri')]
    [string]
    $ContinuationToken,

    # The typename of the results.
    [Parameter(ValueFromPipelineByPropertyName,ParameterSetName='Uri')]
    [Alias('Decorate','Decoration')]
    [string[]]
    $PSTypeName,

    # A set of additional properties to add to an object
    [Parameter(ValueFromPipelineByPropertyName,ParameterSetName='Uri')]
    [Collections.IDictionary]
    $Property,

    # A list of property names to remove from an object
    [Parameter(ValueFromPipelineByPropertyName,ParameterSetName='Uri')]
    [string[]]
    $RemoveProperty,

    # If provided, will expand a given property returned from the REST api.
    [Parameter(ValueFromPipelineByPropertyName,ParameterSetName='Uri')]
    [string]
    $ExpandProperty,

    # If provided, will decorate the values within a property in the return object.
    # This allows nested REST properties to work with the PowerShell Extended Type System.
    [Parameter(ValueFromPipelineByPropertyName,ParameterSetName='Uri')]
    [Collections.IDictionary]
    [Alias('TypeNameOfProperty')]
    $DecorateProperty,

    # If set, will cache results from a request. Only HTTP GET results will be cached.
    [Parameter(ValueFromPipelineByPropertyName)]
    [switch]
    $Cache,

    # If set, will return results as a byte array.
    [Parameter(ValueFromPipelineByPropertyName)]
    [Alias('Binary','AsByteArray')]
    [switch]
    $AsByte,

    # If set, will run as a background job.
    # This parameter will be ignored if the caller is piping the results of Invoke-ADORestAPI.
    # This parameter will also be ignore when calling with -DynamicParameter or -MapParameter.
    [Parameter(ValueFromPipelineByPropertyName)]
    [switch]
    $AsJob,

    # If set, will get the dynamic parameters that should be provided to any function that wraps Invoke-ADORestApi
    [Parameter(Mandatory,ParameterSetName='GetDynamicParameters',ValueFromPipelineByPropertyName)]
    [Alias('DynamicParameters')]
    [switch]
    $DynamicParameter,

    # If set, will return the parameters for any function that can be passed to Invoke-ADORestApi.
    # Unmapped parameters will be added as a noteproperty of the returned dictionary.
    [Parameter(Mandatory,ParameterSetName='MapParameters',ValueFromPipelineByPropertyName)]
    [Alias('MapParameters')]
    [Collections.IDictionary]
    $MapParameter
    )

     begin {
        # From [Irregular](https://github.com/StartAutomating/Irregular):
        # ?<REST_Variable> -VariableFormat Braces
        $RestVariable = [Regex]::new(@'
(?> # A variable can be in a URL segment or subdomain
    (?<Start>[/\.]) # Match the <Start>ing slash|dot ...
    (?<IsOptional>\?)? # ... an optional ? (to indicate optional) ...
    (?:
        \{(?<Variable>\w+)\} # ... A <Variable> name in {} OR
    )
|
    (?<IsOptional> # If it's optional it can also be
        [{\[](?<Start>/) # a bracket or brace, followed by a slash
    )
    (?<Variable>\w+)[}\]] # then a <Variable> name followed by } or ]
 
| # OR it can be in a query parameter:
    (?<Start>[\?\&]) # Match The <Start>ing ? or & ...
    (?<Query>[\$\w\-]+) # ... the <Query> parameter name ...
    = # ... an equals ...
    (?<IsOptional>\?)? # ... an optional ? (to indicate optional) ...
    (?:
        \{(?<Variable>\w+)\} # ... A <Variable> name in {} OR
    )
)
'@
, 'IgnoreCase,IgnorePatternWhitespace')

        $ReplaceRestVariable = {
            param($match)

            if ($urlParameter -and $urlParameter[$match.Groups["Variable"].Value]) {
                return $match.Groups["Start"].Value + $(
                        if ($match.Groups["Query"].Success) { $match.Groups["Query"].Value + '=' }
                    ) +
                    ([Web.HttpUtility]::UrlEncode(
                        $urlParameter[$match.Groups["Variable"].Value]
                    ))
            } else {
                return ''
            }
        }
    }

    process {
        if ($PSCmdlet.ParameterSetName -eq 'GetDynamicParameters') {
            if (-not $script:InvokeADORestAPIParams) {
                $script:InvokeADORestAPIParams = [Management.Automation.RuntimeDefinedParameterDictionary]::new()
                $InvokeADORestApi = $MyInvocation.MyCommand
                :nextInputParameter foreach ($in in ([Management.Automation.CommandMetaData]$InvokeADORestApi).Parameters.Keys) {
                    foreach ($ex in 'Uri','Method','Headers','Body','ContentType',
                        'ExpandProperty','Property','RemoveProperty','DecorateProperty',
                        'PSTypeName', 'ContinuationToken', 'DynamicParameter', 'MapParameter', 'UrlParameter', 'AsByte') {
                        if ($in -like $ex) { continue nextInputParameter }
                    }

                    $script:InvokeADORestAPIParams.Add($in, [Management.Automation.RuntimeDefinedParameter]::new(
                        $InvokeADORestApi.Parameters[$in].Name,
                        $InvokeADORestApi.Parameters[$in].ParameterType,
                        $InvokeADORestApi.Parameters[$in].Attributes
                    ))
                }
                foreach ($paramName in $script:InvokeADORestAPIParams.Keys) {
                    foreach ($attr in $script:InvokeADORestAPIParams[$paramName].Attributes) {
                         if ($attr.ValueFromPipeline) {$attr.ValueFromPipeline = $false}
                         if ($attr.ValueFromPipelineByPropertyName) {$attr.ValueFromPipelineByPropertyName = $false}
                    }
                }
            }
            return $script:InvokeADORestAPIParams
        }
        elseif ($PSCmdlet.ParameterSetName -eq 'MapParameters') {
            $invokeParams = [Ordered]@{} + $MapParameter # Then we copy our parameters
            $unmapped     = [Ordered]@{}
            foreach ($k in @($invokeParams.Keys)) {  # and walk thru each parameter name.
                # If a parameter isn't found in Invoke-ADORestAPI
                if (-not $MyInvocation.MyCommand.Parameters.ContainsKey($k)) {
                    $unmapped[$k] = $invokeParams[$k]
                    $invokeParams.Remove($k) # we remove it.
                }
            }
            if ($invokeParams.Credential) { $script:CachedCredential = $invokeParams.Credential }
            if (-not $invokeParams.Credential -and $script:CachedCredential) {
                $invokeParams.Credential = $script:CachedCredential
            }
            $invokeParams.psobject.properties.add([PSNoteProperty]::new('Unmapped',$unmapped))
            return $invokeParams
        }

        #region Prepare Parameters
        $irmSplat = @{} + $PSBoundParameters    # First, copy PSBoundParameters and remove the parameters that aren't Invoke-RestMethod's
        $irmSplat.Remove('PersonalAccessToken') # * -PersonalAccessToken
        $irmSplat.Remove('PSTypeName') # * -PSTypeName
        $irmSplat.Remove('Property') # *-Property
        $irmSplat.Remove('RemoveProperty') # *-RemoveProperty
        $irmSplat.Remove('ExpandProperty') # *-ExpandProperty
        $irmSplat.Remove('DecorateProperty')
        if (-not $PersonalAccessToken -and
            -not $Credential -and
            -not $UseDefaultCredentials -and
            $script:CachedPersonalAccessToken) {
            $psBoundParameters["PersonalAccessToken"] = $PersonalAccessToken = $script:CachedPersonalAccessToken
        }
        if ($AsJob -and $MyInvocation.PipelinePosition -eq $MyInvocation.PipelineLength) {
            $paramCopy = @{} + $PSBoundParameters
            $paramCopy.Remove('AsJob')
            $jobDefinition = [ScriptBlock]::Create(@'
param([Hashtable]$parameter)
'@
 + @"
function $($MyInvocation.MyCommand.Name) {
    $($MyInvocation.MyCommand.Definition)
}
$($MyInvocation.MyCommand.Name) @parameter
"@
)
            Start-Job -ScriptBlock $jobDefinition -ArgumentList $paramCopy
            return
        }

        if ($PersonalAccessToken) { # If there was a personal access token, set the authorization header
            if ($Headers) { # (make sure not to step on other headers).
                $irmSplat.Headers.Authorization = "Basic $([Convert]::ToBase64String([Text.Encoding]::UTF8.GetBytes(":$PersonalAccessToken")))"
            }
            else {

                $irmSplat.Headers = @{ # If you were wondering, the Personal Access Token is passed like an HTTP credential,
                    Authorization = # (by setting the authorization header to Basic Base64EncodedBytesOf UserName:Password).
                        # The very slight trick is that PersonalAccessToken's don't have a username
                        "Basic $([Convert]::ToBase64String([Text.Encoding]::UTF8.GetBytes(":$PersonalAccessToken")))"
                }
            }
            $script:CachedPersonalAccessToken = $PersonalAccessToken
        }
        if ($Body -and $Body -isnot [string]) { # If a body was passed, and it wasn't a string
            $irmSplat.Body = ConvertTo-Json -Depth 100 -InputObject $body # make it JSON.
        }
        if (-not $irmSplat.ContentType) { # If no content type was passed
            $irmSplat.ContentType = $ContentType # set it to the default.
        }
        #endregion Prepare Parameters

        if (-not $script:AzureDevOpsRequestCache) { $script:AzureDevOpsRequestCache = @{} }

        $uri = $RestVariable.Replace($uri, $ReplaceRestVariable)

        #region Call Invoke-RestMethod
        if ($ContinuationToken) {
            $QueryParameter['ContinuationToken'] = $ContinuationToken
        }

        if ($QueryParameter -and $QueryParameter.Count) {
            $uri =
                "$uri" +
                $(if (-not $uri.Query) { '?' } elseif (-not "$Uri".EndsWith('?')) { '&' }) +
                $(
                    @(foreach ($qp in $QueryParameter.GetEnumerator()) {
                        '' + $qp.Key + '=' + [Web.HttpUtility]::UrlEncode($qp.Value).Replace('+', '%20')
                    }) -join '&'
                )
        }
        if ($Cache -and $method -eq 'Get' -and $script:AzureDevOpsRequestCache[$uri]) {
            foreach ($out in $script:AzureDevOpsRequestCache[$uri]) { $out }
            return
        }
        $webRequest =  [Net.WebRequest]::Create($uri)
        $webRequest.Method = $Method
        $webRequest.contentType = $ContentType
        if ($irmSplat.Headers) {
            foreach ($h in $irmSplat.Headers.GetEnumerator()) {
                $webRequest.headers.add($h.Key, $h.Value)
            }
        }
        if ($UseDefaultCredentials) {
            $webRequest.useDefaultCredentials = $UseDefaultCredentials
        }
        elseif ($Credential) {
            $webRequest.credentials = $Credential.GetNetworkCredential()
        }

        if ($irmSplat.Body) {

            $bytes = [Text.Encoding]::UTF8.GetBytes($irmSplat.Body)
            $webRequest.contentLength = $bytes.Length
            $requestStream = $webRequest.GetRequestStream()
            $requestStream.Write($bytes, 0, $bytes.Length)
            $requestStream.Close()
        } else {
            $webRequest.contentLength = 0
        }

        if ($Property -and $Property.Count) {
            $psProperties = @(
                foreach ($propKeyValue in $Property.GetEnumerator()) {
                    if ($propKeyValue.Value -as [ScriptBlock[]]) {
                        [PSScriptProperty]::new.Invoke(@($propKeyValue.Key) + $propKeyValue.Value)
                    } else {
                        [PSNoteProperty]::new($propKeyValue.Key, $propKeyValue.Value)
                    }
                }
            )
        }

        Write-Verbose "$Method $Uri [$($webRequest.ContentLength) bytes]"

        $response = . {

            $webResponse =
                try {
                    $WebRequest.GetResponse()
                } catch {
                    $ex = $_
                    if ($ex.Exception.InnerException.Response) {
                        $streamIn = [IO.StreamReader]::new($ex.Exception.InnerException.Response.GetResponseStream())
                        $strResponse = $streamIn.ReadToEnd()
                        $streamIn.Close()
                        $streamIn.Dispose()
                        $PSCmdlet.WriteError(
                            [Management.Automation.ErrorRecord]::new(
                                [Exception]::new("$($ex.Exception.InnerException.Response.StatusCode, $ex.Exception.InnerException.Response.StatusDescription)$strResponse ", $ex.Exception.InnerException
                            ), $ex.Exception.HResult, 'NotSpecified', $webRequest)
                        )
                        return
                    } else {
                        $errorRecord = [Management.Automation.ErrorRecord]::new($ex.Exception, $ex.Exception.HResult, 'NotSpecified', $webRequest)
                        $PSCmdlet.WriteError($errorRecord)
                        return
                    }
                }
            $rs = $webresponse.GetResponseStream()
            $responseHeaders = $webresponse.Headers
            $responseHeaders =
                if ($responseHeaders -and $responseHeaders.GetEnumerator()) {
                    $reHead = @{}
                    foreach ($r in $responseHeaders.GetEnumerator()) {
                        $reHead[$r] = $responseHeaders[$r]
                    }
                    $reHead
                } else {
                    @{}
                }
            if ($AsByte) {
                $ms = [IO.MemoryStream]::new()
                $rs.CopyTo($ms)
                ,$ms.ToArray()
                $ms.Dispose()
                return
            }
            $streamIn = [IO.StreamReader]::new($rs, $webResponse.Contentencoding)
            $strResponse = $streamIn.ReadToEnd()
            if ($webResponse.ContentType -like '*json*') {
                try {
                    $strResponse | ConvertFrom-Json
                } catch {
                    $strResponse
                }
            } else {
                $strResponse
            }

            $streamIn.Close()

        } 2>&1
        $null = $null
        # We call Invoke-RestMethod with the parameters we've passed in.
        # It will take care of converting the results from JSON.
        if ($response -is [byte[]]) {
            return $response
        }

        $apiOutput =
            $response |
            & { process {
                $in = $_

                # What it will not do is "unroll" them.
                # A lot of things in the Azure DevOps REST apis come back as a count/value pair
                if ($in -eq 'null') {
                    return
                }                
                if ($ExpandProperty) {
                    if ($in.$ExpandProperty) {
                        return $in.$ExpandProperty
                    }
                } elseif ($in.Value -and $in.Count) {  # If that's what we're dealing with
                    $in.Value # pass value down the pipe.
                } elseif ($in -notlike '*<html*') { # Otherise, As long as the value doesn't look like HTML,
                    $in # pass it down the pipe.
                } else { # If it happened to look like HTML, write an error
                    $PSCmdlet.WriteError(
                        [Management.Automation.ErrorRecord]::new(
                            [Exception]::new("Response was HTML, Request Failed."),
                            "ResultWasHTML", "NotSpecified", $in))
                    $psCmdlet.WriteVerbose("$in") # and write the full content to verbose.
                    return
                }
            } } 2>&1 |
            & { process { # One more step of the pipeline will unroll each of the values.


                $in = $_
                if ($in -is [string]) { return $in }                
                if ($null -ne $in.Count -and $in.Count -eq 0) {
                    return
                }

                if ($PSTypeName -and # If we have a PSTypeName (to apply formatting)
                    $in -isnot [Management.Automation.ErrorRecord] # and it is not an error (which we do not want to format)
                ) {
                    $in.PSTypeNames.Clear() # then clear the existing typenames and decorate the object.
                    foreach ($t in $PSTypeName) {
                        $in.PSTypeNames.add($T)
                    }
                }

                if ($Property -and $Property.Count) {
                    foreach ($prop in $psProperties) {
                        $in.PSObject.Members.Add($prop, $true)
                    }
                }
                if ($RemoveProperty) {
                    foreach ($propToRemove in $RemoveProperty) {
                        $in.PSObject.Properties.Remove($propToRemove)
                    }
                }
                if ($DecorateProperty) {
                    foreach ($kv in $DecorateProperty.GetEnumerator()) {
                        if ($in.$($kv.Key)) {
                            foreach ($v in $in.$($kv.Key)) {
                                if ($null -eq $v -or -not $v.pstypenames) { continue }
                                $v.pstypenames.clear()
                                foreach ($tn in $kv.Value) {
                                    $v.pstypenames.add($tn)
                                }
                            }
                        }
                    }
                }
                return $in # output the object and we're done.
            } }
        #endregion Call Invoke-RestMethod

        # If we have a continuation token
        $paramCopy = @{} + $PSBoundParameters
        $invokeResults = [Collections.ArrayList]::new()
        & {
            if ($responseHeaders -and $responseHeaders['X-MS-ContinuationToken'] -and $Uri -notmatch '\$(top|first)=') {
                if ($Uri.Query -notmatch '\$(top|first)=') { # and the uri is not have top or first parameter
                    $apiOutput # output

                    # Then recursively call yourself with the ContinuationToken
                    $paramCopy['ContinuationToken'] = $responseHeaders.'X-MS-ContinuationToken'
                    Invoke-ADORestAPI @paramCopy
                } else {
                    # Otherwise, output, but add on the ContinuationToken as a property.
                    $apiOutput |
                        Add-Member NoteProperty ContinuationToken $responseHeaders.'X-MS-ContinuationToken' -Force -PassThru
                }
            } else { # If we didn't have a continuation token, just output
                $apiOutput
            }
        } | & { process {
            $in = $_
            if ($in) {
                $null = $invokeResults.Add($in)
                $in

            }
        } }

        if ($Method -eq 'Get') {
            if ($Cache -and -not $ContinuationToken) {
                $script:AzureDevOpsRequestCache[$uri] = $invokeResults.ToArray()
            }
        } else {
            $null =
                New-Event -SourceIdentifier "Invoke-ADORestApi.$Method" -MessageData $(
                    $paramCopy.Remove('PersonalAccessToken')
                    $paramCopy+=@{Response = $response;Results  = $invokeResults.ToArray() }
                    [PSCustomObject]$paramCopy
                )
        }
    }
}