Private/Invoke-UKGRequest.ps1

function Invoke-UKGRequest {
    <#
    .SYNOPSIS
        Central function for making API calls to the UKG HR Service Delivery API.

    .DESCRIPTION
        Handles all HTTP requests to the API, including authentication,
        token refresh, pagination, and error handling.

    .PARAMETER Endpoint
        The API endpoint path (without base URL).

    .PARAMETER Method
        The HTTP method to use (GET, POST, PUT, PATCH, DELETE, HEAD).

    .PARAMETER Body
        The request body as a hashtable (will be converted to JSON).

    .PARAMETER QueryParameters
        Query string parameters as a hashtable.

    .PARAMETER FilePath
        Path to a file for multipart/form-data uploads.

    .PARAMETER FileName
        Name to use for the uploaded file (defaults to file basename).

    .PARAMETER ContentType
        Content type for file uploads.

    .PARAMETER ReturnHeaders
        If specified, returns both data and headers in a wrapper object.

    .PARAMETER RawResponse
        If specified, returns the raw response without parsing.
    #>

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

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

        [Parameter()]
        [hashtable]$Body,

        [Parameter()]
        [hashtable]$QueryParameters,

        [Parameter()]
        [string]$FilePath,

        [Parameter()]
        [string]$FileName,

        [Parameter()]
        [string]$ContentType,

        [Parameter()]
        [switch]$ReturnHeaders,

        [Parameter()]
        [switch]$RawResponse
    )

    # Verify we have an active session
    if ($null -eq $Script:UKGSession) {
        $errorRecord = ConvertTo-UKGError -Response @{ code = 'not_connected'; message = 'Not connected to UKG API. Use Connect-UKG first.' } -StatusCode 0
        $PSCmdlet.ThrowTerminatingError($errorRecord)
    }

    # Check if token needs refresh (refresh 60 seconds before expiry)
    $refreshThreshold = (Get-Date).AddSeconds(60)
    if ($Script:UKGSession.TokenExpiry -lt $refreshThreshold) {
        Write-Verbose "Token expired or expiring soon, refreshing..."
        try {
            Update-UKGToken
        }
        catch {
            $errorRecord = ConvertTo-UKGError -Response @{ code = 'token_refresh_failed'; message = "Failed to refresh token: $_" } -StatusCode 0 -Exception $_.Exception
            $PSCmdlet.ThrowTerminatingError($errorRecord)
        }
    }

    # Build the full URL
    $baseUrl = $Script:UKGSession.BaseUrl
    $url = "$baseUrl$Endpoint"

    # Add query parameters
    if ($null -ne $QueryParameters -and $QueryParameters.Count -gt 0) {
        $queryParts = @()
        foreach ($key in $QueryParameters.Keys) {
            $value = $QueryParameters[$key]
            if ($null -ne $value) {
                # Handle arrays
                if ($value -is [array]) {
                    foreach ($v in $value) {
                        $queryParts += "$([System.Uri]::EscapeDataString($key))=$([System.Uri]::EscapeDataString($v.ToString()))"
                    }
                }
                else {
                    $queryParts += "$([System.Uri]::EscapeDataString($key))=$([System.Uri]::EscapeDataString($value.ToString()))"
                }
            }
        }
        if ($queryParts.Count -gt 0) {
            $url += "?" + ($queryParts -join "&")
        }
    }

    Write-Verbose "Making $Method request to: $url"

    # Build headers
    $headers = @{
        'Authorization' = "Bearer $($Script:UKGSession.AccessToken)"
        'Accept'        = 'application/json'
    }

    # Build request parameters
    $requestParams = @{
        Uri             = $url
        Method          = $Method
        Headers         = $headers
        UseBasicParsing = $true
        ErrorAction     = 'Stop'
    }

    # Handle file uploads (multipart/form-data)
    if ($FilePath) {
        if (-not (Test-Path -Path $FilePath -PathType Leaf)) {
            $errorRecord = ConvertTo-UKGError -Response @{ code = 'file_not_found'; message = "File not found: $FilePath" } -StatusCode 0
            $PSCmdlet.ThrowTerminatingError($errorRecord)
        }

        $fileBytes = [System.IO.File]::ReadAllBytes($FilePath)
        $actualFileName = if ($FileName) { $FileName } else { [System.IO.Path]::GetFileName($FilePath) }

        # Determine content type
        $fileContentType = if ($ContentType) {
            $ContentType
        }
        else {
            $extension = [System.IO.Path]::GetExtension($FilePath).ToLower()
            switch ($extension) {
                '.pdf' { 'application/pdf' }
                '.doc' { 'application/msword' }
                '.docx' { 'application/vnd.openxmlformats-officedocument.wordprocessingml.document' }
                '.xls' { 'application/vnd.ms-excel' }
                '.xlsx' { 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' }
                '.png' { 'image/png' }
                '.jpg' { 'image/jpeg' }
                '.jpeg' { 'image/jpeg' }
                '.gif' { 'image/gif' }
                '.txt' { 'text/plain' }
                '.csv' { 'text/csv' }
                default { 'application/octet-stream' }
            }
        }

        # Build multipart form data
        $boundary = [System.Guid]::NewGuid().ToString()
        $headers['Content-Type'] = "multipart/form-data; boundary=$boundary"

        $bodyLines = @()
        $bodyLines += "--$boundary"
        $bodyLines += "Content-Disposition: form-data; name=`"file`"; filename=`"$actualFileName`""
        $bodyLines += "Content-Type: $fileContentType"
        $bodyLines += ""

        $headerBytes = [System.Text.Encoding]::UTF8.GetBytes(($bodyLines -join "`r`n") + "`r`n")
        $footerBytes = [System.Text.Encoding]::UTF8.GetBytes("`r`n--$boundary--`r`n")

        # Add additional form fields from Body if present
        $additionalFields = @()
        if ($null -ne $Body) {
            foreach ($key in $Body.Keys) {
                $additionalFields += "--$boundary"
                $additionalFields += "Content-Disposition: form-data; name=`"$key`""
                $additionalFields += ""
                $additionalFields += $Body[$key].ToString()
            }
            $additionalFields += ""
        }

        $additionalBytes = if ($additionalFields.Count -gt 0) {
            [System.Text.Encoding]::UTF8.GetBytes(($additionalFields -join "`r`n") + "`r`n")
        }
        else {
            @()
        }

        # Combine all parts
        $bodyStream = New-Object System.IO.MemoryStream
        $bodyStream.Write($headerBytes, 0, $headerBytes.Length)
        $bodyStream.Write($fileBytes, 0, $fileBytes.Length)
        if ($additionalBytes.Length -gt 0) {
            $bodyStream.Write($additionalBytes, 0, $additionalBytes.Length)
        }
        $bodyStream.Write($footerBytes, 0, $footerBytes.Length)

        $requestParams.Body = $bodyStream.ToArray()
        $requestParams.Headers = $headers
    }
    elseif ($null -ne $Body -and $Method -ne 'GET' -and $Method -ne 'HEAD') {
        # Regular JSON body
        $headers['Content-Type'] = 'application/json; charset=utf-8'
        $requestParams.Body = ($Body | ConvertTo-Json -Depth 10 -Compress)
        $requestParams.Headers = $headers
        Write-Verbose "Request body: $($requestParams.Body)"
    }

    try {
        # Make the request
        if ($Method -eq 'HEAD') {
            # HEAD requests - we need response headers
            $response = Invoke-WebRequest @requestParams
            $responseHeaders = @{}
            foreach ($key in $response.Headers.Keys) {
                $responseHeaders[$key] = $response.Headers[$key]
            }

            if ($ReturnHeaders) {
                return [PSCustomObject]@{
                    Data    = $null
                    Headers = $responseHeaders
                }
            }
            return $responseHeaders
        }
        else {
            # Use Invoke-WebRequest to get access to headers
            $response = Invoke-WebRequest @requestParams

            # Parse headers
            $responseHeaders = @{}
            foreach ($key in $response.Headers.Keys) {
                $responseHeaders[$key] = $response.Headers[$key]
            }

            # Parse response content
            $data = $null
            if ($response.Content) {
                if ($RawResponse) {
                    $data = $response.Content
                }
                else {
                    try {
                        $data = $response.Content | ConvertFrom-Json
                    }
                    catch {
                        # Response is not JSON
                        $data = $response.Content
                    }
                }
            }

            if ($ReturnHeaders) {
                return [PSCustomObject]@{
                    Data    = $data
                    Headers = $responseHeaders
                }
            }

            return $data
        }
    }
    catch {
        $statusCode = 0
        $errorResponse = $null

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

            try {
                $reader = New-Object System.IO.StreamReader($_.Exception.Response.GetResponseStream())
                $errorResponse = $reader.ReadToEnd()
                $reader.Close()

                try {
                    $errorResponse = $errorResponse | ConvertFrom-Json
                }
                catch {
                    # Keep as string
                }
            }
            catch {
                $errorResponse = $_.Exception.Message
            }
        }
        else {
            $errorResponse = $_.Exception.Message
        }

        $errorRecord = ConvertTo-UKGError -Response $errorResponse -StatusCode $statusCode -Exception $_.Exception
        $PSCmdlet.ThrowTerminatingError($errorRecord)
    }
}

function Update-UKGToken {
    <#
    .SYNOPSIS
        Refreshes the OAuth access token.

    .DESCRIPTION
        Uses stored credentials to obtain a new access token when the current one expires.
    #>

    [CmdletBinding()]
    param()

    if ($null -eq $Script:UKGSession) {
        throw "No active session. Use Connect-UKG first."
    }

    $tokenUrl = $Script:UKGSession.TokenUrl

    # Decrypt credentials
    $appId = $Script:UKGSession.ApplicationId
    $appSecretSecure = $Script:UKGSession.ApplicationSecret
    $clientId = $Script:UKGSession.ClientId

    # Convert SecureString to plain text
    if ($Script:PSVersionMajor -ge 7) {
        $appSecret = ConvertFrom-SecureString -SecureString $appSecretSecure -AsPlainText
    }
    else {
        $bstr = [System.Runtime.InteropServices.Marshal]::SecureStringToBSTR($appSecretSecure)
        try {
            $appSecret = [System.Runtime.InteropServices.Marshal]::PtrToStringAuto($bstr)
        }
        finally {
            [System.Runtime.InteropServices.Marshal]::ZeroFreeBSTR($bstr)
        }
    }

    $body = @{
        grant_type    = 'client_credentials'
        client_id     = $clientId
        client_secret = "${appId}:${appSecret}"
    }

    $requestParams = @{
        Uri             = $tokenUrl
        Method          = 'POST'
        Body            = $body
        ContentType     = 'application/x-www-form-urlencoded'
        UseBasicParsing = $true
        ErrorAction     = 'Stop'
    }

    try {
        $response = Invoke-RestMethod @requestParams

        # Update session with new token
        $Script:UKGSession.AccessToken = $response.access_token
        $Script:UKGSession.TokenExpiry = (Get-Date).AddSeconds($response.expires_in)

        Write-Verbose "Token refreshed successfully. New expiry: $($Script:UKGSession.TokenExpiry)"
    }
    catch {
        throw "Failed to refresh token: $_"
    }
}