Private/Console/Json.psm1
|
using namespace System using namespace System.Text using namespace System.Net.Http using namespace System.Net.Http.Headers using namespace System.Collections.Generic using namespace System.Globalization using module ..\Enums.psm1 using module ..\Abstracts.psm1 using module .\Rendering.psm1 using module .\Syntax.psm1 class SerializerSettings { static [string]$DateTimeFormat = "yyyy-MM-ddTHH:mm:ss.ffffffZ" } class NewtonsoftJson { static [object] Serialize([object]$obj) { return [Newtonsoft.Json.JsonConvert]::SerializeObject($obj, [Newtonsoft.Json.Formatting]::Indented) } static [object] Deserialize([string]$string) { $settings = New-Object "Newtonsoft.Json.JsonSerializerSettings" if ($global:ErrorActionPreference -eq "Ignore") { $settings.Error = { param ([object]$eventSender, [Newtonsoft.Json.Serialization.ErrorEventArgs]$errorArgs) $currentError = $errorArgs.ErrorContext.Error.Message Write-Warning $currentError $errorArgs.ErrorContext.Handled = $true } } $obj = [Newtonsoft.Json.JsonConvert]::DeserializeObject($string, [Newtonsoft.Json.Linq.JObject], $settings) return [NewtonsoftJson]::ConvertFromJObject($obj) } hidden static [object] ConvertFromJObject([object]$obj) { if ($obj -is [Newtonsoft.Json.Linq.JArray]) { $a = @() foreach ($entry in $obj.GetEnumerator()) { $a += @([NewtonsoftJson]::ConvertFromJObject($entry)) } return $a } elseif ($obj -is [Newtonsoft.Json.Linq.JObject]) { $h = [ordered]@{} foreach ($kvp in $obj.GetEnumerator()) { $val = [NewtonsoftJson]::ConvertFromJObject($kvp.value) if ($kvp.value -is [Newtonsoft.Json.Linq.JArray]) { $val = @($val) } $h += @{ "$($kvp.key)" = $val } } return $h } elseif ($obj -is [Newtonsoft.Json.Linq.JValue]) { return $obj.Value } else { return $obj } } } # .EXAMPLE # Deserialize a JSON string to [PSCustomObject] (no network call). # [Json.JsonSerializer]::Deserialize[PSCustomObject]($json, [SerializerOptionsBuilder]::Build()) # .EXAMPLE # Deserialize a JSON string to a strongly-typed object (no network call). # [Json.JsonSerializer]::Deserialize($json, $targetType, [SerializerOptionsBuilder]::Build()) class SerializerOptionsBuilder { static [Json.JsonSerializerOptions] Build() { $opts = [Json.JsonSerializerOptions]@{ PropertyNameCaseInsensitive = $true WriteIndented = $true } $opts.Converters.Add([Json.Serialization.JsonStringEnumConverter]::new()) return [SerializerOptionsBuilder]::Build($opts, $true, $false) } static [Json.JsonSerializerOptions] Build([Json.JsonSerializerOptions]$defaults, [bool]$includeNullProperties, [bool]$pretty) { $opts = [Json.JsonSerializerOptions]::new($defaults) if (!$includeNullProperties) { $opts.DefaultIgnoreCondition = [Json.Serialization.JsonIgnoreCondition]::WhenWritingNull } $opts.WriteIndented = [bool]$pretty return $opts } } # .SYNOPSIS # Wrapper around System.Text.Json. # # .DESCRIPTION # A pure PowerShell implementation of Serializer that wraps System.Text.Json # and handles specific .NET types correctly (DateTime, IPAddress, Exception, # NameValueCollection, IntPtr) via pre-processing and post-processing, avoiding # the need for C# compilation. # # Provides: # * Sensible defaults (trailing commas, comment skipping, number-from-string). # * Pluggable behavior for Exception, NameValueCollection, DateTime, # IPAddress, IntPtr and enum conversion (native to System.Text.Json). # * Per-call options building. # * Pretty / compact JSON. # * Type-safe deep `CopyObject`. class JsonTextSerializer { # True to include null properties when serializing. [bool]$IncludeNullProperties = $false # Default JsonSerializerOptions - cloned on every call. [Json.JsonSerializerOptions]$DefaultOptions # Kept for backwards compatibility [System.Collections.Generic.List[Json.Serialization.JsonConverter]]$DefaultConverters JsonTextSerializer() { $this.DefaultOptions = [Json.JsonSerializerOptions]::new() $this.DefaultOptions.AllowTrailingCommas = $true $this.DefaultOptions.ReadCommentHandling = [Json.JsonCommentHandling]::Skip $this.DefaultOptions.NumberHandling = [Json.Serialization.JsonNumberHandling]::AllowReadingFromString $this.DefaultOptions.PropertyNameCaseInsensitive = $true $this.DefaultConverters = [System.Collections.Generic.List[Json.Serialization.JsonConverter]]::new() } static [string] GetDateTimeFormat() { return [SerializerSettings]::DateTimeFormat } static [void] SetDateTimeFormat([string]$format) { if ([string]::IsNullOrEmpty($format)) { throw [System.ArgumentNullException]::new('DateTimeFormat') } [SerializerSettings]::DateTimeFormat = $format } [void] SetDefaultOptions([Json.JsonSerializerOptions]$value) { if ($null -eq $value) { throw [System.ArgumentNullException]::new('DefaultOptions') } $this.DefaultOptions = $value } [void] SetDefaultConverters([System.Collections.Generic.List[Json.Serialization.JsonConverter]]$value) { if ($null -eq $value) { throw [System.ArgumentNullException]::new('DefaultConverters') } $this.DefaultConverters = $value } [void] AddConverter([Json.Serialization.JsonConverter]$converter) { if ($null -eq $converter) { throw [System.ArgumentNullException]::new('converter') } $this.DefaultConverters.Add($converter) } hidden [object] PreProcessObject([object]$obj) { if ($null -eq $obj) { return $null } $type = $obj.GetType() if ($type -eq [string] -or $type.IsPrimitive -or $type.IsEnum) { return $obj } if ($obj -is [DateTime]) { return $obj.ToString([SerializerSettings]::DateTimeFormat, [System.Globalization.CultureInfo]::InvariantCulture) } if ($obj -is [System.Net.IPAddress] -or $obj -is [IntPtr]) { return $obj.ToString() } if ($obj -is [System.Exception]) { $dict = [ordered]@{} foreach ($prop in $type.GetProperties()) { if ($prop.Name -ne 'TargetSite') { try { $val = $prop.GetValue($obj) if ($this.IncludeNullProperties -or $null -ne $val) { $dict[$prop.Name] = $this.PreProcessObject($val) } } catch { } } } return $dict } if ($obj -is [System.Collections.Specialized.NameValueCollection]) { $nvc = [System.Collections.Specialized.NameValueCollection]$obj $dict = [ordered]@{} foreach ($key in $nvc.AllKeys) { $vals = $nvc.GetValues($key) $safeKey = if ($null -ne $key) { $key } else { "" } if ($null -ne $vals -and $vals.Length -gt 0) { $validVals = @() foreach ($v in $vals) { if (![string]::IsNullOrEmpty($v)) { $validVals += $v } } if ($validVals.Count -gt 0) { $dict[$safeKey] = ($validVals -join ', ') } else { $dict[$safeKey] = $null } } else { $dict[$safeKey] = $null } } return $dict } if ($obj -is [System.Collections.IDictionary]) { $dict = [ordered]@{} $dictObj = [System.Collections.IDictionary]$obj foreach ($key in $dictObj.Keys) { $val = $dictObj[$key] if ($this.IncludeNullProperties -or $null -ne $val) { $safeKey = if ($null -ne $key) { $key.ToString() } else { "" } $dict[$safeKey] = $this.PreProcessObject($val) } } return $dict } if ($obj -is [System.Collections.IEnumerable]) { $list = [System.Collections.Generic.List[object]]::new() foreach ($item in $obj) { $list.Add($this.PreProcessObject($item)) } return $list } if ($obj -is [psobject] -or $obj -is [Management.Automation.PSCustomObject]) { $dict = [ordered]@{} foreach ($prop in $obj.psobject.Properties) { $val = $prop.Value if ($this.IncludeNullProperties -or $null -ne $val) { $dict[$prop.Name] = $this.PreProcessObject($val) } } return $dict } $dict = [ordered]@{} $props = $type.GetProperties() foreach ($prop in $props) { if ($prop.CanRead) { try { $val = $prop.GetValue($obj) if ($this.IncludeNullProperties -or $null -ne $val) { $dict[$prop.Name] = $this.PreProcessObject($val) } } catch { } } } if ($dict.Count -eq 0 -and $props.Length -eq 0) { return $obj } return $dict } hidden [object] PostProcessJsonElement([object]$element) { if ($null -eq $element) { return $null } if ($element -is [Json.JsonElement]) { $je = [Json.JsonElement]$element switch ($je.ValueKind) { 'Object' { $ht = [ordered]@{} foreach ($prop in $je.EnumerateObject()) { $ht[$prop.Name] = $this.PostProcessJsonElement($prop.Value) } return $ht } 'Array' { $arr = [System.Collections.Generic.List[object]]::new() foreach ($item in $je.EnumerateArray()) { $arr.Add($this.PostProcessJsonElement($item)) } return $arr.ToArray() } 'String' { return $je.GetString() } 'Number' { $i = 0 if ($je.TryGetInt32([ref]$i)) { return $i } $l = 0L if ($je.TryGetInt64([ref]$l)) { return $l } $d = 0.0 if ($je.TryGetDouble([ref]$d)) { return $d } return $je.GetRawText() } 'True' { return $true } 'False' { return $false } 'Null' { return $null } 'Undefined' { return $null } } } if ($element -is [System.Collections.IDictionary]) { $ht = [ordered]@{} foreach ($key in $element.Keys) { $ht[$key] = $this.PostProcessJsonElement($element[$key]) } return $ht } if ($element -is [System.Collections.IEnumerable] -and $element -isnot [string]) { $arr = [System.Collections.Generic.List[object]]::new() foreach ($item in $element) { $arr.Add($this.PostProcessJsonElement($item)) } return $arr.ToArray() } return $element } [object] Deserialize([string]$json, [type]$targetType) { if ($null -eq $targetType) { throw [System.ArgumentNullException]::new('targetType') } if ($null -eq $json) { throw [System.ArgumentNullException]::new('json') } $opts = [SerializerOptionsBuilder]::Build( $this.DefaultOptions, $this.IncludeNullProperties, $false ) foreach ($c in $this.DefaultConverters) { if ($null -ne $c) { $opts.Converters.Add($c) } } $opts.Converters.Add([Json.Serialization.JsonStringEnumConverter]::new()) if ($targetType -eq [System.Collections.Specialized.NameValueCollection]) { $dict = [Json.JsonSerializer]::Deserialize($json, [System.Collections.Generic.Dictionary[string, string]], $opts) $nvc = [System.Collections.Specialized.NameValueCollection]::new() foreach ($key in $dict.Keys) { $val = $dict[$key] if ($null -eq $val) { $nvc.Add($key, $null) } else { if ($val.Contains(',')) { foreach ($v in $val.Split(',')) { $nvc.Add($key, $v.Trim()) } } else { $nvc.Add($key, $val) } } } return $nvc } if ($targetType -eq [System.Exception] -or $targetType.IsSubclassOf([System.Exception])) { throw [System.NotSupportedException]::new("Deserializing exceptions is not allowed") } if ($targetType -eq [IntPtr]) { throw [System.InvalidOperationException]::new("Properties of type IntPtr cannot be Deserialized from JSON.") } if ($targetType -eq [System.Net.IPAddress]) { $str = [Json.JsonSerializer]::Deserialize($json, [string], $opts) return [System.Net.IPAddress]::Parse($str) } if ($targetType -eq [DateTime]) { $str = [Json.JsonSerializer]::Deserialize($json, [string], $opts) $val = [DateTime]::MinValue if ([DateTime]::TryParse($str, [System.Globalization.CultureInfo]::InvariantCulture, [System.Globalization.DateTimeStyles]::RoundtripKind, [ref]$val)) { return $val } if ([DateTime]::TryParse($str, [ref]$val)) { return $val } throw [System.FormatException]::new("The JSON value '$str' could not be converted to System.DateTime.") } try { $result = [Json.JsonSerializer]::Deserialize($json, $targetType, $opts) if ($targetType -eq [Hashtable] -or $targetType -eq [System.Collections.IDictionary]) { return $this.PostProcessJsonElement($result) } return $result } catch { if ($targetType.IsEnum) { throw [Json.JsonException]::new("String value is not valid for enum type or integer value is not defined in enum $($targetType.Name)", $_.Exception) } throw } } [object] Deserialize([byte[]]$json, [type]$targetType) { if ($null -eq $json) { throw [System.ArgumentNullException]::new('json') } return $this.Deserialize([Encoding]::UTF8.GetString($json), $targetType) } [string] Serialize([object]$obj) { return $this.Serialize($obj, $true) } [string] Serialize([object]$obj, [bool]$pretty) { if ($null -eq $obj) { return $null } $opts = [SerializerOptionsBuilder]::Build( $this.DefaultOptions, $this.IncludeNullProperties, $pretty ) foreach ($c in $this.DefaultConverters) { if ($null -ne $c) { $opts.Converters.Add($c) } } $opts.Converters.Add([Json.Serialization.JsonStringEnumConverter]::new()) $processed = $this.PreProcessObject($obj) return [Json.JsonSerializer]::Serialize($processed, $opts) } [object] CopyObject([object]$o, [type]$targetType) { if ($null -eq $o) { return $null } $json = $this.Serialize($o, $false) return $this.Deserialize($json, $targetType) } } class HttpJsonSerializer { hidden [ValidateNotNull()][HttpClient]$Client hidden [ValidateNotNull()][Json.JsonSerializerOptions]$JsonOptions # Default — creates a fresh HttpClient HttpJsonSerializer() { $this.Client = [HttpClient]::new() $this.JsonOptions = [SerializerOptionsBuilder]::Build() $this._SetDefaultHeaders() } # Accept a base URI string HttpJsonSerializer([string]$baseAddress) { $this.Client = [HttpClient]::new() $this.Client.BaseAddress = [uri]$baseAddress $this.JsonOptions = [SerializerOptionsBuilder]::Build() $this._SetDefaultHeaders() } # Accept a pre-configured HttpClient (for mocking / shared handlers) HttpJsonSerializer([HttpClient]$httpClient) { $this.Client = $httpClient $this.JsonOptions = [SerializerOptionsBuilder]::Build() $this._SetDefaultHeaders() } # Full control — custom client + custom serializer options HttpJsonSerializer([HttpClient]$httpClient, [Json.JsonSerializerOptions]$options) { $this.Client = $httpClient $this.JsonOptions = $options $this._SetDefaultHeaders() } # Serialize any object to a JSON string. static [string] Serialize([object]$payload) { $options = [SerializerOptionsBuilder]::Build() return [Json.JsonSerializer]::Serialize($payload, $options) } # Deserialize a JSON string into a [PSCustomObject]. static [PSCustomObject] Deserialize([string]$json) { return [PSCustomObject]([NewtonsoftJson]::Deserialize($json)) } # Deserialize a JSON string into a strongly-typed object. # $user = [HttpJsonSerializer]::Deserialize($json, [User]) static [object] Deserialize([string]$json, [type]$targetType) { $options = [SerializerOptionsBuilder]::Build() return [Json.JsonSerializer]::Deserialize($json, $targetType, $options) } # .SYNOPSIS # GET a URL and deserialize the response body as [PSCustomObject]. # .OUTPUTS # System.Management.Automation.Job (ThreadJob) # .EXAMPLE # $job = $client.GetFromJsonAsync('/users/1') # $user = $job | Wait-Job | Receive-Job [System.Management.Automation.Job] GetFromJsonAsync([string]$requestUri) { $options = $this.JsonOptions return Start-ThreadJob -Name "GET:$requestUri" -ScriptBlock { param($c, $uri, $opts) $task = $c.GetStringAsync($uri) $json = $task.GetAwaiter().GetResult() try { ConvertFrom-Json $json } catch { [PSCustomObject](ConvertFrom-Json $json -AsHashtable) } } -ArgumentList $this.Client, $requestUri, $options } # .SYNOPSIS # GET a URL and deserialize the response body as a strongly-typed object. # .EXAMPLE # $job = $client.GetFromJsonAsync('/users/1', [User]) # $user = $job | Wait-Job | Receive-Job [System.Management.Automation.Job] GetFromJsonAsync([string]$requestUri, [type]$targetType) { $options = $this.JsonOptions return Start-ThreadJob -Name "GET:$requestUri" -ScriptBlock { param($c, $uri, $type, $opts) $task = $c.GetStringAsync($uri) $json = $task.GetAwaiter().GetResult() [System.Text.Json.JsonSerializer]::Deserialize($json, $type, $opts) } -ArgumentList $this.Client, $requestUri, $targetType, $options } # .SYNOPSIS # POST an object serialized as JSON; returns the response as [PSCustomObject]. # .EXAMPLE # $job = $client.PostAsJsonAsync('/users', $newUser) # $result = $job | Wait-Job | Receive-Job [System.Management.Automation.Job] PostAsJsonAsync([string]$requestUri, [object]$payload) { $options = $this.JsonOptions $content = $this._Serialize($payload) return Start-ThreadJob -Name "POST:$requestUri" -ScriptBlock { param($c, $uri, $body, $opts) $task = $c.PostAsync($uri, $body) $response = $task.GetAwaiter().GetResult() $response.EnsureSuccessStatusCode() | Out-Null $readTask = $response.Content.ReadAsStringAsync() $json = $readTask.GetAwaiter().GetResult() try { ConvertFrom-Json $json } catch { [PSCustomObject](ConvertFrom-Json $json -AsHashtable) } } -ArgumentList $this.Client, $requestUri, $content, $options } # .SYNOPSIS # PUT an object serialized as JSON; returns the response as [PSCustomObject]. [System.Management.Automation.Job] PutAsJsonAsync([string]$requestUri, [object]$payload) { $options = $this.JsonOptions $content = $this._Serialize($payload) return Start-ThreadJob -Name "PUT:$requestUri" -ScriptBlock { param($c, $uri, $body, $opts) $task = $c.PutAsync($uri, $body) $response = $task.GetAwaiter().GetResult() $response.EnsureSuccessStatusCode() | Out-Null $readTask = $response.Content.ReadAsStringAsync() $json = $readTask.GetAwaiter().GetResult() try { ConvertFrom-Json $json } catch { [PSCustomObject](ConvertFrom-Json $json -AsHashtable) } } -ArgumentList $this.Client, $requestUri, $content, $options } # .SYNOPSIS # PATCH an object serialized as JSON; returns the response as [PSCustomObject]. [System.Management.Automation.Job] PatchAsJsonAsync([string]$requestUri, [object]$payload) { $options = $this.JsonOptions $content = $this._Serialize($payload) return Start-ThreadJob -Name "PATCH:$requestUri" -ScriptBlock { param($c, $uri, $body, $opts) $req = [System.Net.Http.HttpRequestMessage]::new( [System.Net.Http.HttpMethod]::new('PATCH'), $uri) $req.Content = $body $task = $c.SendAsync($req) $response = $task.GetAwaiter().GetResult() $response.EnsureSuccessStatusCode() | Out-Null $readTask = $response.Content.ReadAsStringAsync() $json = $readTask.GetAwaiter().GetResult() try { ConvertFrom-Json $json } catch { [PSCustomObject](ConvertFrom-Json $json -AsHashtable) } } -ArgumentList $this.Client, $requestUri, $content, $options } # .SYNOPSIS # DELETE a resource; returns the HttpResponseMessage status info. [System.Management.Automation.Job] DeleteAsync([string]$requestUri) { return Start-ThreadJob -Name "DELETE:$requestUri" -ScriptBlock { param($c, $uri) $task = $c.DeleteAsync($uri) $response = $task.GetAwaiter().GetResult() [PSCustomObject]@{ StatusCode = [int]$response.StatusCode Status = $response.StatusCode.ToString() IsSuccess = $response.IsSuccessStatusCode ReasonPhrase = $response.ReasonPhrase } } -ArgumentList $this.Client, $requestUri } static [Json.JsonSerializerOptions] GetDefaultJsonOptions() { $opts = [Json.JsonSerializerOptions]@{ PropertyNameCaseInsensitive = $true WriteIndented = $true } $opts.Converters.Add([Json.Serialization.JsonStringEnumConverter]::new()) return $opts } # .SYNOPSIS # [Static] One-shot GET → PSCustomObject. Creates and disposes its own client. # .OUTPUTS # System.Management.Automation.Job (ThreadJob) # .EXAMPLE # $job = [HttpJsonSerializer]::GetFromJsonAsync('https://jsonplaceholder.typicode.com/users/1') # $user = $job | Wait-Job | Receive-Job static [System.Management.Automation.Job] GetFromJsonAsync([uri]$uri) { $options = [SerializerOptionsBuilder]::Build() return Start-ThreadJob -Name "GET:$uri" -ScriptBlock { param($u, $opts) $c = [System.Net.Http.HttpClient]::new() try { $c.DefaultRequestHeaders.Accept.Add( [System.Net.Http.Headers.MediaTypeWithQualityHeaderValue]::new('application/json')) $task = $c.GetStringAsync($u) $json = $task.GetAwaiter().GetResult() try { ConvertFrom-Json $json } catch { [PSCustomObject](ConvertFrom-Json $json -AsHashtable) } } finally { $c.Dispose() } } -ArgumentList $uri, $options } # .SYNOPSIS # [Static] One-shot GET → strongly-typed object. # .EXAMPLE # $job = [HttpJsonSerializer]::GetFromJsonAsync('https://jsonplaceholder.typicode.com/users/1', [User]) # $user = $job | Wait-Job | Receive-Job static [System.Management.Automation.Job] GetFromJsonAsync([uri]$uri, [type]$targetType) { $options = [SerializerOptionsBuilder]::Build() return Start-ThreadJob -Name "GET:$uri" -ScriptBlock { param($u, $type, $opts) $c = [System.Net.Http.HttpClient]::new() try { $c.DefaultRequestHeaders.Accept.Add( [System.Net.Http.Headers.MediaTypeWithQualityHeaderValue]::new('application/json')) $task = $c.GetStringAsync($u) $json = $task.GetAwaiter().GetResult() [System.Text.Json.JsonSerializer]::Deserialize($json, $type, $opts) } finally { $c.Dispose() } } -ArgumentList $uri, $targetType, $options } # .SYNOPSIS # [Static] One-shot POST with JSON body. # .EXAMPLE # $job = [HttpJsonSerializer]::PostAsJsonAsync('https://jsonplaceholder.typicode.com/users', $user) # $result = $job | Wait-Job | Receive-Job static [System.Management.Automation.Job] PostAsJsonAsync([uri]$uri, [object]$payload) { $options = [SerializerOptionsBuilder]::Build() $json = [Json.JsonSerializer]::Serialize($payload, $options) return Start-ThreadJob -Name "POST:$uri" -ScriptBlock { param($u, $j, $opts) $c = [System.Net.Http.HttpClient]::new() try { $body = [System.Net.Http.StringContent]::new( $j, [System.Text.Encoding]::UTF8, 'application/json') $task = $c.PostAsync($u, $body) $response = $task.GetAwaiter().GetResult() $readTask = $response.Content.ReadAsStringAsync() $respJson = $readTask.GetAwaiter().GetResult() [PSCustomObject]@{ StatusCode = [int]$response.StatusCode Status = $response.StatusCode.ToString() IsSuccess = $response.IsSuccessStatusCode Body = try { ConvertFrom-Json $respJson } catch { [PSCustomObject](ConvertFrom-Json $respJson -AsHashtable) } } } finally { $c.Dispose() } } -ArgumentList $uri, $json, $options } # .SYNOPSIS # [Static] Wait for one or more jobs and return their output, then clean up. # .EXAMPLE # $jobs = $job1, $job2 # $results = [HttpJsonSerializer]::AwaitJobs($jobs) static [object[]] AwaitJobs([System.Management.Automation.Job[]]$jobs) { $jobs | Wait-Job | Out-Null $results = $jobs | Receive-Job $jobs | Remove-Job -Force return $results } hidden [void] _SetDefaultHeaders() { $this.Client.DefaultRequestHeaders.Accept.Clear() $this.Client.DefaultRequestHeaders.Accept.Add( [MediaTypeWithQualityHeaderValue]::new('application/json') ) } hidden [StringContent] _Serialize([object]$payload) { $json = [Json.JsonSerializer]::Serialize($payload, $this.JsonOptions) return [StringContent]::new($json, [Encoding]::UTF8, 'application/json') } hidden [object] _Deserialize([string]$json, [type]$targetType) { return [Json.JsonSerializer]::Deserialize($json, $targetType, $this.JsonOptions) } [void] Dispose() { if ($null -ne $this.Client) { $this.Client.Dispose() } } } class JsonToken { [JsonTokenType]$Type [string]$Value [int]$Position JsonToken([JsonTokenType]$type, [string]$value, [int]$position) { $this.Type = $type $this.Value = $value $this.Position = $position } [string] ToString() { return '{0}({1})@{2}' -f $this.Type, $this.Value, $this.Position } } class JsonTokenizer { hidden [string]$_json hidden [int]$_index hidden [List[JsonToken]]$_tokens static [JsonToken[]] Tokenize([string]$json) { $tokenizer = [JsonTokenizer]::new($json) return $tokenizer.Tokenize() } JsonTokenizer([string]$json) { if ($null -eq $json) { throw [ArgumentNullException]::new('json') } $this._json = $json $this._index = 0 $this._tokens = [List[JsonToken]]::new() } [JsonToken[]] Tokenize() { while ($this._index -lt $this._json.Length) { $ch = $this._json[$this._index] if ([char]::IsWhiteSpace($ch)) { $this._index++ continue } switch ($ch) { '{' { $this.Add([JsonTokenType]::ObjectStart, '{'); $this._index++; break } '}' { $this.Add([JsonTokenType]::ObjectEnd, '}'); $this._index++; break } '[' { $this.Add([JsonTokenType]::ArrayStart, '['); $this._index++; break } ']' { $this.Add([JsonTokenType]::ArrayEnd, ']'); $this._index++; break } ':' { $this.Add([JsonTokenType]::Symbol, ':'); $this._index++; break } ',' { $this.Add([JsonTokenType]::Symbol, ','); $this._index++; break } '"' { $this.Add([JsonTokenType]::String, $this.ReadString()); break } '-' { $this.Add([JsonTokenType]::Number, $this.ReadNumber()); break } default { if ([char]::IsDigit($ch)) { $this.Add([JsonTokenType]::Number, $this.ReadNumber()) } elseif ($this.StartsWith('true')) { $this.Add([JsonTokenType]::Boolean, 'true') $this._index += 4 } elseif ($this.StartsWith('false')) { $this.Add([JsonTokenType]::Boolean, 'false') $this._index += 5 } elseif ($this.StartsWith('null')) { $this.Add([JsonTokenType]::Null, 'null') $this._index += 4 } else { throw [FormatException]::new("Unexpected character '$ch' at position $($this._index).") } } } } return $this._tokens.ToArray() } hidden [void] Add([JsonTokenType]$type, [string]$value) { $this._tokens.Add([JsonToken]::new($type, $value, $this._index)) } hidden [bool] StartsWith([string]$value) { if ($this._index + $value.Length -gt $this._json.Length) { return $false } return [string]::Compare($this._json, $this._index, $value, 0, $value.Length, [StringComparison]::Ordinal) -eq 0 } hidden [string] ReadString() { $start = $this._index $this._index++ $builder = [StringBuilder]::new() while ($this._index -lt $this._json.Length) { $ch = $this._json[$this._index] if ($ch -eq '"') { $this._index++ return $builder.ToString() } if ($ch -eq '\') { $this._index++ if ($this._index -ge $this._json.Length) { throw [FormatException]::new("Unterminated escape sequence at position $($this._index).") } $escaped = $this._json[$this._index] switch ($escaped) { '"' { [void]$builder.Append('"'); break } '\' { [void]$builder.Append('\'); break } '/' { [void]$builder.Append('/'); break } 'b' { [void]$builder.Append("`b"); break } 'f' { [void]$builder.Append("`f"); break } 'n' { [void]$builder.Append("`n"); break } 'r' { [void]$builder.Append("`r"); break } 't' { [void]$builder.Append("`t"); break } 'u' { if ($this._index + 4 -ge $this._json.Length) { throw [FormatException]::new("Incomplete unicode escape at position $($this._index - 1).") } $hex = $this._json.Substring($this._index + 1, 4) $code = [int]::Parse($hex, [NumberStyles]::HexNumber, [CultureInfo]::InvariantCulture) [void]$builder.Append([char]$code) $this._index += 4 break } default { throw [FormatException]::new("Invalid escape sequence '\$escaped' at position $($this._index - 1).") } } } else { if ([char]::IsControl($ch)) { throw [FormatException]::new("Unescaped control character in string at position $($this._index).") } [void]$builder.Append($ch) } $this._index++ } throw [FormatException]::new("Unterminated JSON string at position $start.") } hidden [string] ReadNumber() { $start = $this._index if ($this._json[$this._index] -eq '-') { $this._index++ } if ($this._index -ge $this._json.Length) { throw [FormatException]::new("Incomplete number at position $start.") } if ($this._json[$this._index] -eq '0') { $this._index++ } else { if (![char]::IsDigit($this._json[$this._index])) { throw [FormatException]::new("Invalid number at position $start.") } while ($this._index -lt $this._json.Length -and [char]::IsDigit($this._json[$this._index])) { $this._index++ } } if ($this._index -lt $this._json.Length -and $this._json[$this._index] -eq '.') { $this._index++ if ($this._index -ge $this._json.Length -or ![char]::IsDigit($this._json[$this._index])) { throw [FormatException]::new("Invalid fractional number at position $start.") } while ($this._index -lt $this._json.Length -and [char]::IsDigit($this._json[$this._index])) { $this._index++ } } if ($this._index -lt $this._json.Length -and ($this._json[$this._index] -eq 'e' -or $this._json[$this._index] -eq 'E')) { $this._index++ if ($this._index -lt $this._json.Length -and ($this._json[$this._index] -eq '+' -or $this._json[$this._index] -eq '-')) { $this._index++ } if ($this._index -ge $this._json.Length -or ![char]::IsDigit($this._json[$this._index])) { throw [FormatException]::new("Invalid number exponent at position $start.") } while ($this._index -lt $this._json.Length -and [char]::IsDigit($this._json[$this._index])) { $this._index++ } } return $this._json.Substring($start, $this._index - $start) } } class JsonParser { hidden [JsonToken[]]$_tokens hidden [int]$_index static [JsonSyntax] Parse([JsonToken[]]$tokens) { $parser = [JsonParser]::new($tokens) return $parser.Parse() } static [JsonSyntax] Parse([string]$json) { return [JsonParser]::Parse([JsonTokenizer]::Tokenize($json)) } JsonParser([JsonToken[]]$tokens) { if ($null -eq $tokens) { throw [ArgumentNullException]::new('tokens') } $this._tokens = $tokens $this._index = 0 } [JsonSyntax] Parse() { $result = $this.ReadValue() if ($this._index -lt $this._tokens.Length) { $token = $this.Current() throw [FormatException]::new("Unexpected token '$($token.Value)' at position $($token.Position).") } return $result } hidden [JsonSyntax] ReadValue() { if ($this._index -ge $this._tokens.Length) { throw [FormatException]::new('Unexpected end of JSON input.') } $token = $this.Current() switch ($token.Type) { ([JsonTokenType]::ObjectStart) { return $this.ReadObject() } ([JsonTokenType]::ArrayStart) { return $this.ReadArray() } ([JsonTokenType]::String) { $this._index++; return [JsonString]::new($token.Value) } ([JsonTokenType]::Number) { $this._index++; return [JsonNumber]::new($token.Value) } ([JsonTokenType]::Boolean) { $this._index++; return [JsonBoolean]::new($token.Value -eq 'true') } ([JsonTokenType]::Null) { $this._index++; return [JsonNull]::new() } default { throw [FormatException]::new("Expected JSON value at position $($token.Position), got $($token.Type).") } } return $null } hidden [JsonObject] ReadObject() { $object = [JsonObject]::new() $this.Expect([JsonTokenType]::ObjectStart, $null) if ($this.Match([JsonTokenType]::ObjectEnd, $null)) { return $object } while ($true) { $name = $this.Expect([JsonTokenType]::String, $null) $name.Type = [JsonTokenType]::MemberName $this.Expect([JsonTokenType]::Symbol, ':') | Out-Null $object.Add($name.Value, $this.ReadValue()) if ($this.Match([JsonTokenType]::ObjectEnd, $null)) { break } $this.Expect([JsonTokenType]::Symbol, ',') | Out-Null } return $object } hidden [JsonArray] ReadArray() { $array = [JsonArray]::new() $this.Expect([JsonTokenType]::ArrayStart, $null) if ($this.Match([JsonTokenType]::ArrayEnd, $null)) { return $array } while ($true) { $array.Add($this.ReadValue()) if ($this.Match([JsonTokenType]::ArrayEnd, $null)) { break } $this.Expect([JsonTokenType]::Symbol, ',') | Out-Null } return $array } hidden [JsonToken] Current() { return $this._tokens[$this._index] } hidden [bool] Match([JsonTokenType]$type, [object]$value) { if ($this._index -ge $this._tokens.Length) { return $false } $token = $this.Current() if ($token.Type -ne $type) { return $false } if ($null -ne $value -and $token.Value -ne [string]$value) { return $false } $this._index++ return $true } hidden [JsonToken] Expect([JsonTokenType]$type, [object]$value) { if ($this._index -ge $this._tokens.Length) { throw [FormatException]::new("Expected $type but reached end of input.") } $token = $this.Current() if ($token.Type -ne $type -or ($null -ne $value -and $token.Value -ne [string]$value)) { $expected = if ($null -ne $value) { "$type '$value'" } else { $type.ToString() } throw [FormatException]::new("Expected $expected at position $($token.Position), got $($token.Type) '$($token.Value)'.") } $this._index++ return $token } } class JsonText : IRenderable { [object]$Data [JsonSyntax]$Syntax JsonText([object]$data) { $this.Data = $data if ($data -is [string]) { $trimmed = ([string]$data).TrimStart() if ($trimmed.StartsWith('{') -or $trimmed.StartsWith('[') -or $trimmed.StartsWith('"') -or $trimmed.StartsWith('true') -or $trimmed.StartsWith('false') -or $trimmed.StartsWith('null') -or $trimmed.StartsWith('-') -or ($trimmed.Length -gt 0 -and [char]::IsDigit($trimmed[0]))) { $this.Syntax = [JsonParser]::Parse([string]$data) } else { $this.Syntax = [JsonSyntax]::FromObject($data) } } else { $this.Syntax = [JsonSyntax]::FromObject($data) } } JsonText([JsonSyntax]$syntax) { $this.Syntax = $syntax $this.Data = $syntax } [Measurement] Measure([RenderOptions]$options, [int]$maxWidth) { return $this.Syntax.Measure($options, $maxWidth) } [object[]] Render([RenderOptions]$options, [int]$maxWidth) { return $this.Syntax.Render($options, $maxWidth) } } |