Public/Helper/Invoke-KrWebRequest.ps1

<#
    .SYNOPSIS
        Sends an HTTP request to a Kestrun server over various transport mechanisms (TCP, Named Pipe, Unix Socket).
    .DESCRIPTION
        This function allows sending HTTP requests to a Kestrun server using different transport methods, including TCP, Named Pipe, and Unix Socket.
        It supports various HTTP methods, custom headers, request bodies, and response handling options.
    .PARAMETER NamedPipeName
        The name of the named pipe to connect to. This parameter is mandatory when using the NamedPipe transport.
    .PARAMETER UnixSocketPath
        The file system path to the Unix domain socket. This parameter is mandatory when using the UnixSocket transport.
    .PARAMETER Uri
        The base URI of the Kestrun server. This parameter is mandatory when using the Tcp transport.
    .PARAMETER Method
        The HTTP method to use for the request (e.g., GET, POST, PUT, DELETE). The default is GET.
    .PARAMETER Path
        The request target path (e.g., '/api/resource'). Defaults to '/'.
    .PARAMETER Body
        The request body, which can be a string, byte array, or object (which will be serialized to JSON).
    .PARAMETER InFile
        The path to a file whose contents will be uploaded as the request body.
    .PARAMETER ContentType
        The content type of the request body (e.g., 'application/json').
    .PARAMETER Headers
        A hashtable of additional headers to include in the request.
    .PARAMETER UserAgent
        The User-Agent header value. Defaults to 'PowerShell/7 Kestrun-InvokeKrWebRequest'.
    .PARAMETER Accept
        The Accept header value. Defaults to '*/*'.
    .PARAMETER SkipCertificateCheck
        If specified, SSL certificate errors will be ignored (useful for self-signed certificates).
    .PARAMETER WebSession
        A hashtable containing a CookieContainer for managing cookies across requests.
    .PARAMETER SessionVariable
        The name of a variable to store the web session (cookies) for reuse in subsequent requests
    .PARAMETER DisallowAutoRedirect
        If specified, automatic redirection will be disabled.
    .PARAMETER MaximumRedirection
        The maximum number of automatic redirections to follow. Defaults to 50.
    .PARAMETER Credential
        The credentials to use for server authentication.
    .PARAMETER UseDefaultCredentials
        If specified, the default system credentials will be used for server authentication.
    .PARAMETER Proxy
        The URI of the proxy server to use for the request.
    .PARAMETER ProxyCredential
        The credentials to use for proxy authentication.
    .PARAMETER ProxyUseDefaultCredentials
        If specified, the default system credentials will be used for proxy authentication.
    .PARAMETER TimeoutSec
        The request timeout in seconds. Defaults to 100 seconds.
    .PARAMETER OutFile
        If specified, the response body will be saved to the given file path.
    .PARAMETER AsString
        If specified, the response body will be returned as a string. Otherwise, it will attempt to parse JSON if applicable.
    .PARAMETER PassThru
        If specified, the raw HttpResponseMessage will be returned.
    .EXAMPLE
        Invoke-KrWebRequest -Uri 'http://localhost:5000' -Method 'GET' -Path '/api/resource'
        Sends a GET request to the specified Kestrun server URI and path.
    .EXAMPLE
        Invoke-KrWebRequest -NamedPipeName 'MyNamedPipe' -Method 'POST' -Path '/api/resource' -Body @{ name = 'value' } -ContentType 'application/json'
        Sends a POST request with a JSON body to the Kestrun server over a named pipe.
    .EXAMPLE
        Invoke-KrWebRequest -UnixSocketPath '/var/run/kestrun.sock' -Method 'GET' -Path '/api/resource' -OutFile 'response.json'
        Sends a GET request to the Kestrun server over a Unix socket and saves the response body to a file.
    .NOTES
        This function requires the Kestrun.Net.dll assembly to be available in the same directory or a specified path.
        It is designed to work with Kestrun servers but can be adapted for other HTTP servers as needed.
#>

function Invoke-KrWebRequest {
    [CmdletBinding(DefaultParameterSetName = 'Tcp')]
    param(
        # Transport (pick one)
        [Parameter(Mandatory, ParameterSetName = 'NamedPipe')]
        [string]$NamedPipeName,

        [Parameter(Mandatory, ParameterSetName = 'UnixSocket')]
        [string]$UnixSocketPath,

        [Parameter(Mandatory, ParameterSetName = 'Tcp')]
        [uri]$Uri,

        # Request
        [ValidateSet('GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'HEAD', 'OPTIONS', 'TRACE')]
        [string]$Method = 'GET',
        [string]$Path = '/',
        [object]$Body,
        [string]$InFile,
        [string]$ContentType,
        [hashtable]$Headers,
        [string]$UserAgent = 'PowerShell/7 Kestrun-InvokeKrWebRequest',
        [string]$Accept = '*/*',
        [int]$TimeoutSec = 100,
        [switch]$SkipCertificateCheck,

        # Web session (cookies)
        [Hashtable]$WebSession,              # { CookieContainer = <System.Net.CookieContainer> }
        [string]$SessionVariable,

        # Redirects
        [switch]$DisallowAutoRedirect,
        [int]$MaximumRedirection = 50,

        # Auth (server)
        [pscredential]$Credential,
        [switch]$UseDefaultCredentials,

        # Proxy
        [uri]$Proxy,
        [pscredential]$ProxyCredential,
        [switch]$ProxyUseDefaultCredentials,

        # Output
        [string]$OutFile,
        [switch]$AsString,
        [switch]$PassThru
    )

    # ensure DLL loaded (adjust path if needed)
    if (-not ([Type]::GetType('Kestrun.Client.KrHttpClientFactory, Kestrun.Net', $false))) {
        $try1 = Join-Path $PSScriptRoot '../lib/net8.0/Kestrun.Net.dll'
        $try2 = Join-Path $PSScriptRoot 'Kestrun.Net.dll'
        foreach ($p in @($try1, $try2)) {
            $rp = Resolve-Path -EA SilentlyContinue -LiteralPath $p
            if ($rp) { Add-Type -Path $rp.Path; break }
        }
    }

    # build options for the handler
    $cookieContainer = $null
    if ($WebSession -and $WebSession.ContainsKey('CookieContainer')) {
        $cookieContainer = $WebSession['CookieContainer']
    } else {
        # make a fresh cookie container if caller asked for a session via -SessionVariable
        if ($SessionVariable) { $cookieContainer = [System.Net.CookieContainer]::new() }
    }

    $opts = [Kestrun.Client.KrHttpClientOptions]::new()
    $opts.Timeout = [TimeSpan]::FromSeconds([Math]::Max(1, $TimeoutSec))
    $opts.IgnoreCertErrors = $SkipCertificateCheck.IsPresent
    $opts.Cookies = $cookieContainer
    $opts.AllowAutoRedirect = -not $DisallowAutoRedirect.IsPresent
    $opts.MaxAutomaticRedirections = [Math]::Max(1, $MaximumRedirection)

    if ($UseDefaultCredentials) { $opts.UseDefaultCredentials = $true }
    elseif ($Credential) {
        $opts.Credentials = $Credential.GetNetworkCredential()
    }

    if ($Proxy) {
        $webProxy = [System.Net.WebProxy]::new($Proxy)
        if ($ProxyUseDefaultCredentials) { $webProxy.Credentials = [System.Net.CredentialCache]::DefaultCredentials }
        elseif ($ProxyCredential) { $webProxy.Credentials = $ProxyCredential.GetNetworkCredential() }
        $opts.Proxy = $webProxy
        $opts.UseProxy = $true
        $opts.ProxyUseDefaultCredentials = $ProxyUseDefaultCredentials.IsPresent
    }

    # cache key (vary by transport + timeout + TLS flag + redirect + session + proxy/auth)
    $sessionKey = if ($cookieContainer) { $cookieContainer.GetHashCode() } else { 0 }
    $authKey = @(
        $UseDefaultCredentials.IsPresent,
        [string]$Credential?.UserName,
        [string]$Proxy,
        $ProxyUseDefaultCredentials.IsPresent,
        [string]$ProxyCredential?.UserName,
        (-not $DisallowAutoRedirect.IsPresent),
        $MaximumRedirection
    ) -join '|'

    if (-not $script:__KrIwrClients) { $script:__KrIwrClients = @{} }
    $cacheKey = switch ($PSCmdlet.ParameterSetName) {
        'NamedPipe' { "pipe::$NamedPipeName::$($SkipCertificateCheck.IsPresent)::$TimeoutSec::$sessionKey::$authKey" }
        'UnixSocket' { "uds::$UnixSocketPath::$($SkipCertificateCheck.IsPresent)::$TimeoutSec::$sessionKey::$authKey" }
        'Tcp' { "tcp::$($Uri.AbsoluteUri)::$($SkipCertificateCheck.IsPresent)::$TimeoutSec::$sessionKey::$authKey" }
    }

    if (-not $script:__KrIwrClients.ContainsKey($cacheKey)) {
        $client = switch ($PSCmdlet.ParameterSetName) {
            'NamedPipe' { [Kestrun.Client.KrHttpClientFactory]::CreateNamedPipeClient($NamedPipeName, $opts) }
            'UnixSocket' { [Kestrun.Client.KrHttpClientFactory]::CreateUnixSocketClient($UnixSocketPath, $opts) }
            'Tcp' { [Kestrun.Client.KrHttpClientFactory]::CreateTcpClient($Uri, $opts) }
        }
        $script:__KrIwrClients[$cacheKey] = $client
    } else {
        $client = $script:__KrIwrClients[$cacheKey]
    }

    # Build request URI
    $target = if ($PSCmdlet.ParameterSetName -eq 'Tcp') {
        if ($Path) { [Uri]::new($client.BaseAddress, $Path) } else { $client.BaseAddress }
    } else {
        [Uri]::new(($Path.StartsWith('/') ? $Path : "/$Path"), [System.UriKind]::Relative)
    }

    # Build request
    $req = [System.Net.Http.HttpRequestMessage]::new([System.Net.Http.HttpMethod]::new($Method), $target)
    if ($UserAgent) { $null = $req.Headers.TryAddWithoutValidation('User-Agent', $UserAgent) }
    if ($Accept) { $null = $req.Headers.TryAddWithoutValidation('Accept', $Accept) }
    foreach ($k in ($Headers?.Keys ?? @())) { $null = $req.Headers.TryAddWithoutValidation([string]$k, [string]$Headers[$k]) }

    # Body / InFile
    if ($InFile) {
        $bytes = [System.IO.File]::ReadAllBytes((Resolve-Path -LiteralPath $InFile))
        $content = [System.Net.Http.ByteArrayContent]::new($bytes)
        if ($ContentType) { $content.Headers.ContentType = $ContentType }
        $req.Content = $content
    } elseif ($PSBoundParameters.ContainsKey('Body')) {
        switch ($Body) {
            { $_ -is [string] } {
                $ctype = $ContentType; if (-not $ctype) { $ctype = 'text/plain; charset=utf-8' }
                $req.Content = [System.Net.Http.StringContent]::new([string]$Body, [System.Text.Encoding]::UTF8, $ctype); break
            }
            { $_ -is [byte[]] } {
                $req.Content = [System.Net.Http.ByteArrayContent]::new([byte[]]$Body)
                if ($ContentType) { $req.Content.Headers.ContentType = $ContentType }
                break
            }
            default {
                $json = $Body | ConvertTo-Json -Depth 32 -Compress
                $req.Content = [System.Net.Http.StringContent]::new($json, [System.Text.Encoding]::UTF8, ($ContentType ?? 'application/json'))
            }
        }
    }

    # Persist session if requested
    if ($SessionVariable) {
        if (-not $cookieContainer) { $cookieContainer = [System.Net.CookieContainer]::new() }
        # (Cookies are already in handler; we just hand the container out)
        Set-Variable -Name $SessionVariable -Scope 1 -Value @{ CookieContainer = $cookieContainer }
    }

    # ---- Send (streaming if -OutFile) ----
    if ($OutFile) {
        # Build a fresh request for streaming (do NOT reuse across paths)
        $streamReq = [System.Net.Http.HttpRequestMessage]::new([System.Net.Http.HttpMethod]::new($Method), $target)
        # clone headers
        if ($UserAgent) { $null = $streamReq.Headers.TryAddWithoutValidation('User-Agent', $UserAgent) }
        if ($Accept) { $null = $streamReq.Headers.TryAddWithoutValidation('Accept', $Accept) }
        foreach ($h in ($Headers?.Keys ?? @())) { $null = $streamReq.Headers.TryAddWithoutValidation([string]$h, [string]$Headers[$h]) }
        # clone content if present
        if ($req.Content) {
            $bytesForClone = $req.Content.ReadAsByteArrayAsync().GetAwaiter().GetResult()
            $streamReq.Content = [System.Net.Http.ByteArrayContent]::new($bytesForClone)
            foreach ($ch in $req.Content.Headers) { $null = $streamReq.Content.Headers.TryAddWithoutValidation($ch.Key, ($ch.Value -join ', ')) }
        }

        try {
            $outPath = (Resolve-Path -LiteralPath $OutFile).Path
            [Kestrun.Client.KrHttpDownloads]::DownloadToFileAsync($client, $streamReq, $outPath, $false).GetAwaiter().GetResult() | Out-Null

            # hand back the session cookie container if requested
            if ($SessionVariable) {
                if (-not $cookieContainer) { $cookieContainer = [System.Net.CookieContainer]::new() }
                Set-Variable -Name $SessionVariable -Scope 1 -Value @{ CookieContainer = $cookieContainer }
            }

            return [pscustomobject]@{
                StatusCode = 200
                StatusDescription = 'OK'
                Headers = $null
                RawContent = $null
                Content = $null
                BaseResponse = $null
                SavedTo = $outPath
            }
        } finally {
            $streamReq.Dispose()
            if ($req) { $req.Dispose() } # dispose the original builder too
        }
    }

    # ---- Non-file responses (beware of big bodies) ----
    # Standard send for non-OutFile cases; okay for JSON/text where you expect small/medium sizes.
    try {
        $res = $client.SendAsync($req).GetAwaiter().GetResult()
    } finally {
        $req.Dispose()
    }

    if ($PassThru) { return $res }

    $bytes = $res.Content.ReadAsByteArrayAsync().GetAwaiter().GetResult()
    $text = [System.Text.Encoding]::UTF8.GetString($bytes)
    $ctype = $res.Content.Headers.ContentType?.MediaType

    # session handoff after request completes
    if ($SessionVariable) {
        if (-not $cookieContainer) { $cookieContainer = [System.Net.CookieContainer]::new() }
        Set-Variable -Name $SessionVariable -Scope 1 -Value @{ CookieContainer = $cookieContainer }
    }

    [pscustomobject]@{
        StatusCode = [int]$res.StatusCode
        StatusDescription = $res.ReasonPhrase
        Headers = $res.Headers
        RawContent = $text
        Content = if ($ctype -and $ctype -like 'application/json*') { try { $text | ConvertFrom-Json -Depth 32 } catch { $text } } else { $text }
        BaseResponse = $res
        SavedTo = $null
    }
}