Providers/Authentik/Private/Get-AuthentikErrorDetail.ps1

function Get-AuthentikErrorDetail {
    <#
    .SYNOPSIS
        Reduces an Authentik API error response to one readable line

    .DESCRIPTION
        Authentik answers a failed request with JSON in one of two shapes. A generic failure -
        forbidden, not found, throttled - carries a single 'detail' string. A validation
        failure carries one array of messages per offending field, sometimes alongside
        'non_field_errors', and the field name is the part that says what to fix: 'slug: This
        field must be unique' is actionable where 'Bad Request' is not.

        The body is read from ErrorDetails first, which is where PowerShell 7 puts it, and from
        the response stream on Windows PowerShell, where it can be read exactly once. Anything
        that is not JSON is reduced to its first non-blank line so an HTML error page from a
        proxy in front of Authentik does not become a screenful.

    .PARAMETER ErrorRecord
        The error Invoke-WebRequest raised.

    .OUTPUTS
        System.String. One line describing the failure.

    .EXAMPLE
        PS> Get-AuthentikErrorDetail -ErrorRecord $_

        DESCRIPTION: Turns a caught request error into its message
        OUTPUT: 'slug: application with this slug already exists.'
        USE CASE: Inside Invoke-AuthentikRequest's catch block

    .NOTES
        Author: Jeffrey Stuhr
        Blog: https://www.techbyjeff.net
        LinkedIn: https://www.linkedin.com/in/jeffrey-stuhr-034214aa/
    #>


    [CmdletBinding()]
    [OutputType([string])]
    param(
        [Parameter(Mandatory = $true)]
        [System.Management.Automation.ErrorRecord]$ErrorRecord
    )

    $raw = $null
    if ($ErrorRecord.ErrorDetails -and -not [string]::IsNullOrWhiteSpace($ErrorRecord.ErrorDetails.Message)) {
        $raw = $ErrorRecord.ErrorDetails.Message
    }
    elseif ($ErrorRecord.Exception.PSObject.Properties['Response'] -and $ErrorRecord.Exception.Response) {
        $response = $ErrorRecord.Exception.Response
        # Probed by method rather than by edition: HttpWebResponse has GetResponseStream, the
        # HttpResponseMessage PowerShell 7 raises does not, and its body is already in
        # ErrorDetails above.
        if ($response.PSObject.Methods.Name -contains 'GetResponseStream') {
            try {
                $stream = $response.GetResponseStream()
                if ($stream) {
                    $reader = New-Object System.IO.StreamReader($stream, [System.Text.Encoding]::UTF8)
                    try { $raw = $reader.ReadToEnd() } finally { $reader.Dispose() }
                }
            }
            catch {
                Write-Verbose "Could not read the error response body: $($_.Exception.Message)"
            }
        }
    }

    if ([string]::IsNullOrWhiteSpace($raw)) {
        return $ErrorRecord.Exception.Message
    }

    $parsed = $null
    try { $parsed = $raw | ConvertFrom-Json -ErrorAction Stop } catch { $parsed = $null }

    if ($parsed -and $parsed.PSObject.Properties['detail']) {
        return [string]$parsed.detail
    }

    if ($parsed) {
        # Field errors: every property is a field whose value is a list of messages. Joined
        # as 'field: message' so the offending field is named.
        $parts = foreach ($property in $parsed.PSObject.Properties) {
            if ($property.Name -eq 'code') { continue }
            $messages = @($property.Value | ForEach-Object { [string]$_ } | Where-Object { $_ })
            if ($messages.Count -eq 0) { continue }
            if ($property.Name -eq 'non_field_errors') { $messages -join '; ' }
            else { '{0}: {1}' -f $property.Name, ($messages -join '; ') }
        }
        if ($parts) { return ($parts -join ' | ') }
    }

    $firstLine = @($raw -split "`r?`n" | Where-Object { -not [string]::IsNullOrWhiteSpace($_) }) | Select-Object -First 1
    if ($firstLine) { return $firstLine.Trim() }
    return $ErrorRecord.Exception.Message
}