diff --git a/.github/linters/.jscpd.json b/.github/linters/.jscpd.json new file mode 100644 index 0000000..aa95628 --- /dev/null +++ b/.github/linters/.jscpd.json @@ -0,0 +1,10 @@ +{ + "threshold": 0, + "reporters": [ + "consoleFull" + ], + "ignore": [ + "**/tests/*" + ], + "absolute": true +} diff --git a/.github/workflows/Linter.yml b/.github/workflows/Linter.yml index d7650ae..a0ac5ce 100644 --- a/.github/workflows/Linter.yml +++ b/.github/workflows/Linter.yml @@ -29,3 +29,4 @@ jobs: GITHUB_TOKEN: ${{ github.token }} VALIDATE_MARKDOWN_PRETTIER: false VALIDATE_YAML_PRETTIER: false + VALIDATE_JSON_PRETTIER: false diff --git a/src/functions/public/ConvertFrom-UriQueryString.ps1 b/src/functions/public/ConvertFrom-UriQueryString.ps1 index 2ad05ca..e359245 100644 --- a/src/functions/public/ConvertFrom-UriQueryString.ps1 +++ b/src/functions/public/ConvertFrom-UriQueryString.ps1 @@ -11,7 +11,7 @@ back to their normal representation. .EXAMPLE - ConvertFrom-UriQueryString -QueryString 'name=John%20Doe&age=30&age=40' + ConvertFrom-UriQueryString -Query 'name=John%20Doe&age=30&age=40' Output: ```powershell @@ -25,7 +25,7 @@ values are decoded parameter values. .EXAMPLE - ConvertFrom-UriQueryString '?q=PowerShell%20URI' + '?q=PowerShell%20URI' | ConvertFrom-UriQueryString Output: ```powershell @@ -44,12 +44,11 @@ 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)] + [Parameter(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 @{} @@ -60,9 +59,6 @@ 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 diff --git a/src/functions/public/Get-Uri.ps1 b/src/functions/public/Get-Uri.ps1 new file mode 100644 index 0000000..537824d --- /dev/null +++ b/src/functions/public/Get-Uri.ps1 @@ -0,0 +1,134 @@ +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 + } + } + } +} diff --git a/src/functions/public/Test-Uri.ps1 b/src/functions/public/Test-Uri.ps1 new file mode 100644 index 0000000..47d64d7 --- /dev/null +++ b/src/functions/public/Test-Uri.ps1 @@ -0,0 +1,75 @@ +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) + } +} diff --git a/tests/Uri.Tests.ps1 b/tests/Uri.Tests.ps1 index 1753328..6cd03ec 100644 --- a/tests/Uri.Tests.ps1 +++ b/tests/Uri.Tests.ps1 @@ -1,5 +1,4 @@ Describe 'Uri' { - Context 'Function: ConvertFrom-UriQueryString' { It 'ConvertFrom-UriQueryString - returns empty hashtable for empty input' { @@ -27,8 +26,9 @@ It 'ConvertFrom-UriQueryString - removes leading question mark if present' { $result1 = ConvertFrom-UriQueryString -Query '?foo=bar' - $result2 = ConvertFrom-UriQueryString -Query 'foo=bar' $result1.foo | Should -Be 'bar' + + $result2 = ConvertFrom-UriQueryString -Query 'foo=bar' $result2.foo | Should -Be 'bar' } @@ -65,6 +65,68 @@ } } + Context 'Function: Test-Uri' { + $testUris = @( + # Valid URIs + @{ URI = 'http://example.com'; Expected = 'Valid' }, + @{ URI = 'https://sub.domain.com/path/to/resource'; Expected = 'Valid' }, + @{ URI = 'ftp://ftp.example.org/file.txt'; Expected = 'Valid' }, + @{ URI = 'http://example.com:8080/index.html'; Expected = 'Valid' }, + @{ URI = 'https://example.com/path/to/resource?query=123&another=test'; Expected = 'Valid' }, + @{ URI = 'https://example.com/path?encoded=%20%3C%3E%23%25'; Expected = 'Valid' }, + @{ URI = 'http://example.com/path#section1'; Expected = 'Valid' }, + @{ URI = 'mailto:user@example.com'; Expected = 'Valid' }, + @{ URI = 'tel:+1234567890'; Expected = 'Valid' }, + @{ URI = 'urn:isbn:0451450523'; Expected = 'Valid' }, + @{ URI = 'https://valid-url.com/resource?param=value&other=123'; Expected = 'Valid' }, + @{ URI = 'http://localhost:3000/api/test'; Expected = 'Valid' }, + @{ URI = 'http://192.168.1.1:8080/dashboard'; Expected = 'Valid' }, + @{ URI = 'https://secure-site.org/login?user=admin'; Expected = 'Valid' }, + @{ URI = 'https://example.com/valid/path/with/multiple/segments'; Expected = 'Valid' }, + @{ URI = 'http://user:pass@example.com:8080/path?query=test#fragment'; Expected = 'Valid' }, + @{ URI = 'ws://websocket.example.com/socket'; Expected = 'Valid' }, + @{ URI = 'wss://secure-websocket.com/path'; Expected = 'Valid' }, + @{ URI = 'htp://example.com'; Expected = 'Valid' }, + @{ URI = 'http://example.com/ space in path'; Expected = 'Valid' }, + @{ URI = "http://example.com/<>#{}|\^~[]``"; Expected = 'Valid' }, + @{ URI = 'http://example.com/%%invalid-encoding'; Expected = 'Valid' }, + @{ URI = 'http://example.com/path?query=%%invalid'; Expected = 'Valid' }, + @{ URI = 'http://-invalid-host.com'; Expected = 'Valid' }, + @{ URI = 'https://:invalid@hostname'; Expected = 'Valid' }, + @{ URI = 'ftp://missing/slash'; Expected = 'Valid' }, + @{ URI = 'http://incomplete-path?query='; Expected = 'Valid' }, + @{ URI = 'https://example.com/has|pipe'; Expected = 'Valid' } + + # Invalid URIs + @{ URI = 'http:///missing-host'; Expected = 'Invalid' }, + @{ URI = 'https:// example .com'; Expected = 'Invalid' }, + @{ URI = 'https://example.com:99999'; Expected = 'Invalid' }, + @{ URI = 'http://exa mple.com'; Expected = 'Invalid' }, + @{ URI = 'http://:8080/missing-host'; Expected = 'Invalid' }, + @{ URI = 'https://example.com:abcd'; Expected = 'Invalid' }, + @{ URI = 'https://ex ample.com/path'; Expected = 'Invalid' }, + @{ URI = 'http://::1/invalid-ipv6'; Expected = 'Invalid' }, + @{ URI = 'https://double..dots.com'; Expected = 'Invalid' }, + @{ URI = 'http://username:password@'; Expected = 'Invalid' }, + @{ URI = 'ws://invalid:websocket'; Expected = 'Invalid' } + ) + + It 'Test-Uri - Deems [] a [] URI' -ForEach $testUris { + $result = $URI | Test-Uri + switch ($Expected) { + 'Valid' { + $Valid = $true + $URI | Get-Uri | Should -Not -BeNullOrEmpty + } + 'Invalid' { $Valid = $false } + } + if ($result) { + $URI | Get-Uri | Out-String -Stream | ForEach-Object { Write-Verbose $_ -Verbose } + } + $result | Should -BeExactly $Valid + } + } + Context 'Function: New-Uri' { It 'New-Uri - constructs a URI with base and appended path' { @@ -117,4 +179,143 @@ $query | Should -Match 'q=hello%20world' } } + + Context 'Function: Get-Uri' { + Context 'Default Behavior (returns a [System.Uri] object)' { + It 'Should return a valid System.Uri when given a URI with scheme' { + $result = Get-Uri -Uri 'https://example.com/path' + $result | Out-String -Stream | ForEach-Object { Write-Verbose $_ -Verbose } + $result | Should -BeOfType 'System.Uri' + $result.Scheme | Should -Be 'https' + $result.Host | Should -Be 'example.com' + } + + It 'Should add default scheme (http) when missing' { + $result = Get-Uri -Uri 'example.com/path' + $result | Out-String -Stream | ForEach-Object { Write-Verbose $_ -Verbose } + $result | Should -BeOfType 'System.Uri' + $result.Scheme | Should -Be 'http' + $result.Host | Should -Be 'example.com' + } + } + + Context 'Switch: -AsUriBuilder' { + + It 'Should return a System.UriBuilder object' { + $result = Get-Uri -Uri 'https://example.com/path' -AsUriBuilder + $result | Out-String -Stream | ForEach-Object { Write-Verbose $_ -Verbose } + $result | Should -BeOfType 'System.UriBuilder' + $result.Uri.Scheme | Should -Be 'https' + $result.Uri.Host | Should -Be 'example.com' + } + } + + Context 'Switch: -AsString' { + + It 'Should return a normalized URI string' { + # Example with uppercase scheme and percent-encoded characters + $inputUri = 'HTTP://Example.com/%7Euser/path/page.html' + $result = Get-Uri -Uri $inputUri -AsString + $result | Out-String -Stream | ForEach-Object { Write-Verbose $_ -Verbose } + $expected = 'http://example.com/~user/path/page.html' + $result | Should -Be $expected + } + } + + Context 'Error Handling' { + + It 'Should throw an error for an invalid URI' { + { Get-Uri -Uri 'http://??' } | Should -Throw + } + + It 'Should throw an error when both -AsUriBuilder and -AsString are provided' { + { Get-Uri -Uri 'https://example.com' -AsUriBuilder -AsString } | Should -Throw + } + + It 'Should throw an error when an empty URI string is provided' { + { Get-Uri -Uri '' } | Should -Throw + } + } + + Context 'Pipeline Input' { + + It 'Should accept pipeline input and return a valid [System.Uri]' { + 'example.com/path' | Get-Uri | ForEach-Object { + $_ | Out-String -Stream | ForEach-Object { Write-Verbose $_ -Verbose } + $_ | Should -BeOfType 'System.Uri' + $_.Scheme | Should -Be 'http' + } + } + + It 'Should return a valid System.Uri when given a URI with scheme' { + $result = Get-Uri -Uri 'https://example.com/path' + $result | Out-String -Stream | ForEach-Object { Write-Verbose $_ -Verbose } + $result | Should -BeOfType 'System.Uri' + $result.Scheme | Should -Be 'https' + $result.Host | Should -Be 'example.com' + } + + It 'Should add default scheme (http) when missing' { + $result = Get-Uri -Uri 'example.com/path' + $result | Out-String -Stream | ForEach-Object { Write-Verbose $_ -Verbose } + $result | Should -BeOfType 'System.Uri' + $result.Scheme | Should -Be 'http' + $result.Host | Should -Be 'example.com' + } + } + + Context 'Edge Cases' { + It 'Should handle URIs with ports' { + $result = Get-Uri -Uri 'http://example.com:8080' + $result | Out-String -Stream | ForEach-Object { Write-Verbose $_ -Verbose } + $result | Should -BeOfType 'System.Uri' + $result.Port | Should -Be 8080 + } + + It 'Should handle URIs with query strings' { + $result = Get-Uri -Uri 'https://example.com?query=test' + $result | Out-String -Stream | ForEach-Object { Write-Verbose $_ -Verbose } + $result | Should -BeOfType 'System.Uri' + $result.Query | Should -Be '?query=test' + } + + It 'Should handle URIs with multiple query strings' { + $result = Get-Uri -Uri 'https://example.com?query=test&sort=asc&page=1' + $result | Out-String -Stream | ForEach-Object { Write-Verbose $_ -Verbose } + $result | Should -BeOfType 'System.Uri' + $result.Query | Should -Be '?query=test&sort=asc&page=1' + } + + It 'Should handle URIs with query strings and fragments' { + $result = Get-Uri -Uri 'https://example.com?query=test#section1' + $result | Out-String -Stream | ForEach-Object { Write-Verbose $_ -Verbose } + $result | Should -BeOfType 'System.Uri' + $result.Query | Should -Be '?query=test' + $result.Fragment | Should -Be '#section1' + } + + # Uri + same key query string and fragment + It 'Should handle URIs with query strings and fragments' { + $result = Get-Uri -Uri 'https://example.com?include=test&include=dev&include=prod#section1' + $result | Out-String -Stream | ForEach-Object { Write-Verbose $_ -Verbose } + $result | Should -BeOfType 'System.Uri' + $result.Query | Should -Be '?include=test&include=dev&include=prod' + $result.Fragment | Should -Be '#section1' + } + + It 'Should handle URIs with fragments' { + $result = Get-Uri -Uri 'https://example.com#section1' + $result | Out-String -Stream | ForEach-Object { Write-Verbose $_ -Verbose } + $result | Should -BeOfType 'System.Uri' + $result.Fragment | Should -Be '#section1' + } + + It 'Should handle IPv6 addresses' { + $result = Get-Uri -Uri 'http://[::1]' + $result | Out-String -Stream | ForEach-Object { Write-Verbose $_ -Verbose } + $result | Should -BeOfType 'System.Uri' + $result.Host | Should -Be '[::1]' + } + } + } }