Private/Connect-ServiceNow.ps1

function Connect-ServiceNow {
    <#
    .SYNOPSIS
        Executes authenticated REST API requests against a ServiceNow instance.
    .DESCRIPTION
        Handles ServiceNow REST API authentication (basic auth or API key/token),
        URL construction, pagination, and rate limit handling.
        Returns parsed JSON response objects.
    #>

    [CmdletBinding()]
    param(
        [Parameter(Mandatory)]
        [string]$Instance,

        [Parameter(Mandatory)]
        [ValidateSet('GET', 'POST', 'PUT', 'PATCH', 'DELETE')]
        [string]$Method,

        [Parameter(Mandatory)]
        [string]$Endpoint,

        [Parameter()]
        [PSCredential]$Credential,

        [Parameter()]
        [string]$ApiKey,

        [Parameter()]
        [hashtable]$Body,

        [Parameter()]
        [hashtable]$QueryParameters,

        [Parameter()]
        [switch]$Paginate,

        [Parameter()]
        [int]$PageSize = 1000,

        [Parameter()]
        [int]$MaxRecords = 50000,

        [Parameter()]
        [int]$MaxRetries = 3
    )

    # Build base URL
    $baseUrl = if ($Instance -match '^https?://') {
        $Instance.TrimEnd('/')
    }
    else {
        "https://$Instance"
    }

    # Build authentication headers
    $headers = @{
        'Accept'       = 'application/json'
        'Content-Type' = 'application/json'
    }

    # Resolve API key from environment if not provided
    if (-not $ApiKey -and -not $Credential) {
        if ($env:SNOW_API_KEY) {
            $ApiKey = $env:SNOW_API_KEY
        }
    }

    if ($Credential) {
        $username = $Credential.UserName
        $password = $Credential.GetNetworkCredential().Password
        $pair = "${username}:${password}"
        $bytes = [System.Text.Encoding]::ASCII.GetBytes($pair)
        $base64 = [System.Convert]::ToBase64String($bytes)
        $headers['Authorization'] = "Basic $base64"
    }
    elseif ($ApiKey) {
        # ServiceNow supports Bearer token or custom header depending on config
        $headers['Authorization'] = "Bearer $ApiKey"
    }
    else {
        throw 'No authentication provided for ServiceNow. Supply -Credential, -ApiKey, or set $env:SNOW_API_KEY.'
    }

    # Build the full URL
    $fullEndpoint = $Endpoint.TrimStart('/')
    $url = "$baseUrl/$fullEndpoint"

    # Add query parameters
    if ($QueryParameters -and $QueryParameters.Count -gt 0) {
        $queryParts = @()
        foreach ($key in $QueryParameters.Keys) {
            $encodedValue = [System.Uri]::EscapeDataString($QueryParameters[$key])
            $queryParts += "${key}=${encodedValue}"
        }
        $queryString = $queryParts -join '&'
        $url = "${url}?${queryString}"
    }

    # Non-paginated request
    if (-not $Paginate) {
        return Invoke-SNOWRequestInternal -Url $url -Method $Method -Headers $headers -Body $Body -MaxRetries $MaxRetries
    }

    # Paginated request (GET only)
    if ($Method -ne 'GET') {
        Write-Warning 'Pagination is only supported for GET requests. Executing single request.'
        return Invoke-SNOWRequestInternal -Url $url -Method $Method -Headers $headers -Body $Body -MaxRetries $MaxRetries
    }

    $allResults = @()
    $offset = 0
    $hasMore = $true

    while ($hasMore -and $allResults.Count -lt $MaxRecords) {
        # Append pagination parameters
        $separator = if ($url -match '\?') { '&' } else { '?' }
        $pageUrl = "${url}${separator}sysparm_limit=${PageSize}&sysparm_offset=${offset}"

        Write-Verbose "ServiceNow paginated request: offset=$offset, limit=$PageSize"

        $response = Invoke-SNOWRequestInternal -Url $pageUrl -Method 'GET' -Headers $headers -MaxRetries $MaxRetries

        if ($null -eq $response) {
            $hasMore = $false
            continue
        }

        # ServiceNow wraps results in a 'result' property
        $records = if ($response.PSObject.Properties['result']) {
            $response.result
        }
        else {
            $response
        }

        if ($null -eq $records -or @($records).Count -eq 0) {
            $hasMore = $false
        }
        else {
            $batch = @($records)
            $allResults += $batch
            $offset += $batch.Count

            if ($batch.Count -lt $PageSize) {
                $hasMore = $false
            }

            Write-Verbose "Retrieved $($batch.Count) records (total: $($allResults.Count))"
        }
    }

    if ($allResults.Count -ge $MaxRecords) {
        Write-Warning "Reached maximum record limit ($MaxRecords). Results may be incomplete."
    }

    return $allResults
}


function Invoke-SNOWRequestInternal {
    <#
    .SYNOPSIS
        Internal helper for executing a single ServiceNow REST API request with retry logic.
    #>

    [CmdletBinding()]
    param(
        [string]$Url,
        [string]$Method,
        [hashtable]$Headers,
        [hashtable]$Body,
        [int]$MaxRetries = 3
    )

    $attempt = 0
    $lastError = $null

    while ($attempt -lt $MaxRetries) {
        $attempt++
        try {
            $splat = @{
                Uri         = $Url
                Method      = $Method
                Headers     = $Headers
                ErrorAction = 'Stop'
            }

            if ($Body -and $Method -in @('POST', 'PUT', 'PATCH')) {
                $jsonBody = $Body | ConvertTo-Json -Depth 10 -Compress
                if ($PSVersionTable.PSVersion.Major -le 5) {
                    $splat['Body'] = [System.Text.Encoding]::UTF8.GetBytes($jsonBody)
                }
                else {
                    $splat['Body'] = $jsonBody
                }
            }

            Write-Verbose "ServiceNow $Method $Url (attempt $attempt)"
            $response = Invoke-RestMethod @splat
            return $response
        }
        catch {
            $lastError = $_
            $statusCode = $null

            if ($_.Exception.Response) {
                $statusCode = [int]$_.Exception.Response.StatusCode
            }

            switch ($statusCode) {
                401 {
                    throw "ServiceNow authentication failed (401). Check your credentials or API key. URL: $Url"
                }
                403 {
                    throw "ServiceNow access denied (403). Insufficient permissions. URL: $Url"
                }
                404 {
                    Write-Warning "ServiceNow resource not found (404): $Url"
                    return $null
                }
                429 {
                    $retryAfter = 5
                    if ($_.Exception.Response.Headers) {
                        try {
                            $retryHeader = $_.Exception.Response.Headers.GetValues('Retry-After')
                            if ($retryHeader) {
                                $retryAfter = [int]$retryHeader[0]
                            }
                        }
                        catch {
                            # Header not present, use default
                        }
                    }
                    Write-Warning "ServiceNow rate limited (429). Waiting $retryAfter seconds (attempt $attempt/$MaxRetries)."
                    Start-Sleep -Seconds $retryAfter
                }
                default {
                    if ($attempt -lt $MaxRetries) {
                        $backoff = [math]::Pow(2, $attempt)
                        Write-Warning "ServiceNow request failed (attempt $attempt/$MaxRetries). Retrying in $backoff seconds. Error: $($_.Exception.Message)"
                        Start-Sleep -Seconds $backoff
                    }
                }
            }
        }
    }

    throw "ServiceNow request failed after $MaxRetries attempts. URL: $Url. Last error: $($lastError.Exception.Message)"
}