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 -Query '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
        '?q=PowerShell%20URI' | ConvertFrom-UriQueryString

        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(ValueFromPipeline)]
        [AllowNull()]
        [string] $Query
    )

    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)
    }

    $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, $value = $pair.Split('=', 2)  # split into two parts at first '='
        $key = [System.Uri]::UnescapeDataString($key)
        if ($null -ne $value) {
            $value = [System.Uri]::UnescapeDataString($value)
        } else {
            $value = ''  # 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] += $value
            } else {
                # If a single value exists, turn it into an array
                $result[$key] = @($result[$key], $value)
            }
        } else {
            $result[$key] = $value
        }
    }
    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.

        .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
    )

    Write-Verbose 'Converting hashtable to query string with URL encoding'
    Write-Verbose "Query: $($Query | Out-String)"
    # Build the query string by iterating through each key-value pair
    $pairs = @()
    foreach ($key in $Query.Keys) {
        # URL-encode the key.
        $name = [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])) {
            foreach ($item in $value) {
                $itemValue = [System.Uri]::EscapeDataString("$item")
                $pairs += "$name=$itemValue"
            }
        } else {
            # Single value (includes strings, numbers, booleans, etc.)
            $itemValue = [System.Uri]::EscapeDataString("$value")
            $pairs += "$name=$itemValue"
        }
    }
    return [string]::Join('&', $pairs)
}
Write-Debug "[$scriptName] - [functions] - [public] - [ConvertTo-UriQueryString] - Done"
#endregion [functions] - [public] - [ConvertTo-UriQueryString]
#region [functions] - [public] - [Get-Uri]
Write-Debug "[$scriptName] - [functions] - [public] - [Get-Uri] - Importing"
function Get-Uri {
    <#
        .SYNOPSIS
        Converts a string into a System.Uri, System.UriBuilder, or a normalized URI string.

        .DESCRIPTION
        The Get-Uri function processes a string and attempts to convert it into a valid URI.
        It supports three output formats: a System.Uri object, a System.UriBuilder object,
        or a normalized absolute URI string. If no scheme is present, "http://" is prefixed
        to ensure a valid URI. The function enforces mutual exclusivity between the output
        format parameters.

        .EXAMPLE
        Get-Uri -Uri 'example.com'

        Output:
        ```powershell
        AbsolutePath : /
        AbsoluteUri : http://example.com/
        LocalPath : /
        Authority : example.com
        HostNameType : Dns
        IsDefaultPort : True
        IsFile : False
        IsLoopback : False
        PathAndQuery : /
        Segments : {/}
        IsUnc : False
        Host : example.com
        Port : 80
        Query :
        Fragment :
        Scheme : http
        OriginalString : http://example.com
        DnsSafeHost : example.com
        IdnHost : example.com
        IsAbsoluteUri : True
        UserEscaped : False
        UserInfo :
        ```

        Converts 'example.com' into a normalized absolute URI string.

        .EXAMPLE
        Get-Uri -Uri 'https://example.com/path' -AsUriBuilder

        Output:
        ```powershell
        Scheme : https
        UserName :
        Password :
        Host : example.com
        Port : 443
        Path : /path
        Query :
        Fragment :
        Uri : https://example.com/path
        ```

        Returns a [System.UriBuilder] object for the specified URI.

        .EXAMPLE
        'example.com/path' | Get-Uri -AsString

        Output:
        ```powershell
        http://example.com/path
        ```

        Returns a [string] with the full absolute URI.

        .LINK
        https://psmodule.io/Uri/Functions/Get-Uri
    #>

    [Diagnostics.CodeAnalysis.SuppressMessageAttribute(
        'PSReviewUnusedParameter', 'AsString',
        Scope = 'Function',
        Justification = 'Present for parameter sets'
    )]
    [Diagnostics.CodeAnalysis.SuppressMessageAttribute(
        'PSReviewUnusedParameter', 'AsUriBuilder',
        Scope = 'Function',
        Justification = 'Present for parameter sets'
    )]
    [OutputType(ParameterSetName = 'UriBuilder', [System.UriBuilder])]
    [OutputType(ParameterSetName = 'String', [string])]
    [OutputType(ParameterSetName = 'AsUri', [System.Uri])]
    [CmdletBinding(DefaultParameterSetName = 'AsUri')]
    param(
        # The string representation of the URI to be processed.
        [Parameter(Mandatory, Position = 0, ValueFromPipeline)]
        [string] $Uri,

        # Outputs a System.UriBuilder object.
        [Parameter(Mandatory, ParameterSetName = 'AsUriBuilder')]
        [switch] $AsUriBuilder,

        # Outputs the URI as a normalized string.
        [Parameter(Mandatory, ParameterSetName = 'AsString')]
        [switch] $AsString
    )

    process {
        $inputString = $Uri.Trim()
        if ([string]::IsNullOrWhiteSpace($inputString)) {
            throw 'The Uri parameter cannot be null or empty.'
        }

        # Attempt to create a System.Uri (absolute) from the string
        $uriObject = $null
        $success = [System.Uri]::TryCreate($inputString, [System.UriKind]::Absolute, [ref]$uriObject)
        if (-not $success) {
            # If no scheme present, try adding "http://"
            if ($inputString -notmatch '^[A-Za-z][A-Za-z0-9+.-]*:') {
                $success = [System.Uri]::TryCreate("http://$inputString", [System.UriKind]::Absolute, [ref]$uriObject)
            }
            if (-not $success) {
                throw "The provided value '$Uri' cannot be converted to a valid URI."
            }
        }

        switch ($PSCmdlet.ParameterSetName) {
            'AsUriBuilder' {
                return ([System.UriBuilder]::new($uriObject))
            }
            'AsString' {
                return ($uriObject.GetComponents([System.UriComponents]::AbsoluteUri, [System.UriFormat]::SafeUnescaped))
            }
            'AsUri' {
                return $uriObject
            }
        }
    }
}
Write-Debug "[$scriptName] - [functions] - [public] - [Get-Uri] - Done"
#endregion [functions] - [public] - [Get-Uri]
#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
        - 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]
#region [functions] - [public] - [Test-Uri]
Write-Debug "[$scriptName] - [functions] - [public] - [Test-Uri] - Importing"
function Test-Uri {
    <#
        .SYNOPSIS
        Validates whether a given string is a valid URI.

        .DESCRIPTION
        The Test-Uri function checks whether a given string is a valid URI. By default, it enforces absolute URIs.
        If the `-AllowRelative` switch is specified, it allows both absolute and relative URIs.

        .EXAMPLE
        Test-Uri -Uri "https://example.com"

        Output:
        ```powershell
        True
        ```

        Checks if `https://example.com` is a valid URI, returning `$true`.

        .EXAMPLE
        Test-Uri -Uri "invalid-uri"

        Output:
        ```powershell
        False
        ```

        Returns `$false` for an invalid URI string.

        .EXAMPLE
        "https://example.com", "invalid-uri" | Test-Uri

        Output:
        ```powershell
        True
        False
        ```

        Accepts input from the pipeline and validates multiple URIs.

        .OUTPUTS
        [System.Boolean]

        .NOTES
        Returns `$true` if the input string is a valid URI, otherwise returns `$false`.

        .LINK
        https://psmodule.io/Uri/Functions/Test-Uri
    #>

    [OutputType([bool])]
    [CmdletBinding()]
    param(
        # Accept one or more URI strings from parameter or pipeline.
        [Parameter(Mandatory, ValueFromPipeline)]
        [string] $Uri,

        # If specified, allow valid relative URIs.
        [Parameter()]
        [switch] $AllowRelative
    )

    process {
        # If -AllowRelative is set, try to create a URI using RelativeOrAbsolute.
        # Otherwise, enforce an Absolute URI.
        $uriKind = if ($AllowRelative) {
            [System.UriKind]::RelativeOrAbsolute
        } else {
            [System.UriKind]::Absolute
        }

        # Try to create the URI. The out parameter is not used.
        $dummy = $null
        [System.Uri]::TryCreate($Uri, $uriKind, [ref]$dummy)
    }
}
Write-Debug "[$scriptName] - [functions] - [public] - [Test-Uri] - Done"
#endregion [functions] - [public] - [Test-Uri]
Write-Debug "[$scriptName] - [functions] - [public] - Done"
#endregion [functions] - [public]

#region Member exporter
$exports = @{
    Alias    = '*'
    Cmdlet   = ''
    Function = @(
        'ConvertFrom-UriQueryString'
        'ConvertTo-UriQueryString'
        'Get-Uri'
        'New-Uri'
        'Test-Uri'
    )
}
Export-ModuleMember @exports
#endregion Member exporter