Private/Classes/30-HttpRequest.ps1

<#
    Object to facilitate sorting qualty-valued objects in a request header
    https://developer.mozilla.org/en-US/docs/Glossary/Quality_values
#>

class QualityValue
{
    # Mime type
    [string]$Value

    # Number of wildcards in the mime type
    [int]$WildcardCount

    # Q-Value
    [double]$QValue

    <#
        Construct from mime type and Q-Value
    #>

    QualityValue([string]$type, [double]$qValue)
    {
        $this.Value = $type
        $this.QValue = $qValue
        $this.WildcardCount = $type.Split('*').Length - 1
    }

    <#
        Construct from mime type only. Q-Value will be 1.0
    #>

    QualityValue([string]$type)
    {
        $this.Value = $type
        $this.QValue = 1
        $this.WildcardCount = $type.Split('*').Length - 1
    }

    <#
        Compare function for sorting as per https://developer.mozilla.org/en-US/docs/Glossary/Quality_values
    #>

    [int]CompareTo([QualityValue]$other)
    {
        if ($null -eq $other)
        {
            return 1
        }

        $cmp = $other.QValue.CompareTo($this.QValue)

        if ($cmp -ne 0)
        {
            # Q-Values are different - stop comparing here
            return $cmp
        }

        # If equal, compare wildcard count. More wildcards = lower precedence
        return $this.WildcardCount.CompareTo($other.WildcardCount)
    }

    [string]ToString()
    {
        return "$($this.Value);q=$($this.QValue)"
    }
}

<#
    Separate IComparer for QualityValue class
    Doesn't work when attempting to inherit QualityValue from IComparable<T>
#>

class QualityValueComparer : System.Collections.Generic.IComparer[QualityValue]
{
    static [QualityValueComparer]$Comparer = [QualityValueComparer]::new()

    QualityValueComparer()
    {
    }

    [int]Compare([QualityValue]$x, [QualityValue]$y)
    {
        if ($null -eq $x -and $null -eq $y)
        {
            return 0
        }

        if ($null -eq $x)
        {
            return 1
        }

        if ($null -eq $y)
        {
            return -1
        }

        return $x.CompareTo($y)
    }
}

<#
    Class to read and process an incoming HTTP request stream.
#>

class HttpRequest
{
    # HTTP request stream
    hidden [System.IO.Stream]$HttpStream

    static [string[]]$SupportedRequestMethods = @('HEAD', 'GET', 'POST', 'PUT', 'DELETE', 'OPTIONS')

    # Number of bytes read so far from stream
    [int]$BytesRead = 0

    # Request verb (GET etc.)
    [string]$RequestMethod

    # Request path (without any query string)
    [string]$Path

    # HTTP protocol version (1.0, 1.1 etc)
    [Version]$ProtocolVersion

    # Parsed query parameters (if any)
    [System.Collections.Specialized.NameValueCollection]$QueryParameters = $null

    # Parsed form parameters (if any)
    [System.Collections.Specialized.NameValueCollection]$FormParameters = $null

    # Parsed headers
    [hashtable]$Headers = @{}

    # List of Accept header mime types sorted by priority.
    [System.Collections.Generic.List[QualityValue]]$PrioritisedAcceptMimeTypes = [System.Collections.Generic.List[QualityValue]]::new()

    # List of Accept-Encoding types sorted by priority
    [System.Collections.Generic.List[QualityValue]]$PrioritisedAcceptEncodings = [System.Collections.Generic.List[QualityValue]]::new()

    <#
        Construct request object
    #>

    HttpRequest([System.IO.Stream]$httpStream)
    {
        $this.HttpStream = $httpStream
    }

    <#
        Read the incoming request stream and parse it
    #>

    [void]Read()
    {
        $sr = $null

        try
        {
            $sr = [System.IO.StreamReader]::new($this.HttpStream, [System.Text.Encoding]::UTF8, $true, 1024, $true)

            $requestLine = $sr.ReadLine()
            $this.BytesRead += $requestLine.Length + 2

            # Parse the request line, e.g. GET /some/resource?param=value HTTP/1.0
            if ($requestLine -match '^(?<verb>([A-Z]+))\s+(?<path>.*?)\s+HTTP/(?<protocol>\d\.\d)')
            {
                if ([HttpRequest]::SupportedRequestMethods -inotcontains $Matches.verb)
                {
                    throw [HttpException]::new(501, "$($Matches.verb) not supported")
                }

                $this.RequestMethod = $Matches.verb
                $this.ProtocolVersion = [Version]::Parse($Matches.protocol)
                ($this.Path, $query) = $Matches.path.Split('?')

                if ($null -ne $query)
                {
                    $this.QueryParameters = [System.Web.HttpUtility]::ParseQueryString($query)
                }
            }
            else
            {
                throw [HttpException]::new(400, "Invalid request")
            }

            # Read headers
            $this.Headers = @{}
            $line = $sr.ReadLine()
            $this.BytesRead += $line.Length + 2

            while (-not ([string]::IsNullOrWhiteSpace($line)))
            {
                ($key, $value) = $line -split ':'
                $this.Headers.Add($key, $value.Trim())
                $line = $sr.ReadLine()
                $this.BytesRead += $line.Length + 2
            }

            # Validate POST content-type.
            # TODO: Support multipart encoding
            if ($this.RequestMethod -ieq 'POST' -and $this.Headers.ContainsKey('Content-Type'))
            {
                switch (($this.Headers['Content-Type'] -split ';')[0])
                {
                    'application/x-www-form-urlencoded'
                    {
                        # Should read content by content length
                        $this.FormParameters = [System.Web.HttpUtility]::ParseQueryString($sr.ReadLine())
                    }

                    default
                    {
                        throw [HttpException]::new(400, "Content type $_ not supported")
                    }
                }
            }
        }
        finally
        {
            if ($sr)
            {
                $sr.Dispose()
            }
        }

        $this.ParseAcceptHeaders()
    }

    <#
        Test for CORS request by presense of Access-Control-Request headers
    #>

    [bool]IsCorsRequest()
    {
        return $null -ne ($this.Headers.Keys | Where-Object { $_ -ilike 'Access-Control-Request*' } | Select-Object -First 1)
    }

    <#
        Reform the request header as a string, primarily for dfebugging.
    #>

    [string]RequestHeader()
    {
        $sb = [System.Text.StringBuilder]::new()

        $pathAndQuery = $this.Path + $(
            if ($null -eq $this.QueryParameters)
            {
                [string]::Empty
            }
            else {
                '?' + $this.QueryParameters.ToString()
            }
        )

        $sb.AppendLine("$($this.RequestMethod) $($pathAndQuery) HTTP/$($this.ProtocolVersion.ToString())") | Out-Null
        $this.Headers.Keys |
        ForEach-Object {
            $sb.AppendLine("$($_): $($this.Headers[$_])") | Out-Null
        }

        return $sb.ToString()
    }

    <#
        Parse Accept and Accept-Encoding headers
    #>

    hidden [void]ParseAcceptHeaders()
    {
        if ($this.Headers.ContainsKey('Accept'))
        {
            $this.ParseQValueHeader('Accept', $this.PrioritisedAcceptMimeTypes)
        }
        else
        {
            # Assume the client will accept anything
            $this.PrioritisedAcceptMimeTypes.Add([QualityValue]::new('*/*'))
        }

        if ($this.Headers.ContainsKey('Accept-Encoding'))
        {
            $this.ParseQValueHeader('Accept-Encoding', $this.PrioritisedAcceptEncodings)
        }
        else
        {
            # If no Accept-Encoding, then assume client accepts 'identity'
            $this.PrioritisedAcceptEncodings.Add([QualityValue]::new('identity'))
        }
    }

    <#
        Parse Q-Value header into a sorted array
        https://developer.mozilla.org/en-US/docs/Glossary/Quality_values
    #>

    hidden [void]ParseQValueHeader([string]$headerName, [System.Collections.Generic.List[QualityValue]]$resultList)
    {
        $this.Headers[$headerName] -split ',' |
            ForEach-Object {
            $acc = $_.Trim()

            if ($acc -match '^(?<value>[\w\d\+\*/]+)(\s*\;\s*q=(?<qvalue>\d\.\d+))?')
            {
                if ($Matches.ContainsKey('qvalue'))
                {
                    $resultList.Add([QualityValue]::new($matches.value, [double]::Parse($matches.qvalue)))
                }
                else
                {
                    $resultList.Add([QualityValue]::new($matches.value))
                }
            }
        }

        $resultList.Sort([QualityValueComparer]::Comparer)
    }
}