class/Class.ps1
class ApiClient { [System.Net.Http.HttpClientHandler]$Handler [System.Net.Http.HttpClient]$Client [System.Collections.Hashtable]$Collector ApiClient() { $this.Handler = [System.Net.Http.HttpClientHandler]::New() $this.Client = [System.Net.Http.HttpClient]::New($this.Handler) $this.Collector = $null } [string] Path([string]$Path) { $Output = if (![IO.Path]::IsPathRooted($Path)) { # Convert partial file path to a full file path $FullPath = Join-Path -Path (Get-Location).Path -ChildPath $Path $FullPath = Join-Path -Path $FullPath -ChildPath '.' [IO.Path]::GetFullPath($FullPath) } else { $Path } return $Output } [System.Object] Invoke([System.Object]$Param) { # Send API endpoint and headers to verbose stream $this.Verbose('ApiClient.Invoke',($Param.Method.ToUpper(),$Param.Path -join ' ')) if ($Param.Headers) { [string]$Verbose = $Param.Headers.GetEnumerator().foreach{ "$($_.Key)=$($_.Value)" } -join ', ' if ($Verbose) { $this.Verbose('ApiClient.Invoke',$Verbose) } } $Output = try { # Create basic HTTP request message and add headers $Message = [System.Net.Http.HttpRequestMessage]::New($Param.Method.ToUpper(),$Param.Path) $Param.Headers.GetEnumerator().foreach{ $Message.Headers.Add($_.Key,$_.Value) } if ($Param.Formdata) { # Create Formdata message $Message.Content = [System.Net.Http.MultipartFormDataContent]::New() $Param.Formdata.GetEnumerator().foreach{ $Verbose = if ($_.Key -match '^(file|upfile)$') { # With 'file' or 'upfile', create StreamContent from key/value pair $FileStream = [System.IO.FileStream]::New($this.Path($_.Value), [System.IO.FileMode]::Open) $Filename = [System.IO.Path]::GetFileName($this.Path($_.Value)) $StreamContent = [System.Net.Http.StreamContent]::New($FileStream) $FileType = $this.StreamType($Filename) if ($FileType) { $StreamContent.Headers.ContentType = $FileType } $Message.Content.Add($StreamContent,$_.Key,$Filename) @($_.Key,'<StreamContent>') -join '=' } else { # Add StringContent for other Formdata key/value pairs $Message.Content.Add([System.Net.Http.StringContent]::New($_.Value),$_.Key) @($_.Key,$_.Value) -join '=' } $this.Verbose('ApiClient.Invoke',($Verbose -join ', ')) } } elseif ($Param.Body) { $Message.Content = if ($Param.Body -is [string] -and $Param.Headers.ContentType) { # Add 'Body' as StringContent [System.Net.Http.StringContent]::New($Param.Body,[System.Text.Encoding]::UTF8,$Param.Headers.ContentType) if ($Param.Path -notmatch '/oauth2/token$') { $this.Verbose('ApiClient.Invoke',$Param.Body) } } else { $Param.Body } } # Log request when enabled if ($this.Collector.Enable -contains 'requests') { $this.Log($Message) } $Request = if ($Param.Outfile) { # Download file @($Param.Headers.Keys).foreach{ $this.Client.DefaultRequestHeaders.Add($_,$Param.Headers.$_) } $this.Verbose('ApiClient.Invoke','Receiving ByteArray content...') $this.Client.GetByteArrayAsync($Param.Path) } else { # Send request $this.Client.SendAsync($Message,[System.Net.Http.HttpCompletionOption]::ResponseHeadersRead) } if ($Param.Outfile -and $Request) { try { # When file download is complete, direct to 'Outfile' $this.Verbose('ApiClient.Invoke',"Creating '$($Param.Outfile)'.") [System.IO.File]::WriteAllBytes($this.Path($Param.Outfile),$Request.Result) } catch { throw $_ } finally { @($Param.Headers.Keys).foreach{ if ($this.Client.DefaultRequestHeaders.$_) { [void]($this.Client.DefaultRequestHeaders.Remove($_)) } } } } elseif ($Request.Result.StatusCode) { # Output HTTP response code to verbose stream $HashCode = $Request.Result.StatusCode.GetHashCode() $this.Verbose('ApiClient.Invoke',($HashCode,$Request.Result.StatusCode -join ': ')) if ($Request.Result.Headers) { # Output response headers to verbose stream and warn when 'X-Api-Deprecation' appears $this.Verbose('ApiClient.Invoke',"$($Request.Result.Headers.GetEnumerator().foreach{ @($_.Key,(@($_.Value) -join ', ')) -join '=' } -join ', ')") @($Request.Result.Headers.GetEnumerator().Where({ $_.Key -match '^X-Api-Deprecation' })).foreach{ Write-Warning ([string]$_.Key,[string]$_.Value -join ': ') } } if ($Request.Result.Content -and $this.Collector.Enable -contains 'responses') { # Log response when enabled $this.Log($Request.Result) } $RetryAfter = if ($HashCode -eq 429 -and $Param.Path -notmatch '/oauth2/token$') { # Capture 'X-Ratelimit-Retryafter' when present $Request.Result.Headers.GetEnumerator().Where({ $_.Key -eq 'X-Ratelimit-Retryafter' }).Value } if ($RetryAfter) { # Subtract current time from 'X-Ratelimit-Retryafter', warn, and retry when rate limited [int32]$Wait = (([System.DateTimeOffset]::FromUnixTimeSeconds($RetryAfter)).LocalDateTime - (Get-Date)).Seconds $Limit = $Request.Result.Headers.GetEnumerator().Where({ $_.Key -eq 'X-Ratelimit-Limit' }).Value $Remaining = $Request.Result.Headers.GetEnumerator().Where({ $_.Key -eq 'X-Ratelimit-Remaining' }).Value Write-Warning ('Rate limited for {0} second(s). [{1}, {2}]' -f $Wait,('Limit',$Limit -join '='), ('Remaining',$Remaining -join '=')) Start-Sleep -Seconds $Wait $this.Invoke($Param) } elseif ($Request.Result.Content -and $Param.Path -notmatch '/oauth2/token$') { # Convert from Json or output string content if ($Request.Result.Content.Headers.ContentType -eq 'application/json' -or $Request.Result.Content.Headers.ContentType.MediaType -eq 'application/json') { ConvertFrom-Json ($Request.Result.Content).ReadAsStringAsync().Result } else { ($Request.Result.Content).ReadAsStringAsync().Result } } else { # Output entire response $Request } } } catch { throw $_ } finally { if ($Request) { $Request.Dispose() } } return $Output } [void] Log([System.Object]$Object) { # Create LogScale message payload from 'HttpRequestMessage' or 'HttpResponseMessage' $Item = @{ timestamp = Get-Date -Format o; attributes = @{ Headers = @{} }} if ($Object -is [System.Net.Http.HttpRequestMessage]) { @('RequestUri','Method').foreach{ $Item.attributes[$_] = $Object.$_.ToString() } $Object.Headers.GetEnumerator().Where({ $_.Key -ne 'Authorization' }).foreach{ $Item.attributes.Headers[$_.Key] = $_.Value } if ($Object.Content) { # Redact 'client_secret' from request $Item.attributes['Content'] = $Object.Content.ReadAsStringAsync().Result -replace 'client_secret=\w+&?', 'client_secret=redacted' } } elseif ($Object -is [System.Net.Http.HttpResponseMessage]) { $Object.Headers.GetEnumerator().foreach{ $Item.attributes.Headers[$_.Key] = $_.Value } if ($Object.Content -and ($Object.Content.Headers.ContentType -eq 'application/json' -or $Object.Content.Headers.ContentType.MediaType -eq 'application/json')) { @(($Object.Content.ReadAsStringAsync().Result | ConvertFrom-Json).PSObject.Properties).Where({ $_.Name -ne 'access_token' }).foreach{ # Add content, but exclude 'access_token' $Item.attributes[$_.Name] = $_.Value } } elseif ($Object.Content) { # Add content as a string when unable to determine if 'HttpRequestMessage' or 'HttpResponseMessage' $Item.attributes['Content'] = $Object.Content.ReadAsStringAsync().Result } } # Use Invoke-RestMethod to send to LogScale within a background job $Job = @{ Name = 'ApiClient_Log',$Item.timestamp -join '.' ScriptBlock = { $Param = $args[0]; Invoke-RestMethod @Param } ArgumentList = @{ Uri = $this.Collector.Uri Method = 'post' Headers = @{ Authorization = 'Bearer',$this.Collector.Token -join ' '; ContentType = 'application/json' } Body = ConvertTo-Json @( @{ tags = @{ host = [System.Net.Dns]::GetHostName() source = $this.Client.DefaultRequestHeaders.UserAgent.ToString() } events = @(,$Item) } ) -Depth 32 -Compress } } [void](Start-Job @Job) $this.Verbose('ApiClient.Log',"Submitted job '$($Job.Name)'.") @(Get-Job | Where-Object { $_.Name -match '^ApiClient_Log' -and $_.State -eq 'Completed' }).foreach{ # Remove completed background jobs $this.Verbose('ApiClient.Log',"Removed job '$($_.Name)'.") Remove-Job -Id $_.Id } } [string] StreamType([string]$String) { [string]$Extension = [System.IO.Path]::GetExtension($String) -replace '^\.',$null $Output = switch -Regex ($Extension) { # Output string based on file extension '^(bmp|gif|jp(e?)g|png)$' { "image/$_" } '^(pdf|zip)$' { "application/$_" } '^7z$' { 'application/x-7z-compressed' } '^(csv|txt)$' { if ($_ -eq 'txt') { 'text/plain' } else { "text/$_" }} '^doc(x?)$' { if ($_ -match 'x$') { 'application/vnd.openxmlformats-officedocument.wordprocessingml.document' } else { 'application/msword' } } '^ppt(x?)$' { if ($_ -match 'x$') { 'application/vnd.openxmlformats-officedocument.presentationml.presentation' } else { 'application/vnd.ms-powerpoint' } } '^xls(x?)$' { if ($_ -match 'x$') { 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' } else { 'application/vnd.ms-excel' } } } return $Output } [void] Verbose([string]$Function,[string]$String) { Write-Verbose ((Get-Date -Format 'HH:mm:ss'),"[$Function]",$String -join ' ') } } |