Private/FSvc.Http.ps1

# HTTP plumbing for the Freshservice private API (/api/_/).

# Builds a query string (without leading ?) from a hashtable of params.
function Build-FSvcQueryString {
    param([hashtable]$Query)
    if (-not $Query -or $Query.Count -eq 0) { return "" }
    return ($Query.GetEnumerator() | ForEach-Object { "{0}={1}" -f $_.Key, [uri]::EscapeDataString([string]$_.Value) }) -join "&"
}

# Appends a query string to a path as ?key=value&... . Uses -f rather than
# "$Path?$qs" because `?` is legal in PowerShell variable names and would be
# swallowed into an undefined variable.
function Add-FSvcQuery {
    param([string]$Path, [string]$QueryString)
    if ($QueryString) { return "{0}?{1}" -f $Path, $QueryString }
    return $Path
}

# Parses API JSON, keeping ISO timestamp strings verbatim on PS 7.5+
# (-DateKind String) so the account offset is never converted away.
function ConvertFrom-FSvcJson {
    param([Parameter(Mandatory, ValueFromPipeline)][string]$Json)
    if ((Get-Command ConvertFrom-Json).Parameters.ContainsKey("DateKind")) {
        return $Json | ConvertFrom-Json -DateKind String
    }
    return $Json | ConvertFrom-Json
}

# Transport seam: tests (or an advanced caller) can replace the HTTP transport
# with a scriptblock that receives @{ Method; Path; Query; Body; Config } and
# returns the raw response body. $null means the real Invoke-WebRequest.
if (-not (Get-Variable -Name FSvcTransport -Scope Script -ErrorAction SilentlyContinue)) {
    $script:FSvcTransport = $null
}

# Cached WebRequestSession per base URL so successive calls reuse the same
# connection instead of paying a fresh TLS handshake each time (measured ~40%
# faster per request). The cache entry also remembers the cookie it was built
# with, so switching accounts cannot reuse a session the server tied to the
# old one.
if (-not (Get-Variable -Name FSvcWebSessions -Scope Script -ErrorAction SilentlyContinue)) {
    $script:FSvcWebSessions = @{}
}

function Get-FSvcWebSession {
    param([hashtable]$Config)
    $key = [string]$Config.BaseUrl
    $cookie = [string]$Config.ItildeskSession
    $entry = $script:FSvcWebSessions[$key]
    if ($null -eq $entry -or $entry.Cookie -ne $cookie) {
        $session = New-Object Microsoft.PowerShell.Commands.WebRequestSession
        # The auth cookie rides in the session jar rather than a Cookie header,
        # so PowerShell 5.1 and 7 take exactly the same path (5.1 rejects an
        # explicit Cookie header combined with a session cookie container).
        if ($cookie -and $Config.BaseUrl) {
            $session.Cookies.Add([System.Net.Cookie]::new('_itildesk_session', $cookie, '/', ([uri]$Config.BaseUrl).Host))
        }
        $entry = [pscustomobject]@{
            Cookie  = $cookie
            Session = $session
        }
        $script:FSvcWebSessions[$key] = $entry
    }
    return $entry.Session
}

# Single entry point for API calls. Resolves config, asserts the connection,
# and either calls the injected transport or performs the real HTTP request.
function Invoke-FSvcRequest {
    param(
        [string]$Method,
        [string]$Path,
        [hashtable]$Query,
        [hashtable]$Body,
        [hashtable]$Config
    )
    if (-not $Config) { $Config = Get-FSvcEffectiveConfig }
    Assert-FSvcConnection -Config $Config

    # Cookie values must be ASCII; a pasted/truncated value otherwise fails deep
    # inside the HTTP stack with "Request headers must contain only ASCII
    # characters".
    if ($Config.ItildeskSession -match '[^\x20-\x7E]') {
        $bad = [regex]::Match($Config.ItildeskSession, '[^\x20-\x7E]')
        throw ("ItildeskSession contains a non-ASCII character (U+{0:X4}) at position {1}; cookie values are ASCII. Re-copy the _itildesk_session value." -f [int][char]$bad.Value, $bad.Index)
    }

    $transport = Get-Variable -Name FSvcTransport -Scope Script -ErrorAction SilentlyContinue
    if ($transport -and $null -ne $transport.Value) {
        return & $transport.Value @{ Method = $Method; Path = $Path; Query = $Query; Body = $Body; Config = $Config }
    }

    $url = Add-FSvcQuery -Path $Path -QueryString (Build-FSvcQueryString -Query $Query)
    $headers = @{
        "Accept" = "application/json"
    }
    $params = @{
        Uri             = ("{0}/api/_/{1}" -f $Config.BaseUrl.TrimEnd('/'), $url)
        Headers         = $headers
        Method          = $Method
        UseBasicParsing = $true
        WebSession      = (Get-FSvcWebSession -Config $Config)
        ErrorAction     = 'Stop'
    }
    if ($Method -ne 'GET') {
        if (-not $Config.CsrfToken) {
            throw "No CSRF token configured. Run Set-FSvcConfig -CsrfToken <value>, or set FSVC_CSRF_TOKEN."
        }
        $headers['Content-Type'] = 'application/json; charset=utf-8'
        $headers['X-CSRF-Token'] = $Config.CsrfToken
        $params['Body'] = ($Body | ConvertTo-Json -Compress)
    }
    $resp = Invoke-WebRequest @params
    return $resp.Content
}

function Invoke-FSvcGet {
    param([string]$Path, [hashtable]$Query, [hashtable]$Config)
    return Invoke-FSvcRequest -Method GET -Path $Path -Query $Query -Config $Config
}

function Invoke-FSvcPut {
    param([string]$Path, [hashtable]$Body, [hashtable]$Config)
    return Invoke-FSvcRequest -Method PUT -Path $Path -Body $Body -Config $Config
}

# Walks every page of a list endpoint and returns the items from $ArrayKey.
# $BaseQuery is copied per page (page is added), and $Fetch defaults to
# Invoke-FSvcGet so tests can stub the whole traversal.
function Invoke-FSvcPagedQuery {
    param(
        [Parameter(Mandatory)][string]$Path,
        [hashtable]$BaseQuery,
        [Parameter(Mandatory)][string]$ArrayKey,
        [int]$StartPage = 1,
        [int]$MaxPages = 1000,
        [hashtable]$Config,
        [scriptblock]$Fetch
    )
    if (-not $BaseQuery) { $BaseQuery = @{} }
    if (-not $Fetch) {
        $Fetch = { param($P, $Q, $C) Invoke-FSvcGet -Path $P -Query $Q -Config $C }
    }

    $items = @()
    $page = $StartPage
    $fetched = 0
    do {
        $query = @{}
        foreach ($k in $BaseQuery.Keys) { $query[$k] = $BaseQuery[$k] }
        $query['page'] = $page
        $data = (& $Fetch $Path $query $Config) | ConvertFrom-FSvcJson
        $items += @($data.$ArrayKey)
        $hasNext = $data.meta.has_next
        $page++
        $fetched++
    } while ($hasNext -and $fetched -lt $MaxPages)
    return $items
}