Public/Invoke-ConfluenceRequest.ps1
|
function Invoke-ConfluenceRequest { <# .SYNOPSIS Sends a request to the Confluence REST API using the current Confluence context. .DESCRIPTION Invoke-ConfluenceRequest is the transport used by every tcs.confluence REST command. It builds the endpoint from -Resource (or an explicit -URIPath) on the site set by Set-ConfluenceContext, appends query parameters and an optional CQL title search, sends the request with the stored credential and follows "next" pagination links for GET requests up to -MaxQueryPages pages (or all pages with -All). Behaviour that is applied automatically: - -Resource uses the API version given by -ApiVersion or, when that is not given, the version chosen with Set-ConfluenceContext -ApiVersion (v2 by default). - A -Search against the page or content collection is sent to the v1 CQL search endpoint /wiki/rest/api/content/search as cql=title = "..." (or title ~ "..." with wildcards * ?). Page searches also add type = page. CQL values are escaped (backslash, then quote). - A spaceKey query value is resolved to the numeric space ID and sent as the documented space-id parameter for v2 pages (keys are looked up once per session and cached), or added to the CQL query (space = "KEY" or space.id = 123) for searches. A key that cannot be resolved is reported as an error and no request is sent. - The short paths /pages, /content and /spaces are mapped to their full API paths. - A 429 Too Many Requests response is retried, honouring the Retry-After header. - Pagination links pointing to a different host are not followed, so the credential is only ever sent to the Confluence site in the context. - When -MaxQueryPages stops the paging while more results are available, a warning is written. HTTP error responses are reported as errors that include the status and the Confluence error titles; use -ErrorAction Stop to turn them into terminating errors. Returns an object with a Results array (all collected results) and a MultiPage flag. .PARAMETER Method The HTTP method: GET, POST, PUT or DELETE. .PARAMETER URIPath An explicit API path such as /wiki/api/v2/pages. Used as-is unless -Resource is given without -URIPath. .PARAMETER Resource A resource shortcut used to build the path: pages, content or spaces. .PARAMETER ApiVersion The API version used with -Resource: 2 (/wiki/api/v2) or 1 (/wiki/rest/api). When not given, the version from Set-ConfluenceContext -ApiVersion is used. .PARAMETER Id A resource ID appended to the path built from -Resource, for example a page ID. .PARAMETER RawPath Do not build the path from -Resource or map short paths; use -URIPath exactly as given. .PARAMETER Body The JSON request body for POST and PUT requests. .PARAMETER Query A hashtable of query-string parameters, for example @{ limit = 25; spaceKey = 'DOCS' }. Values are URL-encoded. The caller's hashtable is not changed. .PARAMETER MaxQueryPages The maximum number of result pages to retrieve for GET requests. Default 3. .PARAMETER All Follow the pagination links until every result has been retrieved (ignores -MaxQueryPages). .PARAMETER Search A page title to search for with CQL. Wildcards (* ?) produce a "title ~" search, otherwise an exact "title =" search. .EXAMPLE Invoke-ConfluenceRequest -Method GET -Resource pages -Query @{ spaceKey = 'DOCS'; limit = 50 } Returns up to three pages of results for the pages in the DOCS space (sent as space-id). .EXAMPLE Invoke-ConfluenceRequest -Method GET -Resource pages -Search 'Release*' -All Searches every page whose title matches "Release*" through /wiki/rest/api/content/search. .EXAMPLE Invoke-ConfluenceRequest -Method DELETE -Resource pages -Id 123456 -ErrorAction Stop Deletes page 123456 and throws if Confluence reports an error. .OUTPUTS System.Management.Automation.PSCustomObject with Results and MultiPage properties. #> [CmdletBinding()] [OutputType([pscustomobject])] param ( [Parameter(Mandatory = $true, HelpMessage = "The API method to use for the request. Valid values: 'GET', 'POST', 'PUT', 'DELETE'.")] [ValidateSet('GET', 'POST', 'PUT', 'DELETE')] [string]$Method, [Parameter(HelpMessage = 'Explicit URI path (takes precedence unless -Resource used without -URIPath).')] [string]$URIPath, [Parameter(HelpMessage = 'High-level resource shortcut.')] [ValidateSet('pages', 'content', 'spaces')] [string]$Resource, [Parameter(HelpMessage = 'API version for -Resource. 1=legacy, 2=new. Default: the context API version.')] [ValidateSet(1, 2)] [int]$ApiVersion, [Parameter(HelpMessage = 'Optional resource Id when using -Resource.')] [string]$Id, [Parameter(HelpMessage = 'Use -URIPath exactly as given.')] [switch]$RawPath, [Parameter(HelpMessage = 'The body of the request.')] [string]$Body, [Parameter(HelpMessage = 'Query-string parameters.')] [hashtable]$Query, [Parameter(HelpMessage = 'Maximum number of result pages to retrieve.')] [int16]$MaxQueryPages = 3, [Parameter(HelpMessage = 'Retrieve every result page.')] [switch]$All, [Parameter(HelpMessage = 'A page title to search for with CQL; wildcards (* ?) supported.')] [string]$Search ) $TelemetryArgs = @{ ModuleName = $MyInvocation.MyCommand.Module.Name ModuleVersion = [string]$MyInvocation.MyCommand.Module.Version CommandName = $MyInvocation.MyCommand.Name ExecutionID = [guid]::NewGuid().ToString() } Invoke-TelemetryCollection @TelemetryArgs -Stage Start -ClearTimer $telemetryFailed = $false try { $context = $script:ConfluenceContext # --- Path --- if (-not $RawPath -and -not $URIPath -and $Resource) { if (-not $PSBoundParameters.ContainsKey('ApiVersion')) { $ApiVersion = 2 if ($context -and $context.ApiVersion -eq 'v1') { $ApiVersion = 1 } } $resourcePaths = @{ 2 = @{ pages = '/wiki/api/v2/pages'; spaces = '/wiki/api/v2/spaces'; content = '/wiki/rest/api/content' } 1 = @{ pages = '/wiki/rest/api/content'; spaces = '/wiki/rest/api/space'; content = '/wiki/rest/api/content' } } $URIPath = $resourcePaths[$ApiVersion][$Resource] if ($Id) { $URIPath = "$URIPath/$Id" } } elseif (-not $URIPath) { Write-Error 'Provide either -URIPath or -Resource.' return } if ($null -eq $context -or $null -eq $script:ConfluenceCredential) { Write-Error 'No Confluence context is set. Run Set-ConfluenceContext first.' return } $path = $URIPath.Trim() if (-not $path.StartsWith('/')) { $path = "/$path" } if (-not $RawPath) { $shortPaths = @{ '/pages' = '/wiki/api/v2/pages'; '/content' = '/wiki/rest/api/content'; '/spaces' = '/wiki/api/v2/spaces' } if ($shortPaths.ContainsKey($path.ToLowerInvariant())) { $path = $shortPaths[$path.ToLowerInvariant()] } } # --- Query and CQL --- # Work on a copy so the caller's hashtable is not changed $queryValues = @{} if ($Query) { foreach ($key in @($Query.get_Keys())) { $queryValues[$key] = $Query[$key] } } $cqlClauses = New-Object -TypeName System.Collections.Generic.List[string] if (-not $RawPath -and -not [string]::IsNullOrWhiteSpace($Search)) { if ($path -match '^/wiki/api/v2/pages/?$') { $path = '/wiki/rest/api/content/search' $cqlClauses.Add('type = page') } elseif ($path -match '^/wiki/rest/api/content/?$') { $path = '/wiki/rest/api/content/search' } } $isCqlEndpoint = $path -match '^/wiki/rest/api/(content/)?search/?$' $isV2 = $path -like '/wiki/api/v2/*' $spaceValue = $null if ($queryValues.ContainsKey('spaceKey') -and ($isCqlEndpoint -or $isV2)) { $spaceValue = "$($queryValues['spaceKey'])" $queryValues.Remove('spaceKey') } if ($isV2 -and $queryValues.ContainsKey('spaceId')) { $spaceValue = "$($queryValues['spaceId'])" $queryValues.Remove('spaceId') } if (-not [string]::IsNullOrWhiteSpace($spaceValue)) { if ($isCqlEndpoint) { if ($spaceValue -match '^\d+$') { $cqlClauses.Add("space.id = $spaceValue") } else { $cqlClauses.Add('space = ' + (ConvertTo-ConfluenceCqlString -Value $spaceValue)) } } else { try { $queryValues['space-id'] = Resolve-ConfluenceSpaceId -Space $spaceValue -ErrorAction Stop } catch { Write-Error "Could not resolve space '$spaceValue' to a space ID; no request was sent. $($_.Exception.Message)" return } } } if (-not [string]::IsNullOrWhiteSpace($Search)) { $operator = if ($Search -match '[\*\?]') { '~' } else { '=' } $cqlClauses.Add("title $operator " + (ConvertTo-ConfluenceCqlString -Value $Search)) } if ($cqlClauses.Count -gt 0) { if ($queryValues.ContainsKey('cql') -and $queryValues['cql']) { $cqlClauses.Insert(0, "($($queryValues['cql']))") } $queryValues['cql'] = $cqlClauses -join ' AND ' Write-Verbose "CQL: $($queryValues['cql'])" } # --- Endpoint --- $baseUrl = "$($context.ConnectionBaseURL)".TrimEnd('/') $endpoint = $baseUrl + $path if ($queryValues.Count -gt 0) { # get_Keys(): a query parameter named "keys" would otherwise hide the Keys property $queryParts = foreach ($key in (@($queryValues.get_Keys()) | Sort-Object)) { '{0}={1}' -f [System.Net.WebUtility]::UrlEncode($key), [System.Net.WebUtility]::UrlEncode([string]$queryValues[$key]) } $endpoint = $endpoint + '?' + ($queryParts -join '&') } $baseUri = $null $endpointUri = $null if (-not [System.Uri]::TryCreate($baseUrl, [System.UriKind]::Absolute, [ref]$baseUri) -or -not [System.Uri]::TryCreate($endpoint, [System.UriKind]::Absolute, [ref]$endpointUri)) { Write-Error "The request URI '$endpoint' is not valid." return } # --- Send the request(s) --- $results = New-Object -TypeName System.Collections.Generic.List[object] $multiPage = $false $pageCount = 0 $currentEndpoint = $endpoint do { Write-Verbose ('[Page {0}] {1} {2}' -f ($pageCount + 1), $Method, $currentEndpoint) $requestParams = @{ Uri = $currentEndpoint; Method = $Method } if ($Body) { $requestParams.Body = $Body } try { $response = Invoke-ConfluenceHttpRequest @requestParams -ErrorAction Stop } catch { Write-Error "Request to $currentEndpoint failed. $($_.Exception.Message)" return } if ($response.StatusCode -lt 200 -or $response.StatusCode -gt 299) { $errorText = $null try { $errorContent = $response.Content | ConvertFrom-Json -ErrorAction Stop $errorText = (@($errorContent.errors | ForEach-Object { $_.title }) + @($errorContent.message) | Where-Object { $_ }) -join '; ' } catch { Write-Verbose 'The error response body is not JSON.' } Write-Error "Failed request. Status: $($response.StatusCode) $($response.StatusDescription) Errors: $errorText URL: $currentEndpoint" return } $payload = $null if (-not [string]::IsNullOrWhiteSpace($response.Content)) { try { $payload = $response.Content | ConvertFrom-Json -ErrorAction Stop } catch { Write-Error "Failed to parse JSON response from $currentEndpoint. $_" return } } $nextLink = $null if ($null -ne $payload) { $propertyNames = @($payload.PSObject.Properties.Name) if ($propertyNames -contains 'results') { foreach ($item in @($payload.results)) { if ($null -ne $item) { $results.Add($item) } } $multiPage = $true } elseif ($payload -is [array]) { foreach ($item in $payload) { $results.Add($item) } } else { $results.Add($payload) } foreach ($linkProperty in @('_links', 'links')) { if (-not $nextLink -and $propertyNames -contains $linkProperty -and $payload.$linkProperty -and @($payload.$linkProperty.PSObject.Properties.Name) -contains 'next') { $nextLink = [string]$payload.$linkProperty.next } } } $pageCount++ $currentEndpoint = $null if ($nextLink) { if ($nextLink -match '^[a-z][a-z0-9+.-]*://') { $nextUri = $null if ([System.Uri]::TryCreate($nextLink, [System.UriKind]::Absolute, [ref]$nextUri) -and $nextUri.Scheme -eq 'https' -and $nextUri.Authority -eq $baseUri.Authority) { $currentEndpoint = $nextLink } else { Write-Warning 'Ignoring a pagination link that points outside the Confluence site in the context.' } } else { if (-not $nextLink.StartsWith('/')) { $nextLink = "/$nextLink" } # v2 links are relative to the site root, v1 links to the /wiki context path if ($nextLink -notlike '/wiki/*') { $nextLink = "/wiki$nextLink" } $currentEndpoint = $baseUrl + $nextLink } } if ($Method -ne 'GET' -or $null -eq $currentEndpoint) { break } if (-not $All -and $pageCount -ge $MaxQueryPages) { Write-Warning "Stopped after $pageCount result page(s) (-MaxQueryPages $MaxQueryPages); more results are available. Use -All or a higher -MaxQueryPages to get them." break } } while ($true) Write-Verbose ('Completed request. Pages retrieved: {0}. Results: {1}' -f $pageCount, $results.Count) return [pscustomobject]@{ Results = $results.ToArray() MultiPage = $multiPage } } catch { $telemetryFailed = $true Invoke-TelemetryCollection @TelemetryArgs -Stage End -Failed $true -Exception $_ throw } finally { if (-not $telemetryFailed) { Invoke-TelemetryCollection @TelemetryArgs -Stage End } } } |