Public/Web/Format-Uri.ps1

function Format-Uri {

    <#
        .SYNOPSIS
            Normalizes the given Uri of an Azure DevOps Rest Api.
            Ends all uris with a '/' character.
            Adds or sets query parameters.

        .DESCRIPTION
            This function takes a Uri and a hashtable of parameters.
            It normalizes the given Uri and adds or sets the specified query parameters in the
            query string of the Uri.
            End all Uri paths with a '/' character.

        .PARAMETER Uri
            The Uri to to normalize and add or set the query parameters in.

        .PARAMETER NoTrailingSlash
            If specified, the trailing slash is removed from the Uri.

        .PARAMETER Parameters
            An optional hashtable or PSCustomObject of key-value pairs representing the query parameters to add or set.

        .EXAMPLE
            $uri = "https://example.com?a=1&b=2"
            $params = @{ c = 3; d = 4 }
            $newUri = Add-QueryParameter -Uri $uri -Parameters $params

            $newUri will be "https://example.com?a=1&b=2&c=3&d=4"
    #>


    [CmdletBinding(DefaultParameterSetName = 'Parameters')]
    param(
        [Parameter(ParameterSetName = 'Pipeline', Mandatory, ValueFromPipeline)]
        [Parameter(ParameterSetName = 'Parameters', Mandatory, Position = 0)]
        [AllowEmptyString()]
        [AllowNull()]
        $Uri,

        [Parameter(ParameterSetName = 'Parameters', Position = 1)]
        [AllowNull()]
        $Parameters,

        [Parameter(ParameterSetName = 'Pipeline')]
        [Parameter(ParameterSetName = 'Parameters')]
        [Alias('RemoveTrailingSlash','LastSegment')]
        [switch] $NoTrailingSlash
    )

    process {

        # if $null or empty, return empty string
        if ([string]::IsNullOrWhiteSpace($Uri)) {
            return [string]::Empty
        }

        # If it's a string
        if ($Uri -is [string]) {

            $Uri = $Uri.Trim()

            # replace all instances of '\' by '/' up to first '?' or '#' character
            $index = $Uri.IndexOfAny('?#')
            if ($index -ne -1) {
                $Uri = $Uri.Substring(0, $index).Replace('\', '/') + $Uri.Substring($index)
            } else {
                $Uri = $Uri.Replace('\', '/')
            }

            $original = $Uri

            # If it's a relative URI, convert it to an absolute URI using random root
            if ([Uri]::IsWellFormedUriString($Uri, [UriKind]::Relative)) {
                $root = [System.Uri]::new("https://www." + [guid]::NewGuid().ToString("D") + ".tmp")
                $Uri = [System.Uri]::new($root, $Uri, $true)
            } else {
                $Uri = [System.Uri]::new($Uri, $true)
            }
        } else {
            $original = $Uri.OriginalString
        }

        # otherwise
        # - replace all instances of '\' by '/'
        # - trim whitespace
        # - trim trailing '/','\','?' characters
        # - add back single trailing '/'
        # $Uri = $Uri.Replace('\', '/')
        $builder = [UriBuilder]::new($Uri)
        $builder.Path = $builder.Path.Trim()
        $builder.Path = $builder.Path.Replace('\', '/')
        $builder.Path = $builder.Path.Replace('//', '/')
        $builder.Path = $builder.Path.Trim('/')

        # If requested no trailing slash, do not add it back
        if (!$NoTrailingSlash.IsPresent -or ($NoTrailingSlash -eq $false)) {
            if (!$builder.Path.EndsWith('/')) {
                $builder.Path += '/'
            }
        }

        # Remove the port, if it's the default port
        if ($Uri.IsDefaultPort) {
            $builder.Port = -1
        }

        # Parse the query string
        $query = [System.Web.HttpUtility]::ParseQueryString($Uri.Query)

        # Set the parameters
        if ($Parameters) {
            if ($Parameters -is [hashtable]) {
                foreach ($key in $Parameters.Keys) {
                    $query.Set($key, $Parameters[$key])
                }
            } elseif ($Parameters -is [PSCustomObject]) {
                foreach ($key in $Parameters.PSObject.Properties.Name) {
                    $query.Set($key, $Parameters.$key)
                }
            }
        }

        # Set the Query property
        $builder.Query = $query.ToString()
        $builder.Query = $builder.Query.Trim()
        $builder.Query = $builder.Query.Trim('?')

        # If it's an absolute Uri, just return it
        if (!$root) {
            # The original path was absolute, so just return the absolute URI
            return $builder.Uri.AbsoluteUri
        }

        # The original path was relative, so we need to make it relative again
        $result = [string]::Empty

        # If the original path starts with a slash, we should add it back...
        # Actually no. In example:
        # $baseUri = 'http://www.dev-tfs.org/tfs/internal_projects/'
        # $relative '/_apis/projects'
        # We want the result be:
        # 'http://www.dev-tfs.org/tfs/internal_projects/_apis/projects'
        # We don't want the result to be:
        # 'http://www.dev-tfs.org/_apis/projects/'
        # Which it would be when aplying the normal rules - relative path starting with a slash
        # means 'from the root of the base Uri'.
        if ($original.StartsWith('/')) {
            # DO NOT add the slash
            # $result = '/'
        }

        # Add the relative path
        $result += $root.MakeRelativeUri($builder.Uri).OriginalString

        $result
    }
}