Uri.psm1
[CmdletBinding()] param() $baseName = [System.IO.Path]::GetFileNameWithoutExtension($PSCommandPath) $script:PSModuleInfo = Test-ModuleManifest -Path "$PSScriptRoot\$baseName.psd1" $script:PSModuleInfo | Format-List | Out-String -Stream | ForEach-Object { Write-Debug $_ } $scriptName = $script:PSModuleInfo.Name Write-Debug "[$scriptName] - Importing module" #region [functions] - [public] Write-Debug "[$scriptName] - [functions] - [public] - Processing folder" #region [functions] - [public] - [ConvertFrom-UriQueryString] Write-Debug "[$scriptName] - [functions] - [public] - [ConvertFrom-UriQueryString] - Importing" filter ConvertFrom-UriQueryString { <# .SYNOPSIS Parses a URL query string into a hashtable of parameters. .DESCRIPTION Takes a URI query string (the portion after the '?') and converts it into a hashtable where each key is a parameter name and the corresponding value is the parameter value. If the query string contains the same parameter multiple times, the resulting value will be an array of those values. Percent-encoded characters in the input are decoded back to their normal representation. .EXAMPLE ConvertFrom-UriQueryString -QueryString 'name=John%20Doe&age=30&age=40' Output: ```powershell Name Value ---- ----- name John Doe age {30, 40} ``` Parses the given query string and returns a hashtable where keys are parameter names and values are decoded parameter values. .EXAMPLE ConvertFrom-UriQueryString '?q=PowerShell%20URI' Output: ```powershell Name Value ---- ----- q PowerShell URI ``` Parses a query string that contains a single parameter and returns the corresponding value. .LINK https://psmodule.io/Uri/Functions/ConvertFrom-UriQueryString/ #> [OutputType([hashtable])] [CmdletBinding()] param( # The query string to parse. This can include the leading '?' or just the key-value pairs. # For example, both "?foo=bar&count=10" and "foo=bar&count=10" are acceptable. [Parameter(Position = 0, ValueFromPipeline)] [AllowNull()] [string] $Query ) # Early exit if $Query is null or empty. if ([string]::IsNullOrEmpty($Query)) { Write-Verbose 'Query string is null or empty.' return @{} } Write-Verbose "Parsing query string: $Query" # Remove leading '?' if present if ($Query.StartsWith('?')) { $Query = $Query.Substring(1) } if ([string]::IsNullOrEmpty($Query)) { return @{} # return empty hashtable if no query present } $result = @{} # Split by '&' to get each key=value pair $pairs = $Query.Split('&') foreach ($pair in $pairs) { if ([string]::IsNullOrWhiteSpace($pair)) { continue } # skip empty segments (e.g. "&&") $key, $val = $pair.Split('=', 2) # split into two parts at first '=' $key = [System.Uri]::UnescapeDataString($key) if ($null -ne $val) { $val = [System.Uri]::UnescapeDataString($val) } else { $val = '' # if no '=' present, treat value as empty string } if ($result.Contains($key)) { # If key already exists, convert value to array or add to existing array if ($result[$key] -is [System.Collections.IEnumerable] -and $result[$key] -isnot [string]) { # If already an array or collection, just add $result[$key] += $val } else { # If a single value exists, turn it into an array $result[$key] = @($result[$key], $val) } } else { $result[$key] = $val } } return $result } Write-Debug "[$scriptName] - [functions] - [public] - [ConvertFrom-UriQueryString] - Done" #endregion [functions] - [public] - [ConvertFrom-UriQueryString] #region [functions] - [public] - [ConvertTo-UriQueryString] Write-Debug "[$scriptName] - [functions] - [public] - [ConvertTo-UriQueryString] - Importing" filter ConvertTo-UriQueryString { <# .SYNOPSIS Converts a hashtable of parameters into a URL query string. .DESCRIPTION Takes a hashtable or dictionary of query parameters (keys and values) and constructs a properly encoded query string (e.g. "key1=value1&key2=value2"). By default, all keys and values are URL-encoded per RFC3986 rules to ensure the query string is valid. If a value is an array, multiple entries for the same key are generated. Use -NoEncoding to skip encoding. .EXAMPLE ConvertTo-UriQueryString -Query @{ foo = 'bar'; search = 'hello world'; ids = 1,2,3 } Output: ```powershell foo=bar&search=hello%20world&ids=1&ids=2&ids=3 ``` Converts the hashtable into a URL-encoded query string. Spaces are replaced with `%20`. .EXAMPLE ConvertTo-UriQueryString -Query @{ q = 'PowerShell'; verbose = $true } Output: ```powershell q=PowerShell&verbose=True ``` Converts the query parameters into a valid query string. .LINK https://psmodule.io/Uri/Functions/ConvertTo-UriQueryString #> [OutputType([string])] [CmdletBinding()] param( # The hashtable (or IDictionary) containing parameter names and values. Each key becomes a parameter name. # Values can be strings or other types convertible to string. If a value is an array or collection, each element # in it will result in a separate instance of that parameter name in the output string. [Parameter(Mandatory, Position = 0, ValueFromPipeline)] [System.Collections.IDictionary] $Query, # If set, keys and values are not URL-encoded. Use this only if the inputs are already encoded or consist solely # of characters safe in URLs. Without this, encoding is applied to escape special characters (e.g. spaces, &, =, #). [Parameter()] [switch] $NoEncoding ) Write-Verbose 'Converting hashtable to query string' Write-Verbose "NoEncoding: $NoEncoding" Write-Verbose "Query: $($Query | Out-String)" # Build the query string by iterating through each key-value pair $pairs = @() foreach ($key in $Query.Keys) { $name = if ($NoEncoding) { $key.ToString() } else { [System.Uri]::EscapeDataString($key.ToString()) } $value = $Query[$key] if ($null -eq $value) { # Null value -> include key with empty value $pairs += "$name=" } elseif ([System.Collections.IEnumerable].IsAssignableFrom($value.GetType()) -and -not ($value -is [string])) { # If the value is a collection (and not a string, since strings are IEnumerable of chars), handle each. foreach ($item in $value) { $itemValue = if ($NoEncoding) { "$item" } else { [System.Uri]::EscapeDataString( ("$item") ) } $pairs += "$name=$itemValue" } } else { # Single value (includes strings, numbers, booleans, etc.) $itemValue = if ($NoEncoding) { "$value" } else { [System.Uri]::EscapeDataString( ("$value") ) } $pairs += "$name=$itemValue" } } # Join all pairs with '&' and return return [string]::Join('&', $pairs) } Write-Debug "[$scriptName] - [functions] - [public] - [ConvertTo-UriQueryString] - Done" #endregion [functions] - [public] - [ConvertTo-UriQueryString] #region [functions] - [public] - [New-Uri] Write-Debug "[$scriptName] - [functions] - [public] - [New-Uri] - Importing" function New-Uri { <# .SYNOPSIS Constructs a URI from base, paths, query parameters, and fragment. .DESCRIPTION Builds a URI string or object by combining a base URI with additional path segments, query parameters, and an optional fragment. Ensures proper encoding (per [RFC3986](https://datatracker.ietf.org/doc/html/rfc3986)) and correct placement of '/' in paths, handles query parameter merging, and appends fragment identifiers. By default, returns a `[System.Uri]` object. .EXAMPLE # Simple usage with base and path New-Uri -BaseUri 'https://example.com' -Path 'products/item' Output: ```powershell AbsolutePath : /products/item AbsoluteUri : https://example.com/products/item LocalPath : /products/item Authority : example.com HostNameType : Dns IsDefaultPort : True IsFile : False IsLoopback : False PathAndQuery : /products/item Segments : {/, products/, item} IsUnc : False Host : example.com Port : 443 Query : Fragment : Scheme : https OriginalString : https://example.com:443/products/item DnsSafeHost : example.com IdnHost : example.com IsAbsoluteUri : True UserEscaped : False UserInfo : ``` Constructs a URI with the given base and path. .EXAMPLE # Adding query parameters via hashtable New-Uri 'https://example.com/api' -Path 'search' -Query @{ q = 'test search'; page = @(2, 4) } -AsUriBuilder Output: ```powershell Scheme : https UserName : Password : Host : example.com Port : 443 Path : /api/search Query : ?q=test%20search&page=2&page=4 Fragment : Uri : https://example.com/api/search?q=test search&page=2&page=4 ``` Adds query parameters to the URI, automatically encoding values. .EXAMPLE # Merging with existing query and using -MergeQueryParameter New-Uri 'https://example.com/data?year=2023' -Query @{ year = 2024; sort = 'asc' } -MergeQueryParameters -AsString Output: ```powershell https://example.com/data?sort=asc&year=2023&year=2024 ``` Merges new query parameters with the existing ones instead of replacing them. .OUTPUTS System.Uri .OUTPUTS System.UriBuilder .OUTPUTS string .NOTES - This function ensures URL encoding unless `-NoEncoding` is used. - Merging query parameters allows keeping multiple values for the same key. .LINK https://psmodule.io/Uri/Functions/New-Uri #> [Diagnostics.CodeAnalysis.SuppressMessageAttribute( 'PSUseShouldProcessForStateChangingFunctions', '', Scope = 'Function', Justification = 'Creates a new URI object without changing state' )] [OutputType(ParameterSetName = 'AsString', [string])] [OutputType(ParameterSetName = 'AsUri', [System.Uri])] [OutputType(ParameterSetName = 'AsUriBuilder', [System.UriBuilder])] [CmdletBinding(DefaultParameterSetName = 'AsUri')] param( # The base URI (string or [System.Uri]) to start from. [Parameter(Mandatory, Position = 0)] [Alias('Uri')] [object] $BaseUri, # One or more path segments to append to the base URI. [Parameter(Position = 1)] [string[]] $Path, # Query parameters to add to the URI. [Parameter()] [object] $Query, # A URI fragment to append (the part after '#'). [Parameter()] [string] $Fragment, # If set, allows duplicate query keys instead of overriding. [Parameter()] [switch] $MergeQueryParameters, # Outputs the resulting URI as a string. [Parameter(Mandatory, ParameterSetName = 'AsString')] [switch] $AsString, # Outputs the resulting URI as a System.UriBuilder object. [Parameter(Mandatory, ParameterSetName = 'AsUriBuilder')] [switch] $AsUriBuilder ) # Validate and prepare base URI try { $baseUriObj = if ($BaseUri -is [System.Uri]) { $BaseUri } else { [System.Uri]::new([string]$BaseUri) # may throw if invalid } } catch { throw "BaseUri '$BaseUri' is not a valid URI: $($_.Exception.Message)" } # Use UriBuilder for convenient manipulation $builder = [System.UriBuilder]::new($baseUriObj) # Handle path segments if ($Path) { $basePath = $builder.Path # e.g. "/" from 'https://example.com' $segments = @() # If a single element containing '/' was passed, split it into segments. if ($Path.Count -eq 1 -and $Path[0] -match '/') { $segments = $Path[0].Split('/') | Where-Object { $_ -ne '' } } else { $segments = $Path } # Normalize base path: ensure it ends with '/' if we need to append, except if base path is empty or just "/" if ([string]::IsNullOrEmpty($basePath) -or $basePath -eq '/') { $basePath = '' } elseif ($basePath[-1] -ne '/') { $basePath += '/' } # Build combined path string from segments, always encoding $encodedSegments = @() foreach ($seg in $segments) { $encodedSegments += [System.Uri]::EscapeDataString($seg) } $combinedPath = if ($basePath -ne '' -and $basePath -ne '/') { "$basePath$([string]::Join('/', $encodedSegments))" } else { '/' + [string]::Join('/', $encodedSegments) } # Preserve trailing slash if original single string ended with '/' if ($Path.Count -eq 1 -and $Path[0].EndsWith('/')) { $combinedPath += '/' } $builder.Path = $combinedPath } # Handle query parameters if ($null -ne $Query) { # Convert base URI's existing query to hashtable for merging (if any) $baseQueryParams = @{} if ($builder.Query -and $builder.Query.Length -gt 1) { # builder.Query returns string starting with '?' $existingQueryString = $builder.Query.Substring(1) # drop the '?' $baseQueryParams = ConvertFrom-UriQueryString -Query $existingQueryString } # Determine new query parameters from $Query input $newQueryParams = @{} if ($Query -is [hashtable] -or $Query -is [System.Collections.IDictionary]) { $newQueryParams = $Query } elseif ($Query -is [string]) { # Remove leading '?' if present $queryStr = $Query if ($queryStr.StartsWith('?')) { $queryStr = $queryStr.Substring(1) } if ($queryStr -ne '') { $newQueryParams = ConvertFrom-UriQueryString -Query $queryStr } } else { throw 'Query parameter must be a hashtable or query string (string).' } # Merge base and new query params $mergedParams = @{} foreach ($key in $baseQueryParams.Keys) { $mergedParams[$key] = $baseQueryParams[$key] } foreach ($key in $newQueryParams.Keys) { if ($MergeQueryParameters -and $mergedParams.Contains($key)) { # Merge same parameter: ensure value becomes an array of all values $existingVal = $mergedParams[$key] # Convert single existing value to array if not already if ($null -ne $existingVal -and $existingVal.GetType().IsArray -eq $false) { $existingVal = , $existingVal # wrap in array } $newVal = $newQueryParams[$key] if ($null -ne $newVal -and $newVal.GetType().IsArray -eq $false) { $newVal = , $newVal } # Combine arrays (or values) into one array $combinedVal = @() if ($existingVal) { $combinedVal += $existingVal } if ($newVal) { $combinedVal += $newVal } $mergedParams[$key] = $combinedVal } else { # New value overwrites or adds $mergedParams[$key] = $newQueryParams[$key] } } # Convert merged hashtable to query string (always encoding) $finalQueryString = ConvertTo-UriQueryString -Query $mergedParams $builder.Query = $finalQueryString # UriBuilder handles the '?' automatically } # Handle fragment if ($PSBoundParameters.ContainsKey('Fragment')) { if ([string]::IsNullOrEmpty($Fragment)) { $builder.Fragment = '' # remove any existing fragment } else { $builder.Fragment = [System.Uri]::EscapeDataString(($Fragment -replace '^#', '')) } } # (If fragment not provided, any fragment in base URI stays as is) # Output based on switches switch ($PSCmdlet.ParameterSetName) { 'AsUriBuilder' { return $builder } 'AsUri' { return $builder.Uri } 'AsString' { $uriString = "$($builder.Scheme)://$($builder.Host)$($builder.Uri.PathAndQuery)" if ($builder.Fragment) { $uriString += "$($builder.Fragment)" -replace '(%20| )', '-' } return $uriString } } } Write-Debug "[$scriptName] - [functions] - [public] - [New-Uri] - Done" #endregion [functions] - [public] - [New-Uri] Write-Debug "[$scriptName] - [functions] - [public] - Done" #endregion [functions] - [public] #region Member exporter $exports = @{ Alias = '*' Cmdlet = '' Function = @( 'ConvertFrom-UriQueryString' 'ConvertTo-UriQueryString' 'New-Uri' ) } Export-ModuleMember @exports #endregion Member exporter |