Public/Invoke-GitHubApi.ps1
|
# --------------------------------------------------------------------------- # Invoke-GitHubApi # General-purpose GitHub REST API caller. Handles authentication, # User-Agent, and JSON serialization in one place so callers only # need to supply a token, an endpoint or URI, and an optional body. # # -Endpoint accepts a path relative to https://api.github.com/ and is # the preferred form for all standard GitHub REST API calls (keeps the # base URL out of call sites). -Uri accepts a full URL and is reserved # for cases where the base differs - e.g. pagination next-links. # The two parameters are mutually exclusive. # # -Token accepts both PATs and GitHub App installation tokens; both # are bearer tokens and are interchangeable at the HTTP level. # # By default it returns the raw Invoke-RestMethod response, and callers # extract the fields they need (.token, .runners, .id, etc.). # # -Header and -IncludeResponseDetail exist for conditional (ETag) requests, # which a polling caller needs to stay inside the hourly rate limit: a 304 # Not Modified is not charged against the budget, but observing one requires # sending a request header and reading back both the response headers and a # non-success status code. Both are opt-in and change nothing for callers # that omit them. # # The response detail is RETURNED rather than published through # -*Variable out-parameters mirroring Invoke-RestMethod's own. That shape # was tried and does not work here: this function ships inside a module, so # a function running in module session state cannot write a variable into # the scope of a script that imported it. `Set-Variable -Scope 1` lands in # the module's own parent scope and $PSCmdlet.SessionState.PSVariable.Set # lands in the callee's - both silently, with the caller's variable left # untouched. A return value crosses the boundary unambiguously. # # RETRY # Every call is retried, but the policy is chosen by method because the # two cases are not equally safe: # - Reads (GET/HEAD/OPTIONS) get Common.PowerShell's full # transient-network policy: DNS hiccups, dropped connections, # timeouts and 5xx responses. A replayed read cannot do harm. # - Writes get New-GitHubWriteRetryStrategy, which matches only # failures that provably never reached GitHub. Retrying a POST after # a timeout or a lost 5xx response could mint a second registration # token or double-execute a runner removal. # 4xx responses are permanent under both policies, so a bad token or a # mistyped repo still fails fast instead of sleeping through the budget. # --------------------------------------------------------------------------- function Invoke-GitHubApi { [CmdletBinding()] param( [Parameter(Mandatory)] [string] $Token, # Path relative to https://api.github.com/ - preferred for all standard # GitHub REST API calls. Mutually exclusive with -Uri. [Parameter()] [string] $Endpoint, # Full URL - use for pagination next-links or non-api.github.com hosts. # Mutually exclusive with -Endpoint. [Parameter()] [string] $Uri, [Parameter()] [string] $Method = 'Get', [Parameter()] [hashtable] $Body, # Extra request headers, merged over the three defaults below. Covers # per-call concerns the fixed set cannot express - 'If-None-Match' for # a conditional GET, or the 'Accept' / 'X-GitHub-Api-Version' pins. # A key that collides with a default replaces it, so a caller that # must send a different Content-Type still can. [Parameter()] [hashtable] $Header, # Return an object carrying .Content, .Headers, and .StatusCode # instead of the bare body. # # It also disables Invoke-RestMethod's built-in error check: the # status this mainly exists to observe - 304 Not Modified - is a # non-success code that would otherwise throw before the caller could # see it. The trade is that the caller then owns ALL status handling, # 4xx and 5xx included; nothing throws on a bad response any more. [Parameter()] [switch] $IncludeResponseDetail ) $hasEndpoint = $PSBoundParameters.ContainsKey('Endpoint') $hasUri = $PSBoundParameters.ContainsKey('Uri') if ($hasEndpoint -and $hasUri) { throw '-Endpoint and -Uri are mutually exclusive.' } if (-not $hasEndpoint -and -not $hasUri) { throw 'Either -Endpoint or -Uri must be specified.' } $resolvedUri = if ($hasEndpoint) { "https://api.github.com/$Endpoint" } else { $Uri } $headers = @{ 'Authorization' = "Bearer $Token" 'User-Agent' = 'Infrastructure' 'Content-Type' = 'application/json' } if ($PSBoundParameters.ContainsKey('Header')) { foreach ($extra in $Header.GetEnumerator()) { $headers[$extra.Key] = $extra.Value } } $params = @{ Uri = $resolvedUri Method = $Method Headers = $headers ErrorAction = 'Stop' } if ($PSBoundParameters.ContainsKey('Body')) { $params['Body'] = $Body | ConvertTo-Json -Depth 10 -Compress } # See the RETRY note in the file header for why the policy is chosen by # method rather than applied uniformly. $retryStrategy = if (Test-GitHubIdempotentMethod -Method $Method) { New-TransientNetworkRetryStrategy } else { New-GitHubWriteRetryStrategy } # Surfaced in Invoke-WithRetry's per-attempt warning. The token is # deliberately absent - that warning reaches operator-visible logs. $operationName = "GitHub API $Method $resolvedUri" if (-not $IncludeResponseDetail) { return Invoke-WithRetry ` -ScriptBlock { Invoke-RestMethod @params } ` -RetryStrategy $retryStrategy ` -OperationName $operationName } # Invoke-RestMethod writes these into the scope of ITS caller, which is # the attempt block below - a direct, same-session-state hop that does # work. The harvest has to stay inside that block for the same reason: # each attempt writes into its own invocation scope. $localHeadersName = 'gitHubApiResponseHeaders' $localStatusName = 'gitHubApiStatusCode' $params['ResponseHeadersVariable'] = $localHeadersName $params['StatusCodeVariable'] = $localStatusName $params['SkipHttpErrorCheck'] = $true # SkipHttpErrorCheck is what makes a 304 observable, but it also means a # 5xx arrives as a return value instead of an exception - so on this path # it would slip past the retry classifier that the ordinary path gets for # free. Re-raise it as the HttpResponseException the cmdlet would have # thrown so the same policy judges it, then hand the caller the final # response once the attempts are spent. Nothing new escapes: -Include- # ResponseDetail still never throws on a bad status, which is its # documented contract. # # A hashtable carries the last response out because a plain assignment # inside the attempt block would land in that block's own scope. $httpLowestServerError = 500 $lastDetail = @{ Value = $null } $attemptRequest = { $content = Invoke-RestMethod @params # Get-Variable rather than a bare reference: a mocked Invoke-RestMethod # (the unit suites) never assigns these, and Set-StrictMode -Version # Latest makes reading an unassigned variable a terminating error. # SilentlyContinue yields $null, the honest answer for "no headers". $detail = [PSCustomObject]@{ Content = $content Headers = Get-Variable -Name $localHeadersName -ValueOnly -ErrorAction SilentlyContinue StatusCode = Get-Variable -Name $localStatusName -ValueOnly -ErrorAction SilentlyContinue } $lastDetail.Value = $detail if ($null -ne $detail.StatusCode -and [int] $detail.StatusCode -ge $httpLowestServerError) { throw [Microsoft.PowerShell.Commands.HttpResponseException]::new( "$operationName returned HTTP $($detail.StatusCode).", [System.Net.Http.HttpResponseMessage]::new( [System.Net.HttpStatusCode] [int] $detail.StatusCode)) } return $detail } try { return Invoke-WithRetry ` -ScriptBlock $attemptRequest ` -RetryStrategy $retryStrategy ` -OperationName $operationName } catch [Microsoft.PowerShell.Commands.HttpResponseException] { # The built-in error check is off on this path, so this type can only # have come from the throw above - the retries are spent on a 5xx (or # the write policy declined to retry it at all). Either way the caller # owns status handling and wants the response, not an exception. return $lastDetail.Value } } |