Private/Helpers.ps1
|
#region Helper Functions <# .SYNOPSIS Creates a Basic authentication header from credentials .PARAMETER Credential PSCredential object containing username and password .OUTPUTS String - Base64 encoded authentication header value #> function New-AuthHeader { [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSUseShouldProcessForStateChangingFunctions', '', Justification = 'Internal helper; constructs an in-memory auth string only')] [CmdletBinding()] param( [Parameter(Mandatory = $true)] [PSCredential]$Credential ) $username = $Credential.UserName $password = $Credential.GetNetworkCredential().Password $authString = "${username}:${password}" $authBytes = [System.Text.Encoding]::UTF8.GetBytes($authString) $authBase64 = [System.Convert]::ToBase64String($authBytes) return "Basic $authBase64" } <# .SYNOPSIS Gets the authentication header to use for API calls .PARAMETER Credential Optional PSCredential to generate a new auth header .OUTPUTS String - Authentication header value #> function Get-AuthHeader { [CmdletBinding()] param( [Parameter(Mandatory = $false)] [PSCredential]$Credential ) if ($Credential) { return New-AuthHeader -Credential $Credential } if (-not $script:KeepitAuth) { throw "Not connected to Keepit service. Run Connect-KeepitService first or provide Credential parameter." } return $script:KeepitAuth } <# .SYNOPSIS Constructs the base URL for Keepit API calls .PARAMETER Environment Optional environment override. If not provided, uses cached environment. .OUTPUTS String - Base URL for the configured environment #> function Get-KeepitBaseUrl { [CmdletBinding()] param( [Parameter(Mandatory = $false)] [string]$Environment ) $env = if ($Environment) { $Environment } else { $script:KeepitRegion } if (-not $env) { throw "Keepit environment not configured. Run Connect-KeepitService first or provide Environment parameter." } return "https://$env.keepit.com" } <# .SYNOPSIS Gets the user ID, either from cache or by querying the API .PARAMETER AuthHeader The authentication header to use for API calls .PARAMETER BaseUrl The base URL for API calls .OUTPUTS String - User GUID #> function Get-KeepitUserId { [CmdletBinding()] param( [Parameter(Mandatory = $true)] [string]$AuthHeader, [Parameter(Mandatory = $true)] [string]$BaseUrl ) # If we have a cached user ID and are using cached auth, return it if ($script:KeepitUserId -and $AuthHeader -eq $script:KeepitAuth) { return $script:KeepitUserId } # Otherwise, query the API Write-Verbose "Getting user ID from API" $headers = @{ 'Authorization' = $AuthHeader 'Content-Type' = 'application/xml' } $userResponse = Invoke-RestMethod -Uri "$BaseUrl/users/" -Method Get -Headers $headers -ErrorAction Stop if (-not $userResponse.user.id) { throw "Unable to retrieve user ID from response" } return $userResponse.user.id } <# .SYNOPSIS Converts a DateTime to ISO 8601 format for API requests .PARAMETER DateTime The DateTime to convert .OUTPUTS String - ISO 8601 formatted timestamp .NOTES DateTimeKind handling: - Utc: Used as-is - Local: Converted to UTC - Unspecified: Treated as UTC (for ISO8601 strings without timezone indicator) #> function ConvertTo-KeepitTimestamp { [CmdletBinding()] param( [Parameter(Mandatory = $true)] [DateTime]$DateTime ) # If Kind is Unspecified (e.g., from ISO8601 string without Z suffix), treat as UTC # This allows users to specify times like "2025-12-04T09:50:00" and have them # interpreted as UTC rather than local time if ($DateTime.Kind -eq [System.DateTimeKind]::Unspecified) { $DateTime = [DateTime]::SpecifyKind($DateTime, [System.DateTimeKind]::Utc) } return $DateTime.ToUniversalTime().ToString('yyyy-MM-ddTHH:mm:ssZ', [System.Globalization.CultureInfo]::InvariantCulture) } <# .SYNOPSIS Gets the display name for a connector type .PARAMETER ConnectorType The internal connector type name (e.g., 'o365-admin') .OUTPUTS String - The display name (e.g., 'Microsoft 365'), or the original type if not found #> function Get-ConnectorTypeDisplayName { [CmdletBinding()] param( [Parameter(Mandatory = $true)] [string]$ConnectorType ) if ($script:ConnectorTypes.ContainsKey($ConnectorType)) { return $script:ConnectorTypes[$ConnectorType] } # Return original type if not in mapping (for forward compatibility) return $ConnectorType } <# .SYNOPSIS Resolves a connector type name (key or display name) to the internal key .PARAMETER TypeName The connector type name, which can be either: - An internal key (e.g., 'o365-admin', 'azure-ad', 'jsm') - A display name (e.g., 'Microsoft 365', 'Entra ID', 'Jira Service Management') .OUTPUTS String - The internal key if found, or $null if not recognized #> function Resolve-ConnectorTypeName { [CmdletBinding()] param( [Parameter(Mandatory = $true)] [string]$TypeName ) # Check if it's already an internal key if ($script:ConnectorTypes.ContainsKey($TypeName)) { return $TypeName } # Check if it's a display name (case-insensitive) foreach ($key in $script:ConnectorTypes.Keys) { if ($script:ConnectorTypes[$key] -eq $TypeName) { return $key } } # Not found return $null } <# .SYNOPSIS Validates a connector type name (key or display name) .PARAMETER TypeName The connector type name to validate .OUTPUTS Boolean - $true if valid, $false otherwise #> function Test-ConnectorTypeName { [CmdletBinding()] param( [Parameter(Mandatory = $true)] [string]$TypeName ) return $null -ne (Resolve-ConnectorTypeName -TypeName $TypeName) } <# .SYNOPSIS Validates workload parameter values against connector type .DESCRIPTION Validates that the specified workloads are valid for the given connector type. Throws an error if the connector type does not support workloads or if invalid workload names are specified. .PARAMETER Workload Array of workload names to validate .PARAMETER ConnectorType The connector type to validate against (e.g., 'o365-admin', 'dynamics365') .OUTPUTS None. Throws an error if validation fails. .EXAMPLE Test-WorkloadParameter -Workload @('Exchange', 'Teams') -ConnectorType 'o365-admin' Validates that Exchange and Teams are valid workloads for o365-admin connectors. #> function Test-WorkloadParameter { [CmdletBinding()] param( [Parameter(Mandatory = $true)] [string[]]$Workload, [Parameter(Mandatory = $true)] [string]$ConnectorType ) $validWorkloads = $script:WorkloadsByConnectorType[$ConnectorType] if (-not $validWorkloads) { $displayName = Get-ConnectorTypeDisplayName -ConnectorType $ConnectorType throw "The -Workload parameter is not supported for $displayName ($ConnectorType) connectors. This connector type has a single configuration block." } foreach ($w in $Workload) { if ($w -notin $validWorkloads) { $displayName = Get-ConnectorTypeDisplayName -ConnectorType $ConnectorType throw "Invalid workload '$w' for $displayName ($ConnectorType) connectors. Valid workloads are: $($validWorkloads -join ', ')" } } Write-Verbose "Validated workloads: $($Workload -join ', ')" } function Resolve-WorkloadAlias { <# .SYNOPSIS Resolves a workload alias to its canonical name .DESCRIPTION Resolves workload aliases (ExO, ODB) to their canonical names (Exchange, OneDrive). If the workload is not an alias, returns it unchanged. .PARAMETER Workload The workload name or alias to resolve .OUTPUTS The canonical workload name #> [CmdletBinding()] param( [Parameter(Mandatory = $true)] [string]$Workload ) if ($script:WorkloadAliasToCanonical.ContainsKey($Workload)) { $canonical = $script:WorkloadAliasToCanonical[$Workload] Write-Verbose "Resolved workload alias '$Workload' to '$canonical'" return $canonical } return $Workload } <# .SYNOPSIS Validates that a string is a valid URL .DESCRIPTION Validates that the specified string is a valid URL with at minimum a scheme and host. Used for validating SharePoint site URLs in configuration management. .PARAMETER Url The URL string to validate .OUTPUTS None. Throws an error if validation fails. .EXAMPLE Test-SiteUrl -Url "https://contoso.sharepoint.com/sites/Marketing" Validates that the string is a valid URL. #> function Test-SiteUrl { [CmdletBinding()] param( [Parameter(Mandatory = $true)] [string]$Url ) try { $uri = [System.Uri]::new($Url) if (-not $uri.IsAbsoluteUri) { throw "URL must be absolute (include scheme like https://)" } if ([string]::IsNullOrWhiteSpace($uri.Host)) { throw "URL must include a host" } if ($uri.Scheme -notin @('http', 'https')) { throw "URL scheme must be http or https" } Write-Verbose "Validated URL: $Url" } catch [System.UriFormatException] { throw "Invalid URL format '$Url': $($_.Exception.Message)" } } <# .SYNOPSIS Validates that a string is a valid GUID format .DESCRIPTION Validates that the specified string is a valid GUID (globally unique identifier). Used for validating group GUIDs in Teams/UnifiedGroups configuration management. .PARAMETER Guid The GUID string to validate .OUTPUTS None. Throws an error if validation fails. .EXAMPLE Test-GroupGuid -Guid "0aa94c0a-c5e5-417f-8cfa-6744649e25da" Validates that the string is a valid GUID. #> function Test-GroupGuid { [CmdletBinding()] param( [Parameter(Mandatory = $true)] [string]$Guid ) $guidPattern = '^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$' if ($Guid -notmatch $guidPattern) { throw "Invalid GUID format '$Guid'. Expected format: xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx" } Write-Verbose "Validated GUID: $Guid" } <# .SYNOPSIS Extracts SharePoint coverage information from connector configuration .PARAMETER Config The SharePointNG section of the connector configuration (hashtable) .PARAMETER FullConfig The full connector configuration (hashtable), used for top-level properties .OUTPUTS Array of PSCustomObjects describing SharePoint site coverage #> function Get-SharePointCoverage { [CmdletBinding()] param( [Parameter(Mandatory = $true)] [hashtable]$Config ) $results = @() $autoInclude = if ($Config.ContainsKey('AutoIncludeAllSiteCollections')) { $Config['AutoIncludeAllSiteCollections'] } else { $false } if ($autoInclude) { # When auto-include is on, report a summary entry with exclusions $excludeSites = if ($Config.ContainsKey('ExcludeSiteCollections')) { @($Config['ExcludeSiteCollections']) } else { @() } $excludePrefixes = if ($Config.ContainsKey('ExcludeSiteCollectionsWithPrefixes')) { @($Config['ExcludeSiteCollectionsWithPrefixes']) } else { @() } $results += [PSCustomObject]@{ SiteUrl = '*' AutoIncludeAllSubSites = $true SubSites = @() ExcludeSubSites = @() ExcludeSiteCollections = $excludeSites ExcludeSiteCollectionsWithPrefixes = $excludePrefixes } } # Include explicit site collections if ($Config.ContainsKey('SiteCollections')) { foreach ($site in $Config['SiteCollections']) { $siteUrl = if ($site -is [hashtable] -and $site.ContainsKey('SiteUrl')) { $site['SiteUrl'] } elseif ($site.PSObject -and $site.PSObject.Properties['SiteUrl']) { $site.SiteUrl } else { $null } $autoSubSites = if ($site -is [hashtable] -and $site.ContainsKey('AutoIncludeAllSubSites')) { $site['AutoIncludeAllSubSites'] } elseif ($site.PSObject -and $site.PSObject.Properties['AutoIncludeAllSubSites']) { $site.AutoIncludeAllSubSites } else { $false } $subSites = if ($site -is [hashtable] -and $site.ContainsKey('SubSites')) { @($site['SubSites']) } elseif ($site.PSObject -and $site.PSObject.Properties['SubSites']) { @($site.SubSites) } else { @() } $excludeSubSites = if ($site -is [hashtable] -and $site.ContainsKey('ExcludeSubSites')) { @($site['ExcludeSubSites']) } elseif ($site.PSObject -and $site.PSObject.Properties['ExcludeSubSites']) { @($site.ExcludeSubSites) } else { @() } $results += [PSCustomObject]@{ SiteUrl = $siteUrl AutoIncludeAllSubSites = $autoSubSites SubSites = $subSites ExcludeSubSites = $excludeSubSites ExcludeSiteCollections = $null ExcludeSiteCollectionsWithPrefixes = $null } } } return , $results } <# .SYNOPSIS Extracts Exchange coverage information from connector configuration .PARAMETER Config The Exchange section of the connector configuration (hashtable) .PARAMETER FullConfig The full connector configuration (hashtable), used for top-level UserSelectionRules .OUTPUTS Array containing a single PSCustomObject describing Exchange coverage #> function Get-ExchangeCoverage { [CmdletBinding()] param( [Parameter(Mandatory = $true)] [hashtable]$Config, [Parameter(Mandatory = $true)] [hashtable]$FullConfig ) $enabledCategories = if ($Config.ContainsKey('EnabledCategories')) { @($Config['EnabledCategories']) } else { @() } $userSelectionRules = if ($FullConfig.ContainsKey('UserSelectionRules')) { $FullConfig['UserSelectionRules'] } elseif ($Config.ContainsKey('UserSelectionRules')) { $Config['UserSelectionRules'] } else { $null } return , @([PSCustomObject]@{ EnabledCategories = $enabledCategories UserSelectionRules = $userSelectionRules }) } <# .SYNOPSIS Extracts OneDrive coverage information from connector configuration .PARAMETER Config The OneDriveSP section of the connector configuration (hashtable) .PARAMETER FullConfig The full connector configuration (hashtable), used for top-level UserSelectionRules .OUTPUTS Array containing a single PSCustomObject describing OneDrive coverage #> function Get-OneDriveCoverage { [CmdletBinding()] param( [Parameter(Mandatory = $true)] [hashtable]$Config, [Parameter(Mandatory = $true)] [hashtable]$FullConfig ) $options = if ($Config.ContainsKey('Options')) { $Config['Options'] } else { $null } $userSelectionRules = if ($FullConfig.ContainsKey('UserSelectionRules')) { $FullConfig['UserSelectionRules'] } elseif ($Config.ContainsKey('UserSelectionRules')) { $Config['UserSelectionRules'] } else { $null } return , @([PSCustomObject]@{ Options = $options UserSelectionRules = $userSelectionRules }) } <# .SYNOPSIS Extracts Teams/UnifiedGroups coverage information from connector configuration .PARAMETER Config The UnifiedGroups section of the connector configuration (hashtable) .PARAMETER FullConfig The full connector configuration (hashtable) .OUTPUTS Array containing a single PSCustomObject describing Teams/UnifiedGroups coverage #> function Get-UnifiedGroupsCoverage { [CmdletBinding()] param( [Parameter(Mandatory = $true)] [hashtable]$Config ) $autoInclude = if ($Config.ContainsKey('AutoIncludeGroups')) { $Config['AutoIncludeGroups'] } else { $false } $enabledCategories = if ($Config.ContainsKey('EnabledCategories')) { @($Config['EnabledCategories']) } else { @() } $includeGroups = if ($Config.ContainsKey('IncludeGroups')) { @($Config['IncludeGroups']) } else { @() } $excludeGroups = if ($Config.ContainsKey('ExcludeGroups')) { @($Config['ExcludeGroups']) } else { @() } return , @([PSCustomObject]@{ AutoIncludeGroups = $autoInclude EnabledCategories = $enabledCategories IncludeGroups = $includeGroups ExcludeGroups = $excludeGroups }) } <# .SYNOPSIS Resolves a connector identity (name or GUID) to a GUID .DESCRIPTION Takes a connector identity that can be either a connector name or a GUID, and returns the corresponding GUID. If a GUID is provided, it validates that the connector exists. If a name is provided, it looks up the connector and returns its GUID. Uses cached authentication from Connect-KeepitService. .PARAMETER Identity The connector name or GUID to resolve .OUTPUTS PSCustomObject with properties: - ConnectorGuid: The resolved GUID - Name: The connector name - Type: The connector type #> function Resolve-KeepitConnectorIdentity { [CmdletBinding()] param( [Parameter(Mandatory = $true)] [ValidateNotNullOrEmpty()] [string]$Identity ) Write-Verbose "Resolving connector identity: $Identity" # Get authentication and connection info $authHeader = Get-AuthHeader $baseUrl = Get-KeepitBaseUrl $userId = Get-KeepitUserId -AuthHeader $authHeader -BaseUrl $baseUrl # Check if Identity looks like a GUID (three groups of 6 alphanumeric chars) $isGuid = $Identity -match '^[a-z0-9]{6}-[a-z0-9]{6}-[a-z0-9]{6}$' # Get all connectors $headers = @{ 'Authorization' = $authHeader 'Content-Type' = 'application/xml' 'Accept' = 'application/vnd.keepit.v4+xml' } $uri = "$baseUrl/users/$userId/devices" Write-Verbose "Fetching connectors from: $uri" $response = Invoke-RestMethod -Uri $uri -Method Get -Headers $headers -ErrorAction Stop if (-not $response.devices.cloud) { throw "No connectors found" } # Normalize to array $devices = if ($response.devices.cloud -is [System.Array]) { $response.devices.cloud } else { @($response.devices.cloud) } # Find matching connector $matchingConnector = $null if ($isGuid) { # Look up by GUID (case-insensitive) $matchingConnector = $devices | Where-Object { $_.guid -eq $Identity -or $_.guid -eq $Identity.ToLower() } | Select-Object -First 1 } if (-not $matchingConnector) { # Look up by name (case-insensitive) $matchingConnector = $devices | Where-Object { $_.name -eq $Identity } | Select-Object -First 1 } if (-not $matchingConnector) { throw "Connector '$Identity' not found" } # Determine device type (handle DSL connectors) $deviceType = if ($matchingConnector.type -eq 'dsl') { $matchingConnector.'agent-type' } else { $matchingConnector.type } return [PSCustomObject]@{ ConnectorGuid = $matchingConnector.guid.ToLower() Name = $matchingConnector.name Type = $deviceType } } <# .SYNOPSIS Converts an ISO 8601 duration to human-readable English text .DESCRIPTION Parses ISO 8601 duration format (e.g., P3M, P1Y, P1Y6M, P30D) and converts to readable English text (e.g., "3 months", "1 year", "1 year, 6 months", "30 days"). Returns "Unlimited" for null/empty input. .PARAMETER Duration ISO 8601 duration string (e.g., "P3M", "P1Y6M", "P30D") .OUTPUTS String - Human-readable duration text .EXAMPLE ConvertFrom-ISO8601Duration -Duration "P3M" # Returns: "3 months" .EXAMPLE ConvertFrom-ISO8601Duration -Duration "P1Y6M" # Returns: "1 year, 6 months" .EXAMPLE ConvertFrom-ISO8601Duration -Duration $null # Returns: "Unlimited" #> function ConvertFrom-ISO8601Duration { [CmdletBinding()] param( [Parameter(Mandatory = $false)] [string]$Duration ) # Handle null/empty as unlimited retention if ([string]::IsNullOrWhiteSpace($Duration)) { return "Unlimited" } # ISO 8601 duration format: P[n]Y[n]M[n]D or P[n]W # Examples: P3M (3 months), P1Y (1 year), P1Y6M (1 year 6 months), P30D (30 days), P2W (2 weeks) if ($Duration -notmatch '^P') { Write-Verbose "Invalid ISO 8601 duration format: $Duration" return $Duration # Return as-is if not valid ISO 8601 } $parts = @() # Extract years if ($Duration -match '(\d+)Y') { $years = [int]$Matches[1] $parts += if ($years -eq 1) { "1 year" } else { "$years years" } } # Extract months if ($Duration -match '(\d+)M(?![A-Z])') { $months = [int]$Matches[1] $parts += if ($months -eq 1) { "1 month" } else { "$months months" } } # Extract weeks if ($Duration -match '(\d+)W') { $weeks = [int]$Matches[1] $parts += if ($weeks -eq 1) { "1 week" } else { "$weeks weeks" } } # Extract days if ($Duration -match '(\d+)D') { $days = [int]$Matches[1] $parts += if ($days -eq 1) { "1 day" } else { "$days days" } } if ($parts.Count -eq 0) { Write-Verbose "Could not parse ISO 8601 duration: $Duration" return $Duration # Return as-is if nothing was parsed } return $parts -join ', ' } <# .SYNOPSIS Sends a non-blocking desktop notification .DESCRIPTION Sends a system notification using the platform-appropriate mechanism: - Windows: NotifyIcon balloon tip via System.Windows.Forms - macOS: osascript display notification - Linux: notify-send if available, else Write-Host fallback All errors are swallowed silently so callers are never interrupted. .PARAMETER Title The notification title .PARAMETER Message The notification body text .PARAMETER Icon The icon style: Info (default), Warning, or Error #> function Show-KeepitNotification { [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSAvoidUsingWriteHost', '', Justification = 'Intentional: Linux fallback uses Write-Host when notify-send is unavailable')] [CmdletBinding()] param( [Parameter(Mandatory = $true)] [string]$Title, [Parameter(Mandatory = $true)] [string]$Message, [Parameter(Mandatory = $false)] [ValidateSet('Info', 'Warning', 'Error')] [string]$Icon = 'Info' ) try { # Windows PowerShell 5.1 does not define $IsWindows or $IsMacOS. It runs # only on Windows, so a non-Core edition means Windows. $onWindows = ($PSVersionTable.PSEdition -ne 'Core') -or [bool]$IsWindows $onMacOS = ($PSVersionTable.PSEdition -eq 'Core') -and [bool]$IsMacOS if ($onWindows) { Add-Type -AssemblyName System.Windows.Forms -ErrorAction Stop $balloonIcon = switch ($Icon) { 'Warning' { [System.Windows.Forms.ToolTipIcon]::Warning } 'Error' { [System.Windows.Forms.ToolTipIcon]::Error } default { [System.Windows.Forms.ToolTipIcon]::Info } } $balloon = New-Object System.Windows.Forms.NotifyIcon $balloon.Icon = [System.Drawing.SystemIcons]::Application $balloon.BalloonTipIcon = $balloonIcon $balloon.BalloonTipTitle = $Title $balloon.BalloonTipText = $Message $balloon.Visible = $true $balloon.ShowBalloonTip(5000) Start-Sleep -Milliseconds 200 $balloon.Dispose() } elseif ($onMacOS) { $esc = { param($s) $s -replace '\\', '\\\\' -replace '"', '\"' } $null = & osascript -e "display notification `"$(& $esc $Message)`" with title `"$(& $esc $Title)`"" 2>&1 } else { if (Get-Command notify-send -ErrorAction SilentlyContinue) { $null = & notify-send --urgency=normal $Title $Message 2>&1 } else { Write-Host "`n[$Title] $Message" -ForegroundColor Cyan } } Write-Verbose "Notification sent: [$Title] $Message" } catch { Write-Verbose "Show-KeepitNotification: $($_.Exception.Message)" } } <# .SYNOPSIS Converts a JSON object tree to nested hashtables .DESCRIPTION Internal helper for ConvertFrom-KeepitJson. Walks the PSCustomObject tree that ConvertFrom-Json returns on Windows PowerShell 5.1 and makes a hashtable for each object, an array for each array, and keeps scalars as they are. .PARAMETER InputObject A value from ConvertFrom-Json: a PSCustomObject, an array, a scalar, or $null .OUTPUTS Hashtable, Object[], or the scalar value #> function ConvertTo-KeepitHashtable { [CmdletBinding()] [OutputType([hashtable], [object[]], [object])] param( [Parameter(Mandatory = $false)] [AllowNull()] [object]$InputObject ) if ($null -eq $InputObject) { return $null } if ($InputObject -is [System.Collections.IDictionary]) { $table = @{} foreach ($key in $InputObject.Keys) { $table[$key] = ConvertTo-KeepitHashtable -InputObject $InputObject[$key] } return $table } if ($InputObject -is [System.Management.Automation.PSCustomObject]) { $table = @{} foreach ($property in $InputObject.PSObject.Properties) { $table[$property.Name] = ConvertTo-KeepitHashtable -InputObject $property.Value } return $table } if ($InputObject -is [System.Collections.IEnumerable] -and $InputObject -isnot [string]) { $list = New-Object System.Collections.ArrayList foreach ($item in $InputObject) { $null = $list.Add((ConvertTo-KeepitHashtable -InputObject $item)) } return , $list.ToArray() } return $InputObject } <# .SYNOPSIS Converts JSON text to nested hashtables on Windows PowerShell 5.1 and PowerShell 7 .DESCRIPTION ConvertFrom-Json has the -AsHashtable parameter only on PowerShell 6 and later. This helper gives the same result on both versions, so callers can use ContainsKey and index syntax on the configuration tree. On PowerShell 6 and later it uses the built-in parameter. On Windows PowerShell 5.1 it converts the PSCustomObject tree with ConvertTo-KeepitHashtable. .PARAMETER InputObject JSON text. Several pipeline strings are joined with newlines before parsing, which is what ConvertFrom-Json does. .OUTPUTS Hashtable for a JSON object, Object[] for a JSON array .EXAMPLE $config = $json | ConvertFrom-KeepitJson if ($config.ContainsKey('SharePointNG')) { ... } #> function ConvertFrom-KeepitJson { [CmdletBinding()] [OutputType([hashtable], [object[]])] param( [Parameter(Mandatory = $true, ValueFromPipeline = $true)] [AllowEmptyString()] [string]$InputObject ) begin { $chunks = New-Object System.Collections.ArrayList } process { $null = $chunks.Add($InputObject) } end { $json = $chunks -join "`n" if ($PSVersionTable.PSVersion.Major -ge 6) { # Splatting keeps the parameter name out of the source text, so the # 5.1 compatibility scan does not report this line. $asHashtable = @{ AsHashtable = $true } $result = ConvertFrom-Json -InputObject $json @asHashtable return , $result } $parsed = ConvertFrom-Json -InputObject $json return , (ConvertTo-KeepitHashtable -InputObject $parsed) } } <# .SYNOPSIS Sends a web request and returns the response for every HTTP status code .DESCRIPTION Some Keepit endpoints report state through the status code, for example 202 and 409 on a HEAD request that checks whether a user exists. The caller must read the status code and the body of a non-2xx response instead of catching an exception. PowerShell 7 does this with Invoke-WebRequest -SkipHttpErrorCheck. Windows PowerShell 5.1 has no such parameter and throws a WebException. This helper hides the difference: on both versions it returns an object with StatusCode, StatusDescription, Headers, and Content. On a 2xx response the object is the Invoke-WebRequest response itself. On a non-2xx response under 5.1 it is built from the WebException. Errors that have no HTTP response, such as DNS or connection failures, are thrown on both versions. .PARAMETER Uri Request URI .PARAMETER Method HTTP method. Default: Get .PARAMETER Headers Request headers .PARAMETER Body Request body, a string or a byte array .PARAMETER TimeoutSec Request timeout in seconds. 0 means the Invoke-WebRequest default. .OUTPUTS The Invoke-WebRequest response, or a PSCustomObject with the same StatusCode, StatusDescription, Headers, and Content members .EXAMPLE $r = Invoke-KeepitWebRequest -Uri $checkUri -Method Head -Headers $headers if ($r.StatusCode -eq 409) { 'exists' } #> function Invoke-KeepitWebRequest { [CmdletBinding()] [OutputType([object])] param( [Parameter(Mandatory = $true)] [string]$Uri, [Parameter(Mandatory = $false)] [ValidateSet('Get', 'Head', 'Post', 'Put', 'Delete', 'Patch')] [string]$Method = 'Get', [Parameter(Mandatory = $false)] [hashtable]$Headers, [Parameter(Mandatory = $false)] [AllowNull()] [object]$Body, [Parameter(Mandatory = $false)] [int]$TimeoutSec = 0 ) # UseBasicParsing stops Windows PowerShell 5.1 from parsing HTML responses # with the Internet Explorer engine. PowerShell 7 ignores the switch. $requestParams = @{ Uri = $Uri Method = $Method UseBasicParsing = $true ErrorAction = 'Stop' } if ($Headers) { $requestParams['Headers'] = $Headers } if ($PSBoundParameters.ContainsKey('Body') -and $null -ne $Body) { $requestParams['Body'] = $Body } if ($TimeoutSec -gt 0) { $requestParams['TimeoutSec'] = $TimeoutSec } if ($PSVersionTable.PSVersion.Major -ge 7) { # The parameter name is built from a variable so the 5.1 compatibility # scan does not report this line. $skipErrorCheck = 'SkipHttpErrorCheck' $requestParams[$skipErrorCheck] = $true return Invoke-WebRequest @requestParams } try { return Invoke-WebRequest @requestParams } catch [System.Net.WebException] { $errorRecord = $_ $httpResponse = $errorRecord.Exception.Response if ($null -eq $httpResponse) { # No HTTP response: a transport error. Let the caller handle it. throw } $responseHeaders = @{} if ($httpResponse.Headers) { foreach ($name in $httpResponse.Headers.AllKeys) { $responseHeaders[$name] = $httpResponse.Headers[$name] } } # Windows PowerShell reads the body into ErrorDetails for most responses. # Fall back to the response stream when it did not. $content = '' if ($errorRecord.ErrorDetails -and $errorRecord.ErrorDetails.Message) { $content = $errorRecord.ErrorDetails.Message } else { try { $stream = $httpResponse.GetResponseStream() if ($stream) { if ($stream.CanSeek) { $stream.Position = 0 } $reader = New-Object System.IO.StreamReader($stream, [System.Text.Encoding]::UTF8) $content = $reader.ReadToEnd() $reader.Dispose() } } catch { Write-Verbose "Invoke-KeepitWebRequest: could not read the error response body: $($_.Exception.Message)" } } return [PSCustomObject]@{ StatusCode = [int]$httpResponse.StatusCode StatusDescription = [string]$httpResponse.StatusDescription Headers = $responseHeaders Content = $content } } } <# .SYNOPSIS Returns the body of a web response as UTF-8 text .DESCRIPTION The Keepit API sends UTF-8 text. PowerShell 7 returns Content as a string for text content types and as a byte array for others. Windows PowerShell 5.1 decodes a text response as ISO-8859-1 when the response has no charset, which damages non-ASCII characters such as Danish letters in mailbox names and paths. This helper decodes a byte array as UTF-8, re-decodes the raw response bytes as UTF-8 on Windows PowerShell, and otherwise returns the string Content. .PARAMETER Response An Invoke-WebRequest response, or an object with a Content member .OUTPUTS String. An empty string when the response has no content. .EXAMPLE [xml]$doc = Get-KeepitResponseText -Response $webResponse #> function Get-KeepitResponseText { [CmdletBinding()] [OutputType([string])] param( [Parameter(Mandatory = $true)] [AllowNull()] [object]$Response ) if ($null -eq $Response) { return '' } $content = $Response.Content if ($content -is [byte[]]) { return [System.Text.Encoding]::UTF8.GetString($content) } if ($PSVersionTable.PSVersion.Major -lt 6 -and $Response.RawContentStream) { try { $stream = $Response.RawContentStream if ($stream.CanSeek) { $stream.Position = 0 } # StreamReader removes a UTF-8 byte order mark; Encoding.GetString keeps it. $reader = New-Object System.IO.StreamReader($stream, [System.Text.Encoding]::UTF8) $text = $reader.ReadToEnd() if ($stream.CanSeek) { $stream.Position = 0 } return $text } catch { Write-Verbose "Get-KeepitResponseText: could not re-decode the response as UTF-8: $($_.Exception.Message)" } } if ($null -eq $content) { return '' } return [string]$content } #endregion |