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. # --------------------------------------------------------------------------- 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 } if (-not $IncludeResponseDetail) { return Invoke-RestMethod @params } # Invoke-RestMethod writes these into the scope of ITS caller, which is # this function - a direct, same-session-state hop that does work. $localHeadersName = 'gitHubApiResponseHeaders' $localStatusName = 'gitHubApiStatusCode' $params['ResponseHeadersVariable'] = $localHeadersName $params['StatusCodeVariable'] = $localStatusName $params['SkipHttpErrorCheck'] = $true $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". [PSCustomObject]@{ Content = $content Headers = Get-Variable -Name $localHeadersName -ValueOnly -ErrorAction SilentlyContinue StatusCode = Get-Variable -Name $localStatusName -ValueOnly -ErrorAction SilentlyContinue } } |