diff --git a/docs/Tutorials/Configuration.md b/docs/Tutorials/Configuration.md index f6a8720fa..42992a322 100644 --- a/docs/Tutorials/Configuration.md +++ b/docs/Tutorials/Configuration.md @@ -101,3 +101,6 @@ A "path" like `Server.Ssl.Protocols` looks like the below in the file: | Web.ContentType | Define expected Content Types for certain Routes | [link](../Routes/Utilities/ContentTypes) | | Web.ErrorPages | Defines configuration for custom error pages | [link](../Routes/Utilities/ErrorPages) | | Web.Static | Defines configuration for static content, such as caching | [link](../Routes/Utilities/StaticContent) | +| Web.Conversion.JsonToHashTable | Set the default Json conversion to HashTable if true otherwise PSCustomObject (Default: True for PSCore and False for Windows Powershell )| | +| Web.Conversion.YamlToHashTable | Set the default Yaml conversion to HashTable if true otherwise PSCustomObject (Default: True)| | + diff --git a/examples/server.psd1 b/examples/server.psd1 index ec5b15edf..42f4ea69b 100644 --- a/examples/server.psd1 +++ b/examples/server.psd1 @@ -1,5 +1,5 @@ @{ - Web = @{ + Web = @{ Static = @{ Defaults = @( 'index.html', @@ -19,6 +19,7 @@ Default = 'application/html' Routes = @{ '/john' = 'application/json' + '/auth' = 'application/json' } } Compression = @{ @@ -27,7 +28,14 @@ OpenApi = @{ UsePodeYamlInternal = $true } + + Conversion = @{ + JsonToHashTable = $true + XmlToHashTable = $true + YamlToHashTable = $true + } } + Server = @{ FileMonitor = @{ Enable = $false diff --git a/src/Listener/Pode.csproj b/src/Listener/Pode.csproj index f481af38e..f106b194e 100644 --- a/src/Listener/Pode.csproj +++ b/src/Listener/Pode.csproj @@ -20,4 +20,14 @@ $(TargetFrameworks);net10.0 + + + + + + + + + + diff --git a/src/Listener/PodeConverter.cs b/src/Listener/PodeConverter.cs new file mode 100644 index 000000000..cbdeff6c7 --- /dev/null +++ b/src/Listener/PodeConverter.cs @@ -0,0 +1,336 @@ +#if !NETSTANDARD2_0 + +using System; +using System.Collections; +using System.Collections.Concurrent; +using System.Collections.Generic; +using System.Collections.Specialized; +using System.Linq; +using System.Management.Automation; +using System.Text; +using System.Text.RegularExpressions; + +namespace Pode +{ + public static class PodeConverter + { + public static string ToYaml(object inputObject, int depth = 10, int nestingLevel = 0, bool noNewLine = false) + { + if (depth < nestingLevel) + { + return string.Empty; + } + + if (inputObject == null) + { + return inputObject is Array ? "[]" : string.Empty; + } + + string padding = new string(' ', nestingLevel * 2); + string type = inputObject.GetType().Name; + + if (inputObject is Array) + { + type = inputObject.GetType().BaseType.Name; + } + + if (type != "String") + { + if (inputObject.GetType().IsGenericType && inputObject.GetType().GetGenericTypeDefinition() == typeof(ConcurrentDictionary<,>)) + { + type = "hashtable"; + } + else if (inputObject is OrderedDictionary) + { + type = "ordereddictionary"; + } + else if (inputObject is IList) + { + type = "array"; + } + else if (inputObject is Hashtable) + { + type = "hashtable"; + } + else if (inputObject.GetType().IsGenericType && inputObject.GetType().GetGenericTypeDefinition() == typeof(PodeOrderedConcurrentDictionary<,>)) + { + type = "ordereddictionary"; + } + } + + StringBuilder output = new StringBuilder(); + switch (type.ToLower()) + { + case "string": + string stringValue = inputObject.ToString(); + if ((stringValue.Contains("\r\n") || stringValue.Length > 80) && !stringValue.StartsWith("http")) + { + StringBuilder multiline = new StringBuilder("|" + Environment.NewLine); + string[] items = stringValue.Split(new[] { "\n" }, StringSplitOptions.None); + foreach (string item in items) + { + string workingString = item.Replace("\r", ""); + int length = workingString.Length; + int index = 0; + int wrap = 80; + + while (index < length) + { + int breakpoint = wrap; + bool linebreak = false; + + if ((length - index) > wrap) + { + int lastSpaceIndex = workingString.LastIndexOf(' ', index + wrap, wrap); + if (lastSpaceIndex != -1) + { + breakpoint = lastSpaceIndex - index; + } + else + { + linebreak = true; + breakpoint--; + } + } + else + { + breakpoint = length - index; + } + + multiline.Append(padding).Append(workingString.Substring(index, breakpoint).Trim()); + if (linebreak) + { + multiline.Append('\\'); + } + + index += breakpoint; + if (index < length) + { + multiline.Append(Environment.NewLine); + } + } + + multiline.Append(Environment.NewLine); + } + + output.Append(multiline.ToString().TrimEnd()); + } + else + { + // Define the set of special characters that require wrapping in quotes + var specialChars = new HashSet { '#', '[', ']', '@', '{', '}', '!', '*' }; + + // Check if the first character is in the special characters set + if (stringValue.Length > 0 && specialChars.Contains(stringValue[0])) + { + output.AppendFormat("'{0}'", stringValue.Replace("'", "''")); + } + else + { + output.Append(stringValue); + } + } + break; + + case "hashtable": + case "ordereddictionary": + if (inputObject is IDictionary dict && dict.Count > 0) + { + int index = 0; + StringBuilder stringBuilder = new StringBuilder(); + foreach (DictionaryEntry item in dict) + { + string newPadding = noNewLine && index++ == 0 ? string.Empty : Environment.NewLine + padding; + stringBuilder.Append(newPadding).Append(item.Key).Append(": "); + if (item.Value is ValueType) + { + if (item.Value is bool) + { + stringBuilder.Append(item.Value.ToString().ToLower()); + } + else + { + stringBuilder.Append(item.Value); + } + } + else + { + int increment = item.Value is string ? 2 : 1; + stringBuilder.Append(ToYaml(item.Value, depth, nestingLevel + increment)); + } + } + output.Append(stringBuilder); + } + else + { + output.Append("{}"); + } + break; + + case "pscustomobject": + if (inputObject is PSObject psObject && psObject.Properties.Any()) + { + int index = 0; + StringBuilder stringBuilder = new StringBuilder(); + foreach (PSPropertyInfo item in psObject.Properties) + { + string newPadding = noNewLine && index++ == 0 ? string.Empty : Environment.NewLine + padding; + stringBuilder.Append(newPadding).Append(item.Name).Append(": "); + if (item.Value is ValueType) + { + if (item.Value is bool) + { + stringBuilder.Append(item.Value.ToString().ToLower()); + } + else + { + stringBuilder.Append(item.Value); + } + } + else + { + int increment = item.Value is string ? 2 : 1; + stringBuilder.Append(ToYaml(item.Value, depth, nestingLevel + increment)); + } + } + output.Append(stringBuilder); + } + else + { + output.Append("{}"); + } + break; + + case "array": + IList list = inputObject as IList; + if (list != null && list.Count == 0) + { + output.Append("[]"); + } + else + { + StringBuilder arrayStringBuilder = new StringBuilder(); + int arrayIndex = 0; + foreach (object item in list) + { + string newPadding = noNewLine && arrayIndex++ == 0 ? string.Empty : Environment.NewLine + padding; + arrayStringBuilder.Append(newPadding).Append("- ").Append(ToYaml(item, depth, nestingLevel + 1, true).Trim('\'')); + } + output.Append(arrayStringBuilder.ToString()); + } + break; + + default: + output.AppendFormat("'{0}'", inputObject); + break; + } + + return output.ToString(); + } + + + public static OrderedDictionary FromYaml(string inputObject) + { + // Split the YAML input into lines + string[] lines = inputObject.Split(new[] { "\n" }, StringSplitOptions.None); + // Initialize the main hashtable as an ordered hashtable + var hashtable = new OrderedDictionary(); + // Stacks to keep track of current hashtable and indentation levels + var stack = new Stack(); + var indentStack = new Stack(); + stack.Push(hashtable); + indentStack.Push(-1); + + // Regex patterns for matching lines + var keyValuePattern = new Regex(@"^(\s*)([^:]+):\s*(.*)$"); + var arrayPattern = new Regex(@"^\[(.*)\]$"); + var multilinePattern = new Regex(@"^\s+(.*)$"); + var listItemPattern = new Regex(@"^\s*-\s*(.*)$"); + + // Variables to keep track of current key and hashtable + OrderedDictionary current = hashtable; + string currentKey = null; + + // Iterate over each line of the YAML input + for (int i = 0; i < lines.Length; i++) + { + string line = lines[i]; + Match match = keyValuePattern.Match(line); + if (match.Success) + { + int indent = match.Groups[1].Length; // Indentation level + string key = match.Groups[2].Value.Trim(); // Key + string value = match.Groups[3].Value.Trim(); // Value + + // Pop the stack if the current indentation level is less than or equal to the previous level + while ((int)indentStack.Peek() >= indent) + { + indentStack.Pop(); + stack.Pop(); + } + + // Peek the current hashtable from the stack + current = (OrderedDictionary)stack.Peek(); + currentKey = key; + + // If value is empty, create a new nested ordered hashtable + if (string.IsNullOrEmpty(value)) + { + var newDict = new OrderedDictionary(); + current[key] = newDict; + stack.Push(newDict); + indentStack.Push(indent); + } + // Handle inline arrays + else if (arrayPattern.IsMatch(value)) + { + current[key] = arrayPattern.Match(value).Groups[1].Value.Split(new[] { ", " }, StringSplitOptions.None); + } + // Handle multiline strings + else if (value == "|") + { + value = string.Empty; + while (++i < lines.Length && multilinePattern.IsMatch(lines[i])) + { + value += multilinePattern.Match(lines[i]).Groups[1].Value + "\n"; + } + i--; + current[key] = value.TrimEnd(); + } + // Convert and assign the value + else + { + current[key] = ConvertPodeStringToType(value); + } + } + // Handle list items + else if (listItemPattern.IsMatch(line)) + { + string value = listItemPattern.Match(line).Groups[1].Value.Trim(); + if (!(current[currentKey] is ArrayList list)) + { + list = new ArrayList(); + current[currentKey] = list; + } + list.Add(ConvertPodeStringToType(value)); + } + } + + return hashtable; + } + + + private static object ConvertPodeStringToType(string value) + { + if (bool.TryParse(value, out bool boolResult)) return boolResult; + if (int.TryParse(value, out int intResult)) return intResult; + if (double.TryParse(value, out double doubleResult)) return doubleResult; + if (DateTime.TryParse(value, out DateTime dateResult)) return dateResult; + return value; + } + } + + + +} +#endif \ No newline at end of file diff --git a/src/Listener/PodeOrderedConcurrentDictionary.cs b/src/Listener/PodeOrderedConcurrentDictionary.cs new file mode 100644 index 000000000..623e0ebcc --- /dev/null +++ b/src/Listener/PodeOrderedConcurrentDictionary.cs @@ -0,0 +1,206 @@ +using System; +using System.Collections.Concurrent; +using System.Collections.Generic; + +namespace Pode +{ + public class PodeOrderedConcurrentDictionary + { + private readonly ConcurrentDictionary _concurrentDictionary; + private readonly SortedDictionary _sortedDictionary; + private readonly object _lock = new object(); + + // Constructor accepting a custom key comparer + public PodeOrderedConcurrentDictionary(IComparer keyComparer = null) + { + _concurrentDictionary = new ConcurrentDictionary(); + _sortedDictionary = new SortedDictionary(keyComparer ?? Comparer.Default); + } + + // Adds or updates an item in the dictionary + public void AddOrUpdate(TKey key, TValue value) + { + _concurrentDictionary[key] = value; // Thread-safe operation for the ConcurrentDictionary + + lock (_lock) // Lock to ensure the SortedDictionary remains thread-safe + { + _sortedDictionary[key] = value; + } + } + + // Adds or updates an item in the dictionary using value factories + public TValue AddOrUpdate(TKey key, Func addValueFactory, Func updateValueFactory) + { + TValue result = _concurrentDictionary.AddOrUpdate(key, addValueFactory, updateValueFactory); + + lock (_lock) + { + _sortedDictionary[key] = result; + } + + return result; + } + + // Attempts to add a key-value pair if the key does not already exist + public bool TryAdd(TKey key, TValue value) + { + // Try to add to the ConcurrentDictionary first + if (_concurrentDictionary.TryAdd(key, value)) + { + // If successful, add to the SortedDictionary inside a lock + lock (_lock) + { + _sortedDictionary[key] = value; + } + return true; + } + + return false; // If the key already exists, return false + } + + // Tries to get a value by key + public bool TryGetValue(TKey key, out TValue value) + { + return _concurrentDictionary.TryGetValue(key, out value); + } + + // Tries to remove a key-value pair + public bool TryRemove(TKey key, out TValue value) + { + var removed = _concurrentDictionary.TryRemove(key, out value); + + if (removed) + { + lock (_lock) + { + _sortedDictionary.Remove(key); + } + } + + return removed; + } + + // Attempts to update the value of the specified key if it matches a specified comparison value + public bool TryUpdate(TKey key, TValue newValue, TValue comparisonValue) + { + if (_concurrentDictionary.TryUpdate(key, newValue, comparisonValue)) + { + lock (_lock) + { + _sortedDictionary[key] = newValue; + } + return true; + } + + return false; + } + + // Gets or adds a value by key + public TValue GetOrAdd(TKey key, TValue value) + { + TValue result = _concurrentDictionary.GetOrAdd(key, value); + + lock (_lock) + { + if (!_sortedDictionary.ContainsKey(key)) + { + _sortedDictionary[key] = result; + } + } + + return result; + } + + public TValue GetOrAdd(TKey key, Func valueFactory) + { + TValue result = _concurrentDictionary.GetOrAdd(key, valueFactory); + + lock (_lock) + { + if (!_sortedDictionary.ContainsKey(key)) + { + _sortedDictionary[key] = result; + } + } + + return result; + } + + // Clears the dictionary + public void Clear() + { + _concurrentDictionary.Clear(); + lock (_lock) + { + _sortedDictionary.Clear(); + } + } + + // Checks if the dictionary contains the specified key + public bool ContainsKey(TKey key) + { + return _concurrentDictionary.ContainsKey(key); + } + + // Converts the dictionary to an array of key-value pairs + public KeyValuePair[] ToArray() + { + return _concurrentDictionary.ToArray(); + } + + // Indexer to support [] access in PowerShell + public TValue this[TKey key] + { + get + { + if (_concurrentDictionary.TryGetValue(key, out TValue value)) + { + return value; + } + throw new KeyNotFoundException($"The key '{key}' was not found in the dictionary."); + } + set + { + AddOrUpdate(key, value); + } + } + + // Returns ordered keys + public IEnumerable Keys + { + get + { + lock (_lock) + { + // Return a copy of the keys to maintain thread safety + return new List(_sortedDictionary.Keys); + } + } + } + + // Returns ordered values + public IEnumerable Values + { + get + { + lock (_lock) + { + // Return a copy of the values to maintain thread safety + return new List(_sortedDictionary.Values); + } + } + } + + // Returns the count of items in the dictionary + public int Count + { + get { return _concurrentDictionary.Count; } + } + + // Checks if the dictionary is empty + public bool IsEmpty + { + get { return _concurrentDictionary.IsEmpty; } + } + } +} \ No newline at end of file diff --git a/src/Pode.psd1 b/src/Pode.psd1 index aff2bfcb7..48b357e9e 100644 --- a/src/Pode.psd1 +++ b/src/Pode.psd1 @@ -141,6 +141,8 @@ 'ConvertFrom-PodeXml', 'Set-PodeDefaultFolder', 'Get-PodeDefaultFolder', + 'ConvertTo-PodeYaml', + 'ConvertFrom-PodeYaml', 'Get-PodeCurrentRunspaceName', 'Set-PodeCurrentRunspaceName', 'Invoke-PodeGC', diff --git a/src/Private/Context.ps1 b/src/Private/Context.ps1 index 19d4c1004..c373b7e40 100644 --- a/src/Private/Context.ps1 +++ b/src/Private/Context.ps1 @@ -1126,6 +1126,12 @@ function Set-PodeWebConfiguration { OpenApi = @{ DefaultDefinitionTag = [string](Protect-PodeValue -Value $Configuration.OpenApi.DefaultDefinitionTag -Default 'default') } + Conversion = @{ + # If Pode is running in Powershell Core Json conversion are by default to HashTable + JsonToHashTable = [bool] (Protect-PodeValue -Value $Configuration.Conversion.JsonToHashTable -Default (Test-PodeIsPSCore) ) + XmlToHashTable = [bool] (Protect-PodeValue -Value $Configuration.Conversion.XmlToHashTable -Default $true ) + YamlToHashTable = [bool] (Protect-PodeValue -Value $Configuration.Conversion.XmlToHashTable -Default $true ) + } } if ($Configuration.OpenApi -and $Configuration.OpenApi.ContainsKey('UsePodeYamlInternal')) { diff --git a/src/Private/Helpers.ps1 b/src/Private/Helpers.ps1 index 0b8e8d425..a18900144 100644 --- a/src/Private/Helpers.ps1 +++ b/src/Private/Helpers.ps1 @@ -1419,10 +1419,10 @@ function ConvertTo-PodeResponseContent { { $_ -match '^(.*\/)?(.*\+)?yaml$' } { if ($InputObject -isnot [string]) { if ($Depth -le 0) { - return (ConvertTo-PodeYamlInternal -InputObject $InputObject ) + return (ConvertTo-PodeYaml -InputObject $InputObject ) } else { - return (ConvertTo-PodeYamlInternal -InputObject $InputObject -Depth $Depth ) + return (ConvertTo-PodeYaml -InputObject $InputObject -Depth $Depth ) } } @@ -1556,10 +1556,15 @@ function ConvertFrom-PodeRequestContent { switch ($ContentType) { { $_ -ilike '*/json' } { if (Test-PodeIsPSCore) { - $Result.Data = ($Content | ConvertFrom-Json -AsHashtable) + $Result.Data = ($Content | ConvertFrom-Json -AsHashtable:$PodeContext.Server.Web.Conversion.JsonToHashTable) } else { - $Result.Data = ($Content | ConvertFrom-Json) + if ($PodeContext.Server.Web.Conversion.JsonToHashTable) { + $Result.Data = ConvertTo-PodeHashtable -PSObject ($Content | ConvertFrom-Json) + } + else { + $Result.Data = ($Content | ConvertFrom-Json) + } } } @@ -1567,6 +1572,10 @@ function ConvertFrom-PodeRequestContent { $Result.Data = [xml]($Content) } + { $_ -ilike '*/yaml' } { + $Result.Data = ($Content | ConvertFrom-PodeYaml -AsHashtable:$PodeContext.Server.Web.Conversion.YamlToHashTable) + } + { $_ -ilike '*/csv' } { $Result.Data = ($Content | ConvertFrom-Csv) } @@ -3386,66 +3395,6 @@ function Test-PodeVersionPwshEOL { } } - -<# -.SYNOPSIS - creates a YAML description of the data in the object - based on https://github.com/Phil-Factor/PSYaml - -.DESCRIPTION - This produces YAML from any object you pass to it. - -.PARAMETER Object - The object that you want scripted out. This parameter accepts input via the pipeline. - -.PARAMETER Depth - The depth that you want your object scripted to - -.EXAMPLE - Get-PodeOpenApiDefinition|ConvertTo-PodeYaml -#> -function ConvertTo-PodeYaml { - [CmdletBinding()] - [OutputType([string])] - param ( - [parameter(Position = 0, Mandatory = $true, ValueFromPipeline = $true)] - [AllowNull()] - $InputObject, - - [parameter()] - [int] - $Depth = 16 - ) - - begin { - $pipelineObject = @() - } - - process { - $pipelineObject += $_ - } - - end { - if ($pipelineObject.Count -gt 1) { - $InputObject = $pipelineObject - } - - if ($PodeContext.Server.Web.OpenApi.UsePodeYamlInternal) { - return ConvertTo-PodeYamlInternal -InputObject $InputObject -Depth $Depth -NoNewLine - } - - if ($null -eq $PodeContext.Server.InternalCache.YamlModuleImported) { - $PodeContext.Server.InternalCache.YamlModuleImported = ((Test-PodeModuleInstalled -Name 'PSYaml') -or (Test-PodeModuleInstalled -Name 'powershell-yaml')) - } - - if ($PodeContext.Server.InternalCache.YamlModuleImported) { - return ($InputObject | ConvertTo-Yaml) - } - else { - return ConvertTo-PodeYamlInternal -InputObject $InputObject -Depth $Depth -NoNewLine - } - } -} - <# .SYNOPSIS Converts PowerShell objects into a YAML-formatted string. @@ -3544,7 +3493,7 @@ function ConvertTo-PodeYamlInternal { 'string' { $String = "$InputObject" if (($string -match '[\r\n]' -or $string.Length -gt 80) -and ($string -notlike 'http*')) { - $multiline = [System.Text.StringBuilder]::new("|`n") + $multiline = [System.Text.StringBuilder]::new('|' + [Environment]::NewLine) $items = $string.Split("`n") for ($i = 0; $i -lt $items.Length; $i++) { @@ -3607,7 +3556,7 @@ function ConvertTo-PodeYamlInternal { $index = 0 $string = [System.Text.StringBuilder]::new() foreach ($item in $InputObject.Keys) { - if ($NoNewLine -and $index++ -eq 0) { $NewPadding = '' } else { $NewPadding = "`n$padding" } + if ($NoNewLine -and $index++ -eq 0) { $NewPadding = '' } else { $NewPadding = [Environment]::NewLine + $padding } $null = $string.Append( $NewPadding).Append( $item).Append(': ') if ($InputObject[$item] -is [System.ValueType]) { if ($InputObject[$item] -is [bool]) { @@ -3633,7 +3582,7 @@ function ConvertTo-PodeYamlInternal { $index = 0 $string = [System.Text.StringBuilder]::new() foreach ($item in ($InputObject | Get-Member -MemberType Properties | Select-Object -ExpandProperty Name)) { - if ($NoNewLine -and $index++ -eq 0) { $NewPadding = '' } else { $NewPadding = "`n$padding" } + if ($NoNewLine -and $index++ -eq 0) { $NewPadding = '' } else { $NewPadding = [Environment]::NewLine + $padding } $null = $string.Append( $NewPadding).Append( $item).Append(': ') if ($InputObject.$item -is [System.ValueType]) { if ($InputObject.$item -is [bool]) { @@ -3658,7 +3607,7 @@ function ConvertTo-PodeYamlInternal { $string = [System.Text.StringBuilder]::new() $index = 0 foreach ($item in $InputObject ) { - if ($NoNewLine -and $index++ -eq 0) { $NewPadding = '' } else { $NewPadding = "`n$padding" } + if ($NoNewLine -and $index++ -eq 0) { $NewPadding = '' } else { $NewPadding = [Environment]::NewLine +$padding } $null = $string.Append($NewPadding).Append('- ').Append((ConvertTo-PodeYamlInternal -InputObject $item -depth $Depth -NestingLevel ($NestingLevel + 1) -NoNewLine)) } $string.ToString() @@ -3987,6 +3936,165 @@ function Test-PodeIsISEHost { return ((Test-PodeIsWindows) -and ('Windows PowerShell ISE Host' -eq $Host.Name)) } + + + +<# +.SYNOPSIS + Converts a YAML string into a nested ordered hashtable. + +.DESCRIPTION + This function takes a YAML string as input and converts it into a nested ordered hashtable, preserving + the order of keys. It supports conversion of boolean, integer, float, and datetime values. + +.PARAMETER InputObject + The YAML string to be converted. + +.EXAMPLE + $yamlString = @' + openapi: 3.0.3 + info: + title: Async test - OpenAPI 3.0 + version: 0.0.1 + paths: + /task/{taskId}: + get: + summary: Get Pode Task Info + '@ + + $hashtable = ConvertFrom-PodeYamlInternal -InputObject $yamlString + # Converts the YAML string to a nested ordered hashtable. + +.NOTES + This is an internal function and may change in future releases of Pode. +#> +function ConvertFrom-PodeYamlInternal { + param ( + [Parameter(Mandatory = $true)] + [string]$InputObject + ) + # Split the YAML input into lines + [string[]]$lines = $InputObject -split "`n" + # Initialize the main hashtable as an ordered hashtable + $hashtable = [ordered]@{} + # Stacks to keep track of current hashtable and indentation levels + $stack = [System.Collections.Stack]::new() + $indentStack = [System.Collections.Stack]::new() + $stack.Push($hashtable) + $indentStack.Push(-1) + + # Iterate over each line of the YAML input + for ($i = 0; $i -lt $lines.Length; $i++) { + $line = $lines[$i] + if ($line -match '^(\s*)([^:]+):\s*(.*)$') { + $indent = $matches[1].Length + $key = $matches[2].Trim() + $value = $matches[3].Trim() + + # Pop the stack if the current indentation level is less than or equal to the previous level + while ( $indentStack.Peek() -ge $indent) { + $null = $indentStack.Pop() + $null = $stack.Pop() + } + + # Peek the current hashtable from the stack + $current = $null = $stack.Peek() + + # If value is empty, create a new nested ordered hashtable + if ($value -eq '') { + $current[$key] = [ordered]@{} + $stack.Push($current[$key]) + $indentStack.Push($indent) + } + # Handle inline arrays + elseif ($value -match '^\[(.*)\]$') { + $current[$key] = ($matches[1] -split ',\s*') -replace '"', '' + } + # Handle multiline strings + elseif ($value -eq '|') { + $value = '' + while (++$i -lt $lines.Length -and $lines[$i] -match '^\s+(.*)$') { + $value += $matches[1] + "`n" + } + $i-- + $current[$key] = $value.TrimEnd() + } + # Convert and assign the value + else { + $current[$key] = Convert-PodeStringToType $value + } + } + # Handle list items + elseif ($line -match '^\s*-\s*(.*)$') { + $value = $matches[1].Trim() + if (-not ($current[$key] -is [System.Collections.ArrayList])) { + $current[$key] = [System.Collections.ArrayList]::new() + } + $null = $current[$key].Add((Convert-PodeStringToType $value)) + } + } + + return $hashtable +} + +<# +.SYNOPSIS + Converts a string value to its appropriate data type (bool, int, float, datetime, or string). + +.DESCRIPTION + This function takes a string value and determines if it can be converted to a boolean, integer, float, + or datetime. If none of these conversions apply, it returns the original string. + +.PARAMETER value + The string value to be converted. + +.EXAMPLE + $convertedValue = Convert-PodeStringToType -value "true" + # Converts the string "true" to a boolean $true. + +.EXAMPLE + $convertedValue = Convert-PodeStringToType -value "123" + # Converts the string "123" to an integer 123. + +.EXAMPLE + $convertedValue = Convert-PodeStringToType -value "123.45" + # Converts the string "123.45" to a float 123.45. + +.EXAMPLE + $convertedValue = Convert-PodeStringToType -value "2021-05-01" + # Converts the string "2021-05-01" to a datetime object. + +.EXAMPLE + $convertedValue = Convert-PodeStringToType -value "some string" + # Returns the original string "some string". + +.NOTES + This is an internal function and may change in future releases of Pode. +#> +function Convert-PodeStringToType($value) { + # Convert to boolean if value matches 'true' or 'false' + if ($value -match '^(true|false)$') { + return [bool]::Parse($value) + } + # Convert to integer if value matches an integer pattern + elseif ($value -match '^-?\d+$') { + return [int]::Parse($value) + } + # Convert to float if value matches a float pattern + elseif ($value -match '^-?\d*\.\d+$') { + return [float]::Parse($value) + } + # Convert to datetime if value matches a datetime pattern + elseif ($value -match '^\d{4}-\d{2}-\d{2}(T\d{2}:\d{2}:\d{2}(\.\d{1,3})?(Z|[+-]\d{2}:\d{2})?)?$') { + return [DateTime]::Parse($value) + } + # Return the original string if no other conversion applies + else { + return $value + } +} + + <# .SYNOPSIS Determines the MIME type of an image from its binary header. diff --git a/src/Public/Utilities.ps1 b/src/Public/Utilities.ps1 index e193bbbc3..25ab5c8ff 100644 --- a/src/Public/Utilities.ps1 +++ b/src/Public/Utilities.ps1 @@ -1611,3 +1611,184 @@ function Start-PodeSleep { + +<# +.SYNOPSIS + Converts a YAML input object into a hashtable, using either the internal converter or an external YAML module if available. + +.DESCRIPTION + This function processes a YAML input object and converts it into a hashtable. It checks if a YAML module is available for conversion. + If the module is available, it uses that for conversion. Otherwise, it falls back to the internal converter. + +.PARAMETER InputObject + The YAML input object to be converted. + +.PARAMETER AsHashTable + Converts the YAML to a hash table object + +.EXAMPLE + $yamlString = @' + openapi: 3.0.3 + info: + title: Async test - OpenAPI 3.0 + version: 0.0.1 + paths: + /task/{taskId}: + get: + summary: Get Pode Task Info + '@ + $hashtable = ConvertFrom-PodeYaml -InputObject $yamlString + # Converts the YAML string to a hashtable. + +.NOTES + This is an internal function and may change in future releases of Pode. +#> +function ConvertFrom-PodeYaml { + [CmdletBinding()] + [OutputType([System.Collections.Specialized.OrderedDictionary])] + param ( + [Parameter(Position = 0, Mandatory = $true, ValueFromPipeline = $true)] + [AllowNull()] + [string[]] + $InputObject, + + [Parameter()] + [switch] + $AsHashTable + ) + + begin { + # Initialize an array to store pipeline objects for later processing. + $pipelineObject = @() + + # Determine if the internal Pode YAML converter should be used. + $usePodeYamlInternal = $null -eq $PodeContext -or $PodeContext.Server.Web.OpenApi.UsePodeYamlInternal + + # Check if the YAML module has already been imported and cache the result. + $yamlModuleImported = $PodeContext.Server.InternalCache.YamlModuleImported + if ($null -eq $yamlModuleImported) { + # Test if either PSYaml or powershell-yaml module is installed. + $yamlModuleImported = ((Test-PodeModuleInstalled -Name 'PSYaml') -or (Test-PodeModuleInstalled -Name 'powershell-yaml')) + # Cache the result of the module check. + $PodeContext.Server.InternalCache.YamlModuleImported = $yamlModuleImported + } + } + + process { + # Add each pipeline object to the array for processing in the 'end' block. + $pipelineObject += $_ + } + + end { + # If multiple objects were passed through the pipeline, join them into a single string. + if ( $InputObject.Count -gt 1) { + $obj = $InputObject -join [Environment]::NewLine + } + else { + $obj = $InputObject[0] + } + + # Determine which YAML conversion method to use. + if ($usePodeYamlInternal -or -not $yamlModuleImported) { + # If using the internal Pode YAML converter and PowerShell Core, use the Pode.PodeConverter class. + if (Test-PodeIsPSCore) { + $result = [Pode.PodeConverter]::FromYaml($obj) + } + else { + # Use the internal Pode YAML converter for Windows PowerShell. + $result = ConvertFrom-PodeYamlInternal -InputObject $obj + } + } + else { + # If an external YAML module is available, use it for conversion. + $result = ($InputObject | ConvertFrom-Yaml) + } + + # Convert the result to a hashtable if the AsHashTable switch is used. + if ($AsHashTable) { + return [PSCustomObject]$result + } + + # Return the resulting object. + return $result + + } +} + +<# +.SYNOPSIS + Creates a YAML description of the data in the object - based on https://github.com/Phil-Factor/PSYaml + +.DESCRIPTION + This function produces YAML from any object you pass to it. It supports objects received through the pipeline + and allows specifying the depth of the conversion. + +.PARAMETER InputObject + The object that you want scripted out. This parameter accepts input via the pipeline. + +.PARAMETER Depth + The depth to which you want your object scripted. + +.EXAMPLE + Get-PodeOpenApiDefinition | ConvertTo-PodeYaml +#> +function ConvertTo-PodeYaml { + [CmdletBinding()] + [OutputType([string])] + param ( + # The object to be converted to YAML. Accepts pipeline input. + [parameter(Position = 0, Mandatory = $true, ValueFromPipeline = $true)] + [AllowNull()] + $InputObject, + + # Specifies the depth to which the object should be converted to YAML. + [parameter()] + [int] + $Depth = 16 + ) + + begin { + # Initialize an array to store pipeline objects for later processing. + $pipelineObject = @() + + # Determine if the internal Pode YAML converter should be used. + $usePodeYamlInternal = $null -eq $PodeContext -or $PodeContext.Server.Web.OpenApi.UsePodeYamlInternal + + # Check if the YAML module has already been imported and cache the result. + $yamlModuleImported = $PodeContext.Server.InternalCache.YamlModuleImported + if ($null -eq $yamlModuleImported) { + # Test if either PSYaml or powershell-yaml module is installed. + $yamlModuleImported = ((Test-PodeModuleInstalled -Name 'PSYaml') -or (Test-PodeModuleInstalled -Name 'powershell-yaml')) + # Cache the result of the module check. + $PodeContext.Server.InternalCache.YamlModuleImported = $yamlModuleImported + } + } + process { + # Add each pipeline object to the array for processing in the 'end' block. + $pipelineObject += $_ + } + + end { + if ($pipelineObject.Count -gt 1) { + $InputObject = $pipelineObject + } + + # Determine which YAML conversion method to use. + if ($usePodeYamlInternal -or -not $yamlModuleImported) { + # If using the internal Pode YAML converter and PowerShell Core, use the Pode.PodeConverter class. + if (Test-PodeIsPSCore) { + return [Pode.PodeConverter]::ToYaml($InputObject, $Depth, 0, $true) + } + else { + # Use the internal Pode YAML converter for Windows PowerShell. + return ConvertTo-PodeYamlInternal -InputObject $InputObject -Depth $Depth -NoNewLine + } + } + else { + # If an external YAML module is available, use it for conversion. + return ($InputObject | ConvertTo-Yaml) + } + + } +} + diff --git a/tests/unit/Helpers.Tests.ps1 b/tests/unit/Helpers.Tests.ps1 index 54a298804..2a2a4d214 100644 --- a/tests/unit/Helpers.Tests.ps1 +++ b/tests/unit/Helpers.Tests.ps1 @@ -1664,7 +1664,7 @@ Describe 'ConvertTo-PodeYamlInternal Tests' { - two - three '@) - $result | Should -Be ($expected.Trim() -Replace "`r`n", "`n") + $result | Should -Be $expected.Trim() } It 'Converts hashtables correctly' { @@ -1673,7 +1673,7 @@ Describe 'ConvertTo-PodeYamlInternal Tests' { key2 = 'value2' } $result = ConvertTo-PodeYamlInternal -InputObject $hashTable -NoNewLine - $result | Should -Be "key1: value1`nkey2: value2" + $result | Should -Be "key1: value1$([Environment]::NewLine)key2: value2" } } @@ -1686,7 +1686,7 @@ Describe 'ConvertTo-PodeYamlInternal Tests' { } $result = ConvertTo-PodeYamlInternal -InputObject $nestedHash -NoNewLine - $result | Should -Be "parent: `n child: value" + $result | Should -Be "parent: $([Environment]::NewLine) child: value" } } diff --git a/tests/unit/Utilities.Tests.ps1 b/tests/unit/Utilities.Tests.ps1 new file mode 100644 index 000000000..ab1836ab4 --- /dev/null +++ b/tests/unit/Utilities.Tests.ps1 @@ -0,0 +1,324 @@ +[Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSUseDeclaredVarsMoreThanAssignments', '')] +[Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSAvoidUsingWriteHost', '')] +param() +BeforeAll { + Add-Type -AssemblyName 'System.Net.Http' -ErrorAction SilentlyContinue + + $path = $PSCommandPath + $src = (Split-Path -Parent -Path $path) -ireplace '[\\/]tests[\\/]unit', '/src/' + Get-ChildItem "$($src)/*.ps1" -Recurse | Resolve-Path | ForEach-Object { . $_ } + Import-LocalizedData -BindingVariable PodeLocale -BaseDirectory (Join-Path -Path $src -ChildPath 'Locales') -FileName 'Pode' + + $podeDll = [AppDomain]::CurrentDomain.GetAssemblies() | Where-Object { $_.GetName().Name -eq 'Pode' } + if (! $podeDll) { + # fetch the .net version and the libs path + $version = [System.Environment]::Version.Major + $libsPath = "$($src)/Libs" + + # filter .net dll folders based on version above, and get path for latest version found + if (![string]::IsNullOrWhiteSpace($version)) { + $netFolder = Get-ChildItem -Path $libsPath -Directory -Force | + Where-Object { $_.Name -imatch "net[1-$($version)]" } | + Sort-Object -Property Name -Descending | + Select-Object -First 1 -ExpandProperty FullName + } + + # use netstandard if no folder found + if ([string]::IsNullOrWhiteSpace($netFolder)) { + $netFolder = "$($libsPath)/netstandard2.0" + } + + # append Pode.dll and mount + Add-Type -LiteralPath "$($netFolder)/Pode.dll" -ErrorAction Stop + } + + function Compare-Hashtable { + param ( + [hashtable]$Hashtable1, + [hashtable]$Hashtable2 + ) + + # Function to compare two hashtable values + function Compare-Value($value1, $value2) { + if ($value1 -is [hashtable] -and $value2 -is [hashtable]) { + return Compare-Hashtable -Hashtable1 $value1 -Hashtable2 $value2 + } + else { + return $value1 -eq $value2 + } + } + + $keys1 = $Hashtable1.Keys + $keys2 = $Hashtable2.Keys + + # Check if both hashtables have the same keys + if ($keys1.Count -ne $keys2.Count) { + return $false + } + + foreach ($key in $keys1) { + if (-not $Hashtable2.ContainsKey($key)) { + return $false + } + if ($Hashtable2[$key] -is [hashtable]) { + if (-not (Compare-Hashtable -Hashtable1 $Hashtable1[$key] -Hashtable2 $Hashtable2[$key])){ + return $false + } + } + elseif (-not (Compare-Value $Hashtable1[$key] $Hashtable2[$key])) { + return $false + } + } + + return $true + } + + + # Mocking the external function ConvertFrom-Yaml + function ConvertFrom-Yaml { + return @{ openapi = '3.0.3'; info = @{ title = 'Async test - OpenAPI 3.0'; version = '0.0.1' }; paths = @{ '/task/{taskId}' = @{ get = @{ summary = 'Get Pode Task Info' } } } } + } +} + +Describe 'ConvertTo-PodeYaml Tests' { + BeforeAll { + $PodeContext = @{ Server = @{InternalCache = @{} } } + } + Context 'When converting basic types' { + It 'Converts strings correctly' { + $result = 'hello world' | ConvertTo-PodeYaml + $result | Should -Be 'hello world' + } + + It 'Converts arrays correctly' { + $result = @('one', 'two', 'three') | ConvertTo-PodeYaml + $expected = (@' +- one +- two +- three +'@) + $result | Should -Be $expected.Trim()# -Replace "`r`n", "`n") + } + + It 'Converts hashtables correctly' { + $hashTable = [ordered]@{ + key1 = 'value1' + key2 = 'value2' + } + $result = $hashTable | ConvertTo-PodeYaml + $result | Should -Be "key1: value1$([Environment]::NewLine)key2: value2" + } + } + + Context 'When converting complex objects' { + It 'Handles nested hashtables' { + $nestedHash = @{ + parent = @{ + child = 'value' + } + } + $result = $nestedHash | ConvertTo-PodeYaml + + $result | Should -Be "parent: $([Environment]::NewLine) child: value" + } + } + + Context 'Error handling' { + It 'Returns empty string for null input' { + $result = $null | ConvertTo-PodeYaml + $result | Should -Be '' + } + } +} + +# Requires -Version 5.5 + +Describe 'ConvertFrom-PodeYaml test' { + + Context 'YAML Module' { + BeforeAll { + # Mocking the internal function ConvertFrom-PodeYamlInternal + Mock -CommandName ConvertFrom-PodeYamlInternal -MockWith { + return [ordered]@{ openapi = '3.0.3'; info = @{ title = 'Async test - OpenAPI 3.0'; version = '0.0.1' }; paths = @{ '/task/{taskId}' = @{ get = @{ summary = 'Get Pode Task Info' } } } } + } + + # Mocking the external function ConvertFrom-Yaml + Mock -CommandName ConvertFrom-Yaml -MockWith { + return [ordered]@{ openapi = '3.0.3'; info = @{ title = 'Async test - OpenAPI 3.0'; version = '0.0.1' }; paths = @{ '/task/{taskId}' = @{ get = @{ summary = 'Get Pode Task Info' } } } } + } + + # Mocking the Test-PodeModuleInstalled function + Mock -CommandName Test-PodeModuleInstalled -MockWith { return $false } + } + Context 'When no YAML module is available' { + BeforeAll { + $PodeContext = @{ + Server = @{ + Web = @{ + OpenApi = @{ + UsePodeYamlInternal = $true + } + } + InternalCache = @{ + YamlModuleImported = $false + } + } + } + } + + It 'Should use the internal converter if no YAML module is available' { + $yamlString = @' + openapi: 3.0.3 + info: + title: Async test - OpenAPI 3.0 + version: 0.0.1 + paths: + /task/{taskId}: + get: + summary: Get Pode Task Info +'@ + + $result = ConvertFrom-PodeYaml -InputObject $yamlString + + # Assert-MockCalled -CommandName ConvertFrom-PodeYamlInternal -Times 1 + Assert-MockCalled -CommandName ConvertFrom-Yaml -Times 0 + $result | Should -BeOfType 'System.Collections.Specialized.OrderedDictionary' + $result.openapi | Should -Be '3.0.3' + } + } + + Context 'When a YAML module is available' { + BeforeAll { + $PodeContext = @{ + Server = @{ + Web = @{ + OpenApi = @{ + UsePodeYamlInternal = $false + } + } + InternalCache = @{ + YamlModuleImported = $true + } + } + } + + } + + It 'Should use the external converter if a YAML module is available' { + $yamlString = @' + openapi: 3.0.3 + info: + title: Async test - OpenAPI 3.0 + version: 0.0.1 + paths: + /task/{taskId}: + get: + summary: Get Pode Task Info +'@ + + $result = ConvertFrom-PodeYaml -InputObject $yamlString + + Assert-MockCalled -CommandName ConvertFrom-Yaml -Times 1 + # Assert-MockCalled -CommandName ConvertFrom-PodeYamlInternal -Times 0 + $result | Should -BeOfType 'System.Collections.Specialized.OrderedDictionary' + $result.openapi | Should -Be '3.0.3' + } + } + + + } + + Context 'When an objects is provided' { + BeforeAll { + $PodeContext = @{ + Server = @{ + Web = @{ + OpenApi = @{ + UsePodeYamlInternal = $true + } + } + InternalCache = @{ + YamlModuleImported = $false + } + } + } + } + + It 'single input object' { + $yamlString1 = @' + openapi: 3.0.3 + info: + title: Async test - OpenAPI 3.0 + version: 0.0.1 + paths: + /task/{taskId}: + get: + summary: Get Pode Task Info +'@ + + + + $result = $yamlString1 | ConvertFrom-PodeYaml + $result | Should -BeOfType 'System.Collections.Specialized.OrderedDictionary' + Compare-Hashtable -Hashtable1 $result -Hashtable2 ( @{ + openapi = '3.0.3' + info = @{ + title = 'Async test - OpenAPI 3.0' + version = '0.0.1' + } + paths = @{ + '/task/{taskId}' = @{ + get = @{ + summary = 'Get Pode Task Info' + } + } + } + }) | Should -BeTrue + } + } + + Context 'When multiple pipeline objects are provided' { + BeforeAll { + $PodeContext = @{ + Server = @{ + Web = @{ + OpenApi = @{ + UsePodeYamlInternal = $true + } + } + InternalCache = @{ + YamlModuleImported = $false + } + } + } + } + + It 'Should combine pipeline objects into a single input object' { + $yamlString1 = @' + openapi: 3.0.3 + info: + title: Async test - OpenAPI 3.0 + version: 0.0.1 + paths: + /task/{taskId}: + get: + summary: Get Pode Task Info +'@ + + $yamlString2 = @' + openapi: 3.0.3 + info: + title: Another test - OpenAPI 3.0 + version: 0.0.2 + paths: + /task/{taskId}: + get: + summary: Get Another Task Info +'@ + + $result = $yamlString1, $yamlString2 | ConvertFrom-PodeYaml + $result | Should -BeOfType 'System.Collections.Specialized.OrderedDictionary' + } + } + +}