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, where Invoke-TestWebRequest puts it on both
        editions after reading the failed response 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
    )

    # The body is in ErrorDetails on both editions: Invoke-TestWebRequest reads a failed response
    # once, with -SkipHttpErrorCheck on PowerShell 7 and from the response stream on Windows
    # PowerShell, and puts it there. Nothing here reads a stream.
    $raw = $null
    if ($ErrorRecord.ErrorDetails -and -not [string]::IsNullOrWhiteSpace($ErrorRecord.ErrorDetails.Message)) {
        $raw = $ErrorRecord.ErrorDetails.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
}