Invoke-GitHubRESTApi.ps1
function Invoke-GitHubRestAPI { <# .Synopsis Invokes the Git Rest API .Description Invokes the GitHub REST API .Example # Uses the Azure DevOps REST api to get builds from a project $org = 'StartAutomating' $repo = 'PSDevOps' Invoke-GitRestAPI "https://api.github.com/repos/StartAutomating/PSDevOps" .Link Invoke-RestMethod #> [OutputType([PSObject])] [CmdletBinding(DefaultParameterSetName='Url')] param( # The REST API Url [Parameter(ValueFromPipelineByPropertyName,ParameterSetName='Url')] [Alias('Url')] [uri] $Uri, <# Specifies the method used for the web request. The acceptable values for this parameter are: - Default - Delete - Get - Head - Merge - Options - Patch - Post - Put - Trace #> [Parameter(ValueFromPipelineByPropertyName,ParameterSetName='Url')] [ValidateSet('GET','DELETE','HEAD','MERGE','OPTIONS','PATCH','POST', 'PUT', 'TRACE')] [string] $Method = 'GET', # Specifies the body of the request. # If this value is a string, it will be passed as-is # Otherwise, this value will be converted into JSON. [Parameter(ValueFromPipelineByPropertyName,ParameterSetName='Url')] [Object] $Body, [Parameter(ValueFromPipelineByPropertyName,ParameterSetName='Url')] [Alias('UrlParameters')] [Collections.IDictionary] $UrlParameter = @{}, # Additional parameters provided in the query string. [Parameter(ValueFromPipelineByPropertyName,ParameterSetName='Url')] [Alias('QueryParameters')] [Collections.IDictionary] $QueryParameter = @{}, # A Personal Access Token [Parameter(ValueFromPipelineByPropertyName)] [Alias('PAT')] [string] $PersonalAccessToken, # The page number. If provided, will only get one page of results. # If this is not provided, additional results will be fetched until they are exhausted. [Parameter(ParameterSetName='Url')] [int] $Page, # The number of items to retreive on a single page. [Parameter(ParameterSetName='Url')] [Alias('Per_Page')] [int] $PerPage, # The typename of the results. # If not set, will be the depluralized last non-variable segment of a URL. # (i.e. "https://api.github.com/user/repos" would use a typename of 'repos' # so would: "https://api.github.com/users/{UserName}/repos") [Parameter(ValueFromPipelineByPropertyName,ParameterSetName='Url')] [Alias('Decorate','Decoration')] [string[]] $PSTypeName, # A set of additional properties to add to an object [Parameter(ValueFromPipelineByPropertyName,ParameterSetName='Url')] [Collections.IDictionary] $Property, # A list of property names to remove from an object [Parameter(ValueFromPipelineByPropertyName,ParameterSetName='Url')] [string[]] $RemoveProperty, # If provided, will expand a given property returned from the REST api. [Parameter(ValueFromPipelineByPropertyName,ParameterSetName='Url')] [string] $ExpandProperty, # If provided, will decorate the values within a property in the return object. # This allows nested REST properties to work with the PowerShell Extended Type System. [Parameter(ValueFromPipelineByPropertyName,ParameterSetName='Url')] [Alias('TypeNameOfProperty')] [Collections.IDictionary] $DecorateProperty, # If set, will cache results from a request. Only HTTP GET results will be cached. [Parameter(ValueFromPipelineByPropertyName)] [switch] $Cache, # If set, will run as a background job. # This parameter will be ignored if the caller is piping the results of Invoke-ADORestAPI. # This parameter will also be ignore when calling with -DynamicParameter or -MapParameter. [Parameter(ValueFromPipelineByPropertyName)] [switch] $AsJob, # If set, will get the dynamic parameters that should be provided to any function that wraps Invoke-ADORestApi [Parameter(Mandatory,ParameterSetName='GetDynamicParameters',ValueFromPipelineByPropertyName)] [Alias('DynamicParameters')] [switch] $DynamicParameter, # If set, will return the parameters for any function that can be passed to Invoke-ADORestApi. # Unmapped parameters will be added as a noteproperty of the returned dictionary. [Parameter(Mandatory,ParameterSetName='MapParameters',ValueFromPipelineByPropertyName)] [Alias('MapParameters')] [Collections.IDictionary] $MapParameter, # The GitAPIUrl # This will used if -Uri does not contain a hostname. # It will default to $env:GIT_API_URL if it is set, otherwise 'https://api.github.com/' [Parameter(ValueFromPipelineByPropertyName,ParameterSetName='Url')] [uri] $GitApiUrl = $(if ($env:GIT_API_URL) { $env:GIT_API_URL} else { "https://api.github.com/" } ), # Specifies the content type of the web request. # If this parameter is omitted and the request method is POST, Invoke-RestMethod sets the content type to application/x-www-form-urlencoded. Otherwise, the content type is not specified in the call. [Parameter(ValueFromPipelineByPropertyName,ParameterSetName='Url')] [string] $ContentType = 'application/json', # Specifies the headers of the web request. Enter a hash table or dictionary. [Parameter(ValueFromPipelineByPropertyName,ParameterSetName='Url')] [Alias('Header')] [Collections.IDictionary] $Headers, # Provides a custom user agent. GitHub API requests require a User Agent. [Parameter(ValueFromPipelineByPropertyName,ParameterSetName='Url')] [string] $UserAgent = "PSDevOps/1.0 (StartAutomating;PSDevOps;Invoke-GitRESTApi)" ) dynamicParam { $myInv = $MyInvocation $RestVariable = [Regex]::new(@' # Matches URL segments and query strings containing variables. # Variables can be enclosed in brackets or curly braces, or preceeded by a $ or : (?> # A variable can be in a URL segment or subdomain (?<Start>[/\.]) # Match the <Start>ing slash|dot ... (?<IsOptional>\?)? # ... an optional ? (to indicate optional) ... (?: \{(?<Variable>\w+)\}| # ... A <Variable> name in {} OR \[(?<Variable>\w+)\]| # A <Variable> name in [] OR \<(?<Variable>\w+)\>| # A <Variable> name in <> OR \$(?<Variable>\w+) | # A `$ followed by a <Variable> OR \:(?<Variable>\w+) # A : followed by a <Variable> ) | (?<IsOptional> # If it's optional it can also be [{\[](?<Start>/) # a bracket or brace, followed by a slash ) (?<Variable>\w+)[}\]] # then a <Variable> name followed by } or ] | # OR it can be in a query parameter: (?<Start>[?&]) # Match The <Start>ing ? or & ... (?<Query>[\w\-]+) # ... the <Query> parameter name ... = # ... an equals ... (?<IsOptional>\?)? # ... an optional ? (to indicate optional) ... (?: \{(?<Variable>\w+)\}| # ... A <Variable> name in {} OR \[(?<Variable>\w+)\]| # A <Variable> name in [] OR \<(?<Variable>\w+)\>| # A <Variable> name in <> OR \$(?<Variable>\w+) | # A `$ followed by a <Variable> OR \:(?<Variable>\w+) # A : followed by a <Variable> ) ) '@, 'IgnoreCase,IgnorePatternWhitespace') $ReplaceRestVariable = { param($match) if ($urlParameter -and $urlParameter[$match.Groups["Variable"].Value]) { return $match.Groups["Start"].Value + $( if ($match.Groups["Query"].Success) { $match.Groups["Query"].Value + '=' } ) + ([Web.HttpUtility]::UrlEncode( $urlParameter[$match.Groups["Variable"].Value] )) } else { return '' } } $dynamicParams = [Management.Automation.RuntimeDefinedParameterDictionary]::new() if ($MyInvocation.InvocationName -ne $MyInvocation.MyCommand.Name) { $GitApiUrl = $MyInvocation.InvocationName if ($GitApiUrl -notlike 'https://*') { $GitApiUrl = "https://$GitApiUrl" } # Now here come the actual dynamic parameters. $pos = 0 $UrlParameterDefaultValueKey = $MyInvocation.MyCommand.Name + ":URLParameter" foreach ($match in $RestVariable.Matches($MyInvocation.InvocationName)) { $dynamicParamName = $match.Groups["Variable"].Value $paramAttr = [Management.Automation.ParameterAttribute]::new() $paramAttr.ValueFromPipelineByPropertyName = $true $paramAttr.Mandatory = -not $match.Groups["IsOptional"].Success -and -not $Global:PSDefaultParameterValues.$UrlParameterDefaultValueKey.$dynamicParamName $paramAttr.HelpMessage = "$match" $paramAttr.Position = $pos $pos++ $dynamicParam = [Management.Automation.RuntimeDefinedParameter]::new($dynamicParamName, [string],$paramAttr) $dynamicParamNames += $dynamicParamName $dynamicParams.Add($dynamicParamName, $dynamicParam) } } $DynamicParameterNames = $dynamicParams.Keys -as [string[]] return $dynamicParams } begin { if (-not $gitProgressId -and ($ProgressPreference -ne 'silentlycontinue') ) { $gitProgressId = [Random]::new().Next() } if ($MyInvocation.InvocationName -ne $MyInvocation.MyCommand.Name -and $MyInvocation.InvocationName -like "$GitApiUrl*" -or $MyInvocation.InvocationName -like "$($GitApiUrl.Host)*") { $GitApiUrl = $MyInvocation.InvocationName if ($GitApiUrl -notlike 'https://*') { $GitApiUrl = "https://$GitApiUrl" } } } process { $irmSplat = @{} + $PSBoundParameters # First, copy PSBoundParameters. if ($PSCmdlet.ParameterSetName -eq 'GetDynamicParameters') { if (-not $script:InvokeGitRESTApiParams) { $script:InvokeGitRESTApiParams = [Management.Automation.RuntimeDefinedParameterDictionary]::new() $InvokeGitRESTApi = $MyInvocation.MyCommand :nextInputParameter foreach ($in in ([Management.Automation.CommandMetaData]$InvokeGitRESTApi).Parameters.Keys) { if ($in -notin 'Cache', 'PersonalAccessToken', 'AsJob') { continue nextInputParameter } $script:InvokeGitRESTApiParams.Add($in, [Management.Automation.RuntimeDefinedParameter]::new( $InvokeGitRESTApi.Parameters[$in].Name, $InvokeGitRESTApi.Parameters[$in].ParameterType, $InvokeGitRESTApi.Parameters[$in].Attributes )) } foreach ($paramName in $script:InvokeGitRESTApiParams.Keys) { foreach ($attr in $script:InvokeGitRESTApiParams[$paramName].Attributes) { if ($attr.ValueFromPipeline) {$attr.ValueFromPipeline = $false} if ($attr.ValueFromPipelineByPropertyName) {$attr.ValueFromPipelineByPropertyName = $false} } } } return $script:InvokeGitRESTApiParams } elseif ($PSCmdlet.ParameterSetName -eq 'MapParameters') { $invokeParams = [Ordered]@{} + $MapParameter # Then we copy our parameters $unmapped = [Ordered]@{} foreach ($k in @($invokeParams.Keys)) { # and walk thru each parameter name. # If a parameter isn't found in Invoke-ADORestAPI if (-not $MyInvocation.MyCommand.Parameters.ContainsKey($k)) { $unmapped[$k] = $invokeParams[$k] $invokeParams.Remove($k) # we remove it. } } $invokeParams.psobject.properties.add([PSNoteProperty]::new('Unmapped',$unmapped)) return $invokeParams } #region Prepare Parameters if (-not $PersonalAccessToken -and $script:CachedGitPAT) { $PersonalAccessToken = $psBoundParameters['PersonalAccessToken'] = $script:CachedGitPAT # Then, use a cached PAT if appropriate. } if ($AsJob -and ($MyInvocation.PipelinePosition -eq $MyInvocation.PipelineLength)) { $irmSplat.Remove('AsJob') Start-Job -ScriptBlock ([ScriptBlock]::Create("param([Hashtable]`$parameter) function $($MyInvocation.MyCommand.Name) { $($MyInvocation.MyCommand.ScriptBlock) } $($MyInvocation.MyCommand.Name) @parameter ")) -ArgumentList $irmSplat } $authHeaderType = # Next we need to determine the correct auth header. # If we're using the graph API, it's 'bearer' if ($uri.Segments.Length -ge 2 -and $Uri.Segments[1] -eq 'graphql') { "bearer" } else { "token" # otherwise, it's 'token'. } if (-not $uri.Host -and $uri -match "\w+\s+(?:http|/)") { $method, $uri = $uri -split ' ' } if (-not $uri.host -and $GitApiUrl) { $uri = "$GitApiUrl" + $Uri } if ($UrlParameter.Count) { # If URLParameter was already populated, it might be a reference. # we wouldn't want that reference to cache a parameter by accident, so create a local copy. $UrlParameter = @{} + $UrlParameter } if ($dynamicParameterNames) { foreach ($dynamicParamName in $dynamicParameterNames) { if ($PSBoundParameters[$dynamicParamName]) { $UrlParameter[$dynamicParamName] = $PSBoundParameters[$dynamicParamName] } } } $originalUri = "$uri" $uri = $RestVariable.Replace($uri, $ReplaceRestVariable) if ($Page) { $QueryParameter['page'] = $Page } if ($PerPage) { $QueryParameter['per_page'] = $PerPage } if ($QueryParameter -and $QueryParameter.Count) { $uri = "$uri" + $(if (-not $uri.Query) { '?' } else { '&' }) + @( foreach ($qp in $QueryParameter.GetEnumerator()) { '' + $qp.Key + '=' + [Web.HttpUtility]::UrlEncode($qp.Value).Replace('+', '%20') } ) -join '&' } if (-not $script:GitRequestCache) { $script:GitRequestCache = @{} } if ($Cache -and $method -eq 'Get' -and $script:GitRequestCache[$uri]) { foreach ($out in $script:GitRequestCache[$uri]) { $out } return } if ($PersonalAccessToken) { # If there was a personal access token, set the authorization header if ($Headers) { # (make sure not to step on other headers). $irmSplat.Headers.Authorization = "$authHeaderType $PersonalAccessToken" } else { $irmSplat.Headers = @{ Authorization = "$authHeaderType $PersonalAccessToken" } } $script:CachedGitPAT = $PersonalAccessToken } if ($Body -and $Body -isnot [string]) { # If a body was passed, and it wasn't a string $irmSplat.Body = ConvertTo-Json -Depth 100 -InputObject $body # make it JSON. } if (-not $irmSplat.ContentType) { # If no content type was passed $irmSplat.ContentType = $ContentType # set it to the default. } #endregion Prepare Parameters #region Call Invoke-RestMethod $webRequest = [Net.HttpWebRequest]::Create($uri) $webRequest.Method = $Method $webRequest.contentType = $ContentType $irmSplat.UserAgent = $webRequest.UserAgent = $UserAgent if ($irmSplat.Headers) { foreach ($h in $irmSplat.Headers.GetEnumerator()) { $webRequest.headers.add($h.Key, $h.Value) } } if ($irmSplat.Body) { $bytes = [Text.Encoding]::UTF8.GetBytes($irmSplat.Body) $webRequest.contentLength = $bytes.Length $requestStream = $webRequest.GetRequestStream() $requestStream.Write($bytes, 0, $bytes.Length) $requestStream.Close() } else { $webRequest.contentLength = 0 } Write-Verbose "$Method $Uri [$($webRequest.ContentLength) bytes]" $response = . { $webResponse = try { $WebRequest.GetResponse() } catch { $ex = $_ if ($ex.Exception.InnerException.Response) { $streamIn = [IO.StreamReader]::new($ex.Exception.InnerException.Response.GetResponseStream()) $strResponse = $streamIn.ReadToEnd() $streamIn.Close() $streamIn.Dispose() $errorRecord = [Management.Automation.ErrorRecord]::new($ex.Exception.InnerException, $ex.Exception.HResult, 'NotSpecified', $webRequest) $PSCmdlet.WriteError($errorRecord) return } else { $errorRecord = [Management.Automation.ErrorRecord]::new($ex.Exception, $ex.Exception.HResult, 'NotSpecified', $webRequest) $PSCmdlet.WriteError($errorRecord) return } } $rs = $webresponse.GetResponseStream() $responseHeaders = $webresponse.Headers $responseHeaders = if ($responseHeaders -and $responseHeaders.GetEnumerator()) { $reHead = @{} foreach ($r in $responseHeaders.GetEnumerator()) { $reHead[$r] = $responseHeaders[$r] } $reHead } else { @{} } $streamIn = if ($webResponse.ContentEncoding) { [IO.StreamReader]::new($rs, $webResponse.ContentEncoding) } else { [IO.StreamReader]::new($rs) } $strResponse = $streamIn.ReadToEnd() if ($webResponse.ContentType -like '*json*') { try { $strResponse | ConvertFrom-Json } catch { $strResponse } } else { $strResponse } $streamIn.Close() } 2>&1 $null = $null # We call Invoke-RestMethod with the parameters we've passed in. # It will take care of converting the results from JSON. if (-not $PSTypeName) { # If we have no typename $PSTypeName = # the last non-variable uri segment, depluralized and trimming slashes will do ([uri]($RestVariable.Replace($originalUri, ''))).Segments[-1].TrimEnd('s').TrimEnd('/') $PSTypeName = $PSTypeName[0].TrimEnd('.') # then trim any trailing dot. if ($PSTypeName) { $PSTypeName = 'PSDevOps.Git' + $PSTypeName[0].Substring(0,1).ToUpper() + $PSTypeName[0].Substring(1) } $PSTypeName += 'PSDevOps.GitObject' } $apiOutput = $response | & { process { # process each object in the response. $in = $_ if ($Uri.Segments -and $uri.Segments[-1] -eq 'graphql') { # If it was from GraphQL if ($in.data) { $in.data # data is in .data } elseif ($in.errors) { # and errors need to be turned in PowerShell errors. foreach ($err in $in.errors) { $psCmdlet.WriteError([Management.Automation.ErrorRecord]::new( [Exception]::new($err.Message), 'Git.GraphQL.Error', 'InvalidOperation', $err )) } } } else { # If it wasn't from GraphQL, pass it on down. $in } } } | & { process { # One more step of the pipeline will unroll each of the values. if ($_ -is [string]) { return $_ } if ($null -ne $_.Count -and $_.Count -eq 0) { return } $in = $_ if ($PSTypeName -and # If we have a PSTypeName (to apply formatting) $in -isnot [Management.Automation.ErrorRecord] # and it is not an error (which we do not want to format) ) { $in.PSTypeNames.Clear() # then clear the existing typenames and decorate the object. foreach ($t in $PSTypeName) { $in.PSTypeNames.add($T) } } if ($in.Initialize -is [PSScriptMethod]) { $null = $in.Initialize.Invoke() } if ($Property) { foreach ($propKeyValue in $Property.GetEnumerator()) { if ($in.PSObject.Properties[$propKeyValue.Key]) { $in.PSObject.Properties.Remove($propKeyValue.Key) } $in.PSObject.Properties.Add($( if ($propKeyValue.Value -as [ScriptBlock[]]) { [PSScriptProperty]::new.Invoke(@($propKeyValue.Key) + $propKeyValue.Value) } else { [PSNoteProperty]::new($propKeyValue.Key, $propKeyValue.Value) })) } } if ($RemoveProperty) { foreach ($propToRemove in $RemoveProperty) { $in.PSObject.Properties.Remove($propToRemove) } } if ($DecorateProperty) { foreach ($kv in $DecorateProperty.GetEnumerator()) { if ($in.$($kv.Key)) { foreach ($v in $in.$($kv.Key)) { if ($null -eq $v -or -not $v.pstypenames) { continue } $v.pstypenames.clear() foreach ($tn in $kv.Value) { $v.pstypenames.add($tn) } } } } } return $in # output the object and we're done. } } #endregion Call Invoke-RestMethod # If we have a continuation token $paramCopy = @{} + $PSBoundParameters $invokeResults = [Collections.ArrayList]::new() & { if (-not $Page -and # If we weren't provided with a page number $responseHeaders.Link -match # but out .Link header '<(?<u>[^>]+)>; rel="next"' # has a 'next' uri ) { $apiOutput # output # Then recursively call yourself with the next uri $uri = $PSBoundParameters['Uri'] = ($matches.u) if ($ProgressPreference -ne 'silentlycontinue' -and $responseHeaders.Link -match '<(?<u>[^>]+)>; rel="last"' ) { $lastUri = [uri]$matches.u $nextPageNumber = [Web.HttpUtility]::ParseQueryString(([uri]$Uri).Query)["page"] -as [float] $lastPageNumber = [Web.HttpUtility]::ParseQueryString($lastUri.Query)["page"] -as [float] Write-Progress "$Method" "$uri [$nextPageNumber/$lastPageNumber]" -PercentComplete ( $nextPageNumber * 100 / $lastPageNumber ) -Id $gitProgressId } Invoke-GitHubRESTAPI @PSBoundParameters } else { # If we didn't have a next page, just output $apiOutput } } | & { process { $in = $_ if ($in) { $null = $invokeResults.Add($in) $in } } } if ($Method -eq 'Get') { if ($Cache -and -not $ContinuationToken) { $script:GitRequestCache[$uri] = $invokeResults.ToArray() } } else { $null = New-Event -SourceIdentifier "Invoke-GitRESTApi.$Method" -MessageData $( $paramCopy.Remove('PersonalAccessToken') $paramCopy+=@{Response = $response;Results = $invokeResults.ToArray() } [PSCustomObject]$paramCopy ) } if ($ProgressPreference -ne 'silentlycontinue') { Write-Progress "$Method" "$uri" -Completed -Id $gitProgressId } } } |