Public/Invoke-ActiveNetAPI.ps1

<#
.SYNOPSIS
    Invokes the ACTIVENet REST API for a specified resource with retry, pagination, and credential hardening.
.DESCRIPTION
    Constructs and executes authenticated REST calls against the ACTIVENet API Gateway.
     
    Enterprise modernization features (v2.0.0):
    - Accepts credentials as [System.Security.SecureString] or [string] with unmanaged BSTR zero-freeing.
    - Automatic dynamic signature calculation when -Secret is provided (no manual signature step required).
    - Robust transient HTTP retry with exponential backoff on HTTP 429 (Rate Limit / Over QPS) and 5xx errors.
    - Automated result pagination across all pages using the -AllPages switch.
    - Dual gateway routing: defaults to US Gateway (api.amp.active.com), supports Canada via -Canada (anet-systemapi-ca-sec).
    - Full backward compatibility with v1 parameter names and aliases.
.PARAMETER OrgId
    The ACTIVENet organization ID (e.g. 'your-org-id' or trainer site ID). This parameter is required.
.PARAMETER ResourceName
    The name or relative path of the API resource (e.g., 'activities', 'facilityschedules', 'facilities').
.PARAMETER RequestParameters
    A hashtable of key-value query parameters to include in the request.
.PARAMETER ApiKey
    Your ACTIVENet API key as [System.Security.SecureString] or [string].
.PARAMETER Signature
    Pre-computed SHA-256 dynamic digital signature string. If omitted, -Secret must be provided.
.PARAMETER Secret
    Your ACTIVENet shared secret key as [System.Security.SecureString] or [string]. When provided,
    Invoke-ActiveNetAPI dynamically generates and updates signatures across all paginated requests.
.PARAMETER Canada
    Switch indicating whether to route to the Canadian ACTIVENet gateway service (anet-systemapi-ca-sec).
.PARAMETER PageNumber
    The initial or specific page number to retrieve. Defaults to '1'.
.PARAMETER AllPages
    Switch indicating whether to automatically paginate through all available result pages and aggregate records.
.PARAMETER PageSize
    Number of records to request per page (1 to 500). Defaults to 250.
.PARAMETER MaxRetries
    Maximum number of retry attempts on transient network or HTTP 429/5xx errors. Defaults to 3.
.PARAMETER BaseBackoffMs
    Base backoff delay in milliseconds for exponential retry. Defaults to 1500.
.PARAMETER BaseUri
    Base URI for the ACTIVENet Gateway. Defaults to 'https://api.amp.active.com'.
.OUTPUTS
    Returns the parsed API response object, or an aggregated response with combined records when -AllPages is specified.
.EXAMPLE
    PS> # Dynamic signature generation with SecureString credentials and full pagination:
    PS> $res = Invoke-ActiveNetAPI -OrgId 'myorg' -ResourceName 'activities' -ApiKey $secKey -Secret $secSecret -AllPages
.EXAMPLE
    PS> # Backward-compatible v1 calling syntax:
    PS> $sig = New-DynamicDigitalSignature -ApiKey $key -Secret $secret
    PS> $res = Invoke-ActiveNetAPI -orgId 'myorg' -resourceName 'activities' -apiKey $key -signature $sig
#>

function Invoke-ActiveNetAPI {
    [CmdletBinding()]
    [OutputType([PSCustomObject])]
    param (
        [Parameter(Mandatory = $true, Position = 0)]
        [Alias('organizationId')]
        [string]$OrgId,

        [Parameter(Mandatory = $true, Position = 1)]
        [Alias('endpoint', 'resource')]
        [string]$ResourceName,

        [Parameter(Mandatory = $false, Position = 2)]
        [Alias('parameters', 'queryParameters')]
        [hashtable]$RequestParameters = @{},

        [Parameter(Mandatory = $true, Position = 3)]
        [Alias('API_Key', 'Key')]
        [object]$ApiKey,

        [Parameter(Mandatory = $false, Position = 4)]
        [Alias('sig')]
        [string]$Signature,

        [Parameter(Mandatory = $false)]
        [Alias('sharedSecret', 'sharedSecretKey', 'Shared_Secret')]
        [object]$Secret,

        [Parameter(Mandatory = $false)]
        [Alias('IsCanada')]
        [switch]$Canada,

        [Parameter(Mandatory = $false)]
        [Alias('page')]
        [string]$PageNumber = '1',

        [Parameter(Mandatory = $false)]
        [Alias('all')]
        [switch]$AllPages,

        [Parameter(Mandatory = $false)]
        [ValidateRange(1, 500)]
        [int]$PageSize = 250,

        [Parameter(Mandatory = $false)]
        [ValidateRange(0, 10)]
        [int]$MaxRetries = 3,

        [Parameter(Mandatory = $false)]
        [ValidateRange(0, 60000)]
        [int]$BaseBackoffMs = 1500,

        [Parameter(Mandatory = $false)]
        [Alias('baseUrl')]
        [string]$BaseUri = 'https://api.amp.active.com'
    )

    if ([string]::IsNullOrWhiteSpace($Signature) -and $null -eq $Secret) {
        throw "Either -Signature or -Secret must be provided for authentication."
    }

    # Internal helper for string extraction from SecureString or string
    $extractString = {
        param([object]$val, [string]$paramName)
        if ($null -eq $val) {
            throw "$paramName cannot be null."
        }
        if ($val -is [System.Security.SecureString]) {
            $bstr = [System.Runtime.InteropServices.Marshal]::SecureStringToBSTR($val)
            try {
                return [System.Runtime.InteropServices.Marshal]::PtrToStringBSTR($bstr)
            } finally {
                if ($bstr -ne [System.IntPtr]::Zero) {
                    [System.Runtime.InteropServices.Marshal]::ZeroFreeBSTR($bstr)
                }
            }
        } elseif ($val -is [string]) {
            return [string]$val
        } else {
            throw "$paramName must be a [System.Security.SecureString] or [string], received [$($val.GetType().FullName)]."
        }
    }

    $plainApiKey = & $extractString $ApiKey 'ApiKey'

    # Determine service gateway and base URI
    $trimmedBase = $BaseUri.TrimEnd('/')
    $trimmedResource = $ResourceName.TrimStart('/')

    $serviceName = if ($Canada.IsPresent) {
        'anet-systemapi-ca-sec'
    } else {
        'anet-systemapi-sec'
    }

    # Format query parameters
    $queryPairs = [System.Collections.Generic.List[string]]::new()
    if ($null -ne $RequestParameters -and $RequestParameters.Count -gt 0) {
        foreach ($key in ($RequestParameters.Keys | Sort-Object)) {
            $val = [string]$RequestParameters[$key]
            $encodedKey = [System.Uri]::EscapeDataString([string]$key)
            $encodedVal = [System.Uri]::EscapeDataString($val)
            $queryPairs.Add("$encodedKey=$encodedVal")
        }
    }
    $queryPrefix = if ($queryPairs.Count -gt 0) { ($queryPairs -join '&') + '&' } else { '' }

    $allRecords = [System.Collections.Generic.List[object]]::new()
    $currentPage = [int]$PageNumber
    if ($currentPage -lt 1) { $currentPage = 1 }
    $lastResponse = $null
    $hasMorePages = $true

    while ($hasMorePages) {
        # Prepare page_info header
        $pageInfoJson = ([ordered]@{
            page_number            = $currentPage
            total_records_per_page = $PageSize
        } | ConvertTo-Json -Compress)

        $headers = @{
            'Accept'       = 'application/json'
            'content-type' = 'application/json'
            'page_info'    = $pageInfoJson
        }

        # Calculate dynamic signature if Secret was provided, else use supplied Signature
        $currentSig = if ($null -ne $Secret) {
            if (Get-Command -Name 'New-DynamicDigitalSignature' -ErrorAction SilentlyContinue) {
                New-DynamicDigitalSignature -ApiKey $ApiKey -Secret $Secret
            } else {
                # Fallback implementation if function not in scope
                $plainSecret = & $extractString $Secret 'Secret'
                $nowSeconds = [DateTimeOffset]::UtcNow.ToUnixTimeSeconds()
                $payload = "$plainApiKey$plainSecret$nowSeconds"
                $bytes = [System.Text.Encoding]::UTF8.GetBytes($payload)
                $sha = [System.Security.Cryptography.SHA256]::Create()
                try {
                    ([System.BitConverter]::ToString($sha.ComputeHash($bytes)) -replace '-', '').ToLowerInvariant()
                } finally {
                    $sha.Dispose()
                }
            }
        } else {
            $Signature
        }

        $uri = "$trimmedBase/$serviceName/$OrgId/api/v1/$trimmedResource`?$queryPrefix" + "api_key=$plainApiKey&sig=$currentSig"

        # HTTP request execution with exponential backoff retry on 429 and 5xx
        $attempt = 0
        $requestSucceeded = $false
        $pageResponse = $null

        while ($attempt -le $MaxRetries) {
            $attempt++
            try {
                $pageResponse = Invoke-RestMethod -Uri $uri -Method Get -Headers $headers -ErrorAction Stop
                $requestSucceeded = $true
                break
            } catch {
                $statusCode = 0
                if ($null -ne $_.Exception -and $null -ne $_.Exception.PSObject.Properties['Response'] -and $null -ne $_.Exception.Response) {
                    $statusCode = [int]$_.Exception.Response.StatusCode
                }

                $errText = $_.ToString()
                if ($null -ne $_.Exception -and $null -ne $_.Exception.Message) { $errText += " " + $_.Exception.Message }
                $isQps = ($statusCode -eq 429) -or ($statusCode -eq 403 -and $errText -match '(?i)over\s*qps')
                $isRetryable = ($statusCode -eq 0) -or ($statusCode -ge 500 -and $statusCode -lt 600) -or $isQps

                if ($isRetryable -and $attempt -le $MaxRetries) {
                    $delayMs = [int]($BaseBackoffMs * [Math]::Pow(2, ($attempt - 1)))
                    Write-Verbose "Transient error (Status: $statusCode). Retrying attempt $attempt of $MaxRetries in ${delayMs}ms..."
                    if ($delayMs -gt 0) {
                        Start-Sleep -Milliseconds $delayMs
                    }
                    continue
                }

                throw "Failed to invoke ACTIVENet API on page $currentPage (Status: $statusCode): $($_.Exception.Message)"
            }
        }

        if (-not $requestSucceeded -or $null -eq $pageResponse) {
            throw "Failed to retrieve response from ACTIVENet API for resource '$ResourceName'."
        }

        $lastResponse = $pageResponse

        # If not paginating, return immediately
        if (-not $AllPages.IsPresent) {
            return $pageResponse
        }

        # Handle ACTIVENet specific response headers
        if ($null -ne $pageResponse.PSObject.Properties['headers'] -and $null -ne $pageResponse.headers) {
            $respCode = [string]$pageResponse.headers.response_code
            if ($respCode -eq '0001') {
                # Documented empty result set
                break
            }
            if ($respCode -ne '0000') {
                $respMsg = if ($null -ne $pageResponse.headers.PSObject.Properties['response_message']) {
                    [string]$pageResponse.headers.response_message
                } else {
                    "API code $respCode"
                }
                throw "ACTIVENet returned error code '$respCode': $respMsg"
            }
        }

        # Collect records from body
        if ($null -ne $pageResponse.PSObject.Properties['body'] -and $null -ne $pageResponse.body) {
            $bodyItems = @($pageResponse.body)
            foreach ($item in $bodyItems) {
                if ($null -ne $item) {
                    $allRecords.Add($item)
                }
            }

            # Check total_page in page_info if provided
            $totalPages = $null
            if ($null -ne $pageResponse.headers -and
                $null -ne $pageResponse.headers.PSObject.Properties['page_info'] -and
                $null -ne $pageResponse.headers.page_info -and
                $null -ne $pageResponse.headers.page_info.PSObject.Properties['total_page']) {
                $totalPages = [int]$pageResponse.headers.page_info.total_page
            }

            if ($null -ne $totalPages -and $totalPages -gt 0) {
                if ($currentPage -ge $totalPages) {
                    $hasMorePages = $false
                } else {
                    $currentPage++
                }
            } else {
                if ($bodyItems.Count -lt $PageSize) {
                    $hasMorePages = $false
                } else {
                    $currentPage++
                }
            }
        } else {
            $hasMorePages = $false
        }
    }

    # Return aggregated PSCustomObject maintaining response contract
    return [PSCustomObject]@{
        headers = if ($null -ne $lastResponse -and $null -ne $lastResponse.PSObject.Properties['headers']) {
            $lastResponse.headers
        } else {
            [PSCustomObject]@{
                response_code = '0000'
                response_message = 'Success'
            }
        }
        body    = $allRecords.ToArray()
    }
}