lib/Polaris.Class.ps1
class Polaris { [int]$Port [System.Collections.Generic.List[PolarisMiddleWare]]$RouteMiddleWare = [System.Collections.Generic.List[PolarisMiddleWare]]::new() [System.Collections.Generic.Dictionary[string, [System.Collections.Generic.Dictionary[string, scriptblock]]]]$ScriptblockRoutes = [System.Collections.Generic.Dictionary[string, [System.Collections.Generic.Dictionary[string, scriptblock]]]]::new() hidden [Action[string]]$Logger hidden [System.Net.HttpListener]$Listener hidden [bool]$StopServer = $False [string]$GetLogsString = "PolarisLogs" [string]$ClassDefinitions = $Script:ClassDefinitions $ContextHandler = (New-ScriptblockCallback -Callback { param( [System.IAsyncResult] $AsyncResult ) [Polaris]$Polaris = $AsyncResult.AsyncState $Context = $Polaris.Listener.EndGetContext($AsyncResult) if ($Polaris.StopServer -or $null -eq $Context) { if ($null -ne $Polaris.Listener) { $Polaris.Listener.Close() } break } $Polaris.Listener.BeginGetContext($Polaris.ContextHandler, $Polaris) [System.Net.HttpListenerRequest]$RawRequest = $Context.Request [System.Net.HttpListenerResponse]$RawResponse = $Context.Response $Polaris.Log("request came in: " + $RawRequest.HttpMethod + " " + $RawRequest.RawUrl) [PolarisRequest]$Request = [PolarisRequest]::new($RawRequest) [PolarisResponse]$Response = [PolarisResponse]::new() [string]$Route = $RawRequest.Url.AbsolutePath [System.Management.Automation.InformationRecord[]]$InformationVariable = @() if ([string]::IsNullOrEmpty($Route)) { $Route = "/" } try { # Run middleware in the order in which it was added foreach ($Middleware in $Polaris.RouteMiddleware) { $InformationVariable += $Polaris.InvokeRoute( $Middleware.Scriptblock, $Null, $Request, $Response ) } $Polaris.Log("Parsed Route: $Route") $Polaris.Log("Request Method: $($RawRequest.HttpMethod)") $Routes = $Polaris.ScriptblockRoutes # # Searching for the first route that matches by the most specific route paths first. # $MatchingRoute = $Routes.keys | Sort-Object -Property Length -Descending | Where-Object { $Route -match [Polaris]::ConvertPathToRegex($_) } | Select-Object -First 1 $Request.Parameters = ([PSCustomObject]$Matches) Write-Debug "Parameters: $Parameters" $MatchingMethod = $false if ($MatchingRoute) { $MatchingMethod = $Routes[$MatchingRoute].keys -contains $Request.Method } if ($MatchingRoute -and $MatchingMethod) { try { $InformationVariable += $Polaris.InvokeRoute( $Routes[$MatchingRoute][$Request.Method], $Parameters, $Request, $Response ) } catch { $ErrorsBody += $_.Exception.ToString() $ErrorsBody += $_.InvocationInfo.PositionMessage + "`n`n" $Response.Send($ErrorsBody) $Polaris.Log($_) $Response.SetStatusCode(500) } } elseif ($MatchingRoute) { $Response.Send("Method not allowed") $Response.SetStatusCode(405) } else { $Response.Send("Not found") $Response.SetStatusCode(404) } # Handle logs if ($Request.Query -and $Request.Query[$Polaris.GetLogsString]) { $InformationBody = "`n" for ([int]$i = 0; $i -lt $InformationVariable.Count; $i++) { foreach ($tag in $InformationVariable[$i].Tags) { $InformationBody += "[" + $tag + "]" } $InformationBody += $InformationVariable[$i].MessageData.ToString() + "`n" } $InformationBody += "`n" # Set response to the logs and the actual response (could be errors) $LogBytes = [System.Text.Encoding]::UTF8.GetBytes($InformationBody) $Bytes = [byte[]]::new($LogBytes.Length + $Response.ByteResponse.Length) $LogBytes.CopyTo($Bytes, 0) $Response.ByteResponse.CopyTo($Bytes, $LogBytes.Length) $Response.ByteResponse = $Bytes } [Polaris]::Send($RawResponse, $Response) } catch { $Polaris.Log(($_ | Out-String)) $Response.SetStatusCode(500) $Response.Send($_) try{ [Polaris]::Send($RawResponse, $Response) } catch { $Polaris.Log($_) } $Polaris.Log($_) } }) hidden [object] InvokeRoute ( [Scriptblock]$Route, [PSCustomObject]$Parameters, [PolarisRequest]$Request, [PolarisResponse]$Response ) { $InformationVariable = "" $Scriptblock = [scriptblock]::Create( "param(`$Parameters,`$Request,`$Response)`r`n" + $Route.ToString() ) Invoke-Command -Scriptblock $Scriptblock ` -ArgumentList @($Parameters, $Request, $Response) ` -InformationVariable InformationVariable ` -ErrorAction Stop return $InformationVariable } [void] AddRoute ( [string]$Path, [string]$Method, [scriptblock]$Scriptblock ) { if ($null -eq $Scriptblock) { throw [ArgumentNullException]::new("scriptBlock") } [string]$SanitizedPath = [Polaris]::SanitizePath($Path) if (-not $this.ScriptblockRoutes.ContainsKey($SanitizedPath)) { $this.ScriptblockRoutes[$SanitizedPath] = [System.Collections.Generic.Dictionary[string, string]]::new() } $this.ScriptblockRoutes[$SanitizedPath][$Method] = $Scriptblock } RemoveRoute ( [string]$Path, [string]$Method ) { if ($null -eq $Path) { throw [ArgumentNullException]::new("path") } if ($null -eq $Method) { throw [ArgumentNullException]::new("method") } [string]$SanitizedPath = [Polaris]::SanitizePath($Path) $this.ScriptblockRoutes[$SanitizedPath].Remove($Method) if ($this.ScriptblockRoutes[$SanitizedPath].Count -eq 0) { $this.ScriptblockRoutes.Remove($SanitizedPath) } } static [string] SanitizePath([string]$Path){ $SanitizedPath = $Path.TrimEnd('/') if ([string]::IsNullOrEmpty($SanitizedPath)) { $SanitizedPath = "/" } return $SanitizedPath } static [RegEx] ConvertPathToRegex([string]$Path){ Write-Debug "Path: $path" # Replacing all periods with an escaped period to prevent regex wildcard $path = $path -replace '\.', '\.' # Replacing all - with \- to escape the dash $path = $path -replace '-', '\-' # Replacing the wildcard character * with a regex aggressive match .* $path = $path -replace '\*', '.*' # Creating a strictly matching regular expression that must match beginning (^) to end ($) $path = "^$path$" # Creating a route based parameter # Match any and all word based characters after the : for the name of the parameter # Use the name in a named capture group that will show up in the $matches variable # References: # - https://docs.microsoft.com/en-us/dotnet/standard/base-types/grouping-constructs-in-regular-expressions#named_matched_subexpression # - https://technet.microsoft.com/en-us/library/2007.11.powershell.aspx # - https://docs.microsoft.com/en-us/powershell/module/microsoft.powershell.core/about/about_automatic_variables?view=powershell-6#matches $path = $path -replace ":(\w+)(\?{0,1})", '(?<$1>.+)$2' Write-Debug "Parsed Regex: $path" return [RegEx]::New($path) } static [RegEx] ConvertPathToRegex([RegEx]$Path){ Write-Debug "Path is a RegEx" return $Path } AddMiddleware ( [string]$Name, [scriptblock]$Scriptblock ) { if ($null -eq $Scriptblock) { throw [ArgumentNullException]::new("scriptBlock") } $this.RouteMiddleware.Add([PolarisMiddleware]@{ 'Name' = $Name 'Scriptblock' = $Scriptblock }) } RemoveMiddleware ([string]$Name) { if ($null -eq $Name) { throw [ArgumentNullException]::new("name") } $this.RouteMiddleware.RemoveAll( [Predicate[PolarisMiddleWare]]([scriptblock]::Create("`$args[0].Name -eq '$Name'")) ) } [void] Start ( [int]$Port = 3000 ) { $this.StopServer = $false $this.InitListener($Port) $this.Listener.BeginGetContext($this.ContextHandler, $this) $this.Log("App listening on Port: " + $Port + "!") } [void] Stop () { $this.StopServer = $true $this.Listener.Close() $this.Listener.Dispose() $this.Log("Server Stopped.") } [void] InitListener ([int]$Port) { $this.Port = $Port $this.Listener = [System.Net.HttpListener]::new() # If user is on a non-windows system or windows as administrator if ([System.Environment]::OSVersion.Platform -ne [System.PlatformID]::Win32NT -or ([System.Environment]::OSVersion.Platform -eq [System.PlatformID]::Win32NT -and ([System.Security.Principal.WindowsPrincipal]::new([System.Security.Principal.WindowsIdentity]::GetCurrent())).IsInRole([System.Security.Principal.WindowsBuiltInRole]::Administrator))) { $this.Listener.Prefixes.Add("http://+:" + $this.Port + "/") } else { $this.Listener.Prefixes.Add("http://localhost:" + $this.Port + "/") } $this.Listener.IgnoreWriteExceptions = $true if([System.Environment]::OSVersion.Platform -eq [System.PlatformID]::Win32NT -and $this.Listener.TimeoutManager){ $this.Listener.TimeoutManager.RequestQueue = [timespan]::FromMinutes(5) $this.Listener.TimeoutManager.IdleConnection = [timespan]::FromSeconds(45) $this.Listener.TimeoutManager.EntityBody = [timespan]::FromSeconds(50) $this.Listener.TimeoutManager.HeaderWait = [timespan]::FromSeconds(5) } $this.Listener.Start() } static [void] Send ( [System.Net.HttpListenerResponse]$RawResponse, [PolarisResponse]$Response ) { if ($Response.StreamResponse) { [Polaris]::Send( $RawResponse, $Response.StreamResponse, $Response.StatusCode, $Response.ContentType, $Response.Headers ) } else { [Polaris]::Send( $RawResponse, $Response.ByteResponse, $Response.StatusCode, $Response.ContentType, $Response.Headers ) } } static [void] Send ( [System.Net.HttpListenerResponse]$RawResponse, [byte[]]$ByteResponse, [int]$StatusCode, [string]$ContentType, [System.Net.WebHeaderCollection]$Headers ) { $RawResponse.StatusCode = $StatusCode; $RawResponse.Headers = $Headers; $RawResponse.ContentType = $ContentType; $RawResponse.ContentLength64 = $ByteResponse.Length; $RawResponse.OutputStream.Write($ByteResponse, 0, $ByteResponse.Length); $RawResponse.OutputStream.Close(); } static [void] Send ( [System.Net.HttpListenerResponse]$RawResponse, [System.IO.Stream]$StreamResponse, [int]$StatusCode, [string]$ContentType, [System.Net.WebHeaderCollection]$Headers ) { $RawResponse.StatusCode = $StatusCode; $RawResponse.Headers = $Headers; $RawResponse.ContentType = $ContentType; $StreamResponse.CopyTo($RawResponse.OutputStream); $RawResponse.OutputStream.Close(); } static [void] Send ( [System.Net.HttpListenerResponse]$RawResponse, [byte[]]$ByteResponse, [int]$StatusCode, [string]$ContentType ) { [Polaris]::Send($RawResponse, $ByteResponse, $StatusCode, $ContentType, $Null) } [void] Log ([string]$LogString) { try { $this.Logger($LogString) } catch { Write-Host $_.Message Write-Host $LogString } } Polaris ( [Action[string]]$Logger ) { if ($Logger) { $this.Logger = $Logger } else { $this.Logger = { param($LogItem) Write-Host $LogItem } } } } |