Public/Web/Invoke-CurlWebRequest.ps1

[System.Diagnostics.CodeAnalysis.SuppressMessageAttribute(
    'PSAvoidUsingCmdletAliases', '',
    Justification = 'Intentional usage of curl executable.'
)]
param()

function Invoke-CurlWebRequest {

    <#
        .SYNOPSIS
            Using curl executable to invoke web request instead of Invoke-WebRequest.

        .DESCRIPTION
            This function is used by the module to call web services instead of Invoke-WebRequest.
            It is not intended to be called directly.

            Parameters are intended to be the same as in Invoke-WebRequest.
            Declared as single parameter due to simplicity of declaration.

        .NOTES
            username password *does* work on linux
            ssl certificate check *can* be skipped on linux
    #>


    [CmdletBinding()]
    param($params)

    process {

        if (!$Headers -and $params.ContainsKey('Headers')) {
            $Headers = $params['Headers']
        }

        # Report params when verbose requested
        if ($VerbosePreference -eq 'Continue') {
            $headersWithValues = ($Headers.GetEnumerator() `
                | ForEach-Object { " $($_.Key): $($_.Value)" } `
            ) -join "`n"

            $paramsWithValues = ($params.GetEnumerator() `
                | Where-Object { $_.Key -ne 'Headers' } `
                | ForEach-Object { " $($_.Key): $($_.Value)" } `
            ) -join "`n"

            Write-Verbose "curl `n$($headersWithValues)`n$($paramsWithValues)"
        }

        # Determine credentials and authentication type
        # use --anyauth or --ntlm for username/password (--anyauth does not work on linux...)
        # use --basic for PAT
        # use --anyauth for OAuth2
        $authParams = '--anyauth'

        if ($params.Credential) {
            # If Credentials are provided, use NTLM authentication
            $username = $params.Credential.GetNetworkCredential().UserName
            $password = $params.Credential.GetNetworkCredential().Password
            $authParams = '--ntlm', '--user', "$($username):$($password)"
        } elseif ($Headers['Authorization']) {
            # If Authorization header is provided, extract scheme and value
            $scheme, $authHeaderValue = ($Headers['Authorization'] -split ' ')
            if ($scheme -eq 'Bearer') {
                # curl --oauth2-bearer YOUR_TOKEN https://dev-tfs/tfs/internal_projects/zvjs/_apis...
                $authParams = '--oauth2-bearer', $authHeaderValue
            } elseif ($scheme -eq 'Basic') {
                # If scheme is Basic take username and password from it
                $username, $password = [System.Text.Encoding]::UTF8.GetString(
                    [System.Convert]::FromBase64String($authHeaderValue)
                ) -split ':'
                # If username is provided, default to NTLM authentication
                if ($username) {
                    $authParams = '--ntlm', '--user', "$($username):$($password)"
                } else {
                    # assuming it is a PAT auth
                    $authParams = '--basic', '--user', ":$($password)"
                }
            }
        }

        # Create the parameters for curl command
        $curlParams = @(
            # use progressbar instead of statistics
            # usefull when redirecting error output - won't clutter the output
            '-#'
            # silent
            '--silent'
            # show errors
            '--show-error'
            # no url globbing
            '--globoff'
            # include headers in output
            '--include'
            # disable certificate check
            '--insecure'
            # timeout
            '--connect-timeout', 5
            # url to call
            '--url', $params.uri
            # requested authorization parameters
            $authParams
        )

        # Add additional Headers (except Authorization)
        foreach ($header in $Headers.GetEnumerator()) {
            if ($header.Key -eq 'Authorization') {
                continue
            }

            $curlParams += '--header', "$($header.Key): $($header.Value)"
        }

        # Use given method
        if ($params.ContainsKey('Method')) {
            $curlParams += '--request', $params.Method
        }

        # Use Body
        if ($params.ContainsKey('Body')) {
            # Add Content-Type if provided Body
            if ($params.ContainsKey('ContentType')) {
                $curlParams += '--header', "Content-Type: $($params.ContentType)"
            }

            # Add the body if provided
            $curlParams += '--data', $params.Body
        }

        # Execute the curl command

        # Ensure the input / output is UTF-8 encoded
        $inputEncoding = [console]::InputEncoding
        $outputEncoding = [console]::OutputEncoding
        [console]::InputEncoding = [console]::OutputEncoding = [System.Text.UTF8Encoding]::new()

        # If debug is enabled, show the curl command
        if ($DebugPreference -eq 'Continue') {
            Write-Verbose "curl $($curlParams)"
        }

        # Execute the curl executable
        if ((Get-PSVersion) -lt 6) {
            # PS5 does a lousy job of encoding the parameters
            $curlResponse = & curl.exe @curlParams *>&1

            # Tried a workaround, but could not figure out the right way to escape special
            # characters in input parameters
            # $psi = [System.Diagnostics.ProcessStartInfo]::new()
            # $psi.RedirectStandardOutput = $true
            # $psi.RedirectStandardError = $true
            # $psi.RedirectStandardInput = $true
            # $psi.UseShellExecute = $false
            # $psi.CreateNoWindow = $false
            # $psi.StandardOutputEncoding = [System.Text.Encoding]::UTF8
            # $psi.StandardErrorEncoding = [System.Text.Encoding]::UTF8
            # $psi.FileName = 'curl.exe'
            # $psi.Arguments = ($curlParams | ForEach-Object {
            # $tmp = $_
            # # Handle quotes in argument values
            # $tmp = $tmp -replace '"', '""'
            # if ($tmp -notlike '-*') {
            # # Handle special characters
            # $tmp = [regex]::new("([()[\]{}^;!'+,`~])").Replace($tmp, '^$1')
            # $tmp = $tmp -replace "&", "`&"
            # $tmp = $tmp -replace "[$]", "`$"
            # # Handle spaces in argument values
            # if ($tmp -match "[ &()[\]{}^=;!'+,`~]") {
            # if ($tmp -match "["":& `r`n]") {
            # $tmp = "`"$($tmp)`""
            # }
            # }
            # $tmp
            # }) -join ' '

            # Write-Host "curl $($psi.Arguments)"

            # # Output is read as a single string (then split into lines) to get the same output
            # # as the original curl command
            # $process = [System.Diagnostics.Process]::Start($psi)
            # $process.WaitForExit()

            # # Process output
            # $curlResponse1 = $process.StandardOutput.ReadToEnd()

            # # Detect end of line characters
            # $index10 = $curlResponse1.IndexOf([char]10)
            # $index13 = $curlResponse1.IndexOf([char]13)
            # # If index difference is 1, it's a line ending sequence
            # if ([Math]::Abs($index10 - $index13) -eq 1) {
            # $ln = "`r`n"
            # } else {
            # $ln = "`n"
            # }

            # $curlResponse = @($curlResponse1 -split $ln)
            # $curlExitCode = $process.ExitCode
        } else {
            # Output is read as array of strings
            $curlResponse = & curl @curlParams *>&1
            $curlExitCode = $LASTEXITCODE
        }

        # Ensure the input / output is encoding is reset
        [console]::InputEncoding = $inputEncoding
        [console]::OutputEncoding = $outputEncoding

        # are we in testing mode?
        if ($Pester) {
            # the output unusable because mangled by the Pester mock;
            # try to find the mock output
            $curlResponse = $curlResponse `
            | Where-Object { $_ -notmatch '^\[30m\[43mMock:' }
        }

        # Check for executing errors;
        # If anything other than 0, exit with error
        # 2 = curl: (2) unknown option
        # 6 = curl: (6) Could not resolve host: ...
        if (!$Pester -and $curlExitCode -ne 0) {
            # If $ErrorActionPreference is set to Stop, throw an exception
            # Otherwise, write the error to the error stream and return
            if ($ErrorActionPreference -eq 'Stop') {
                throw $curlResponse
            } else {
                # Write-Error cmdlet does not set the $? variable to $false;
                # so we have to use the $PSCmdlet.WriteError
                foreach ($line in $curlResponse) {
                    $PSCmdlet.WriteError($line)
                }
                return
            }
        }

        # Process the response
        # index the lines for easier processing
        $index = 0
        $response = @(
            $curlResponse `
            | ForEach-Object -Process {
                [PSCustomObject] @{
                    Index   = $index++
                    Content = $_
                }
            }
        )

        # Write out the response for debugging
        if ($DebugPreference -eq 'Continue' -and $VerbosePreference -eq 'Continue') {
            $response | Out-Host
        }

        # Find last line starting with "HTTP/"
        # There may be more than one if multiple requests were made -
        # for example due to redirection or authentication
        $lastHttpLine = $response `
        | Where-Object { $_.Content -like "HTTP/*" } `
        | Select-Object -Last 1

        # Find the end of the headers
        # (aka the next empty line)
        $endOfHeadersLine = $response `
        | Select-Object -Skip ($lastHttpLine.Index) `
        | Where-Object { -not ($_.Content) } `
        | Select-Object -First 1

        # Find last content-type header
        $lastContentTypeLine = $response `
        | Where-Object { $_.Index -lt $endOfHeadersLine.Index } `
        | Where-Object { $_.Content -like "Content-Type:*" } `
        | Select-Object -Last 1
        $contentType = ($lastContentTypeLine.Content -split '[:; ]')[2]

        # Find last content-length header
        $lastContentLengthLine = $response `
        | Where-Object { $_.Index -lt $endOfHeadersLine.Index } `
        | Where-Object { $_.Content -like "Content-Length:*" } `
        | Select-Object -Last 1
        $contentLength = ($lastContentLengthLine.Content -split ' ')[1]

        # Remaining response lines should be the payload;
        # join them together to a string
        $content = ($response `
            | Select-Object -Skip ($endOfHeadersLine.Index + 1) `
            | Select-Object -ExpandProperty Content
        ) -join "`n"

        # Report the content type and length
        Write-Verbose "Received $($contentLength) bytes of $($contentType)."

        # Parse the status code and reason phrase
        # HTTP/1.1 200 OK
        # HTTP/2 401 Unauthorized
        # HTTP/2 404 Not Found
        [int] $statusCode, [string] $reasonPhrase = ($lastHttpLine.Content -split ' ', 3)[1..2]
        if (!$reasonPhrase) {
            $reasonPhrase = [System.Net.HttpStatusCode] $statusCode
        }

        # Report the status code and reason phrase
        Write-Verbose "Status line: $($lastHttpLine.Content)"
        Write-Verbose "Status code: $($statusCode) $($reasonPhrase)"

        # Handle non-200 status codes
        if ($statusCode -eq '401') {
            $combinedErrorMessage = 'Access is denied due to invalid credentials.'
            $contentType = 'text/html'
        } elseif ($statusCode -ne '200') {

            # Try to find error message in response

            # 1/ From header
            # Find error in X-TFS-ServiceError header
            # X-TFS-ServiceError: TF400813%3A%20Resource%20not%20available%20for%20anonymous%20access...
            $lastXTfsServiceErrorLine = $response `
            | Where-Object { $_.Index -lt $endOfHeadersLine.Index } `
            | Where-Object { $_.Content -like "X-TFS-ServiceError:*" } `
            | Select-Object -Last 1

            if ($lastXTfsServiceErrorLine) {
                $errorMessage = [System.Web.HttpUtility]::UrlDecode(
                    $lastXTfsServiceErrorLine.Content.Split(':')[1].Trim()
                )
            }

            # 2/ From text/html payload
            # 45 <div id="content">
            # 46 <div class="content-container"><fieldset>
            # 47 <h2>401 - Unauthorized: Access is denied due to invalid credentials.</h2>
            # 48 <h3>You do not have permission to view this directory or page using the credentials that you supplied.</h3>
            # 49 </fieldset></div>
            if ($contentType -eq 'text/html') {

                $errorLine1 = $response `
                | Select-Object -Skip ($endOfHeadersLine.Index) `
                | Where-Object { $_.Content -like "*<h2>*" } `
                | Select-Object -First 1 -ExpandProperty Content

                $errorLine2 = $response `
                | Select-Object -Skip ($endOfHeadersLine.Index) `
                | Where-Object { $_.Content -like "*<h3>*" } `
                | Select-Object -First 1 -ExpandProperty Content

                if ($errorLine1 -match '<h2>(.*)</h2>') {
                    $errorMessage = $Matches[1]
                }

                if ($errorLine2 -match '<h3>(.*)</h3>') {
                    $errorDescription = $Matches[1]
                }
            }

            # 3/ From application/json payload
            if (-not $errorMessage -and ($contentType -eq 'application/json')) {
                $errorPayload = $content | ConvertFrom-JsonCustom
                # Get error details for Create and Update errors;
                # they will have attribute validation errors
                $errorDescription = $errorPayload.customProperties.RuleValidationErrors.errorMessage
                if ($errorDescription) {
                    $errorMessage = $errorDescription
                    $errorDescription = $null
                }
                if (!$errorMessage) {
                    $errorMessage = $errorPayload.value.message
                }
                if (!$errorMessage) {
                    $errorMessage = $errorPayload.Message
                }
            }

            if ($errorMessage -and $errorDescription) {
                $combinedErrorMessage = $errorMessage + ' ' + $errorDescription
            } else {
                $combinedErrorMessage = $errorMessage
            }
        }

        if ($statusCode -ne '200') {
            # Write-Verbose output
            Write-Verbose "Response: $($combinedErrorMessage)"
        }

        # Create a 'powershell-like' WebResponse object
        $webResponse = [PSCustomObject] @{
            StatusCode        = $statusCode
            StatusDescription = $reasonPhrase
            ContentType       = $contentType
            Content           = $content -join "`n"
            RawContent        = ($response `
                | Select-Object -Skip ($lastHttpLine.Index) `
                | Select-Object -ExpandProperty Content `
            ) -join "`n"
            Headers           = @{ }
            RawContentLength  = 0
        }

        # Finish the response
        # Set content length
        $webResponse.RawContentLength = $webResponse.RawContent.Length

        # Parse the headers
        $response `
        | Select-Object -Skip ($lastHttpLine.Index) `
        | Select-Object -First ($endOfHeadersLine.Index - $lastHttpLine.Index) `
        | Select-Object -ExpandProperty Content `
        | ForEach-Object {
            $headerName, $headerValue = $_.Split(':')
            if (!$headerName) {
                continue
            }
            if ($null -ne $headerValue) {
                $headerValue = $headerValue.Trim()
            }
            if (!$webResponse.Headers.ContainsKey($headerName)) {
                $webResponse.Headers[$headerName] = [string[]] $headerValue
            } else {
                $webResponse.Headers[$headerName] += $headerValue
            }
        }

        # Construct the Web Exception
        if ($combinedErrorMessage) {
            $webException = New-WebException `
                -Response $webResponse `
                -Message $combinedErrorMessage

            if ($ErrorActionPreference -eq 'Stop') {
                throw $webException
            } else {
                Write-Error $webException
            }
        }

        # Return the response
        return $webResponse
    }
}

Set-Alias -Name Invoke-WebRequestCurl -Value Invoke-CurlWebRequest