Private/Classes/35-HttpResponse.ps1

<#
    Handles formation of response to client
#>

class HttpResponse
{
    # Culture for formatting response timestamps
    static hidden [System.Globalization.CultureInfo]$CultureEnUS = [System.Globalization.CultureInfo]::CreateSpecificCulture("en-US")

    # Default server identifier. Set to specific value during init.
    static hidden [string]$ServerIdentifier = 'PowerShellRest/0.0'

    # Status code for response
    [HttpStatus]$Status

    # Resonse body text
    [string]$Body

    # Encoded body text
    [byte[]]$BodyCompressed = $null

    # Default content type
    [string]$ContentType = 'text/plain'

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

    # Number of bytes sent to client
    [int]$BytesSent = 0

    # If a .NET exception (not one defined in this module) was caught, it is stored here for reporting to the error log file
    [Exception]$UnderlyingException = $null

    # Original request verb (GET etc.)
    [HttpRequest]$Request

    HttpResponse([HttpStatus]$status, [HttpRequest]$request)
    {
        $this.Request = $request
        $this.Status = $status
        $this.AddDefaultHeaders()
    }

    HttpResponse([HttpStatus]$status, [string]$body, [string]$contentType, [HttpRequest]$request)
    {
        $this.Request = $request
        $this.Status = $status
        $this.Body = $body

        if (-not [string]::IsNullOrEmpty($contentType))
        {
            $this.ContentType = $contentType
        }

        $this.AddDefaultHeaders()
        $this.ApplyEncoding()
    }

    HttpResponse([HttpException]$exception, [HttpRequest]$request)
    {
        $this.Request = $request

        $stat = [HttpStatus]::GetStatus($exception.StatusCode)

        if ($null -eq $stat)
        {
            $this.Status = [HttpStatus]::InternalServerError
            $this.Body = "Unsupported status code $($exception.StatusCode)"
        }
        else
        {
            $this.Status = $stat
            $this.Body = $exception.Message
        }

        $this.AddDefaultHeaders()
    }

    HttpResponse([HttpStatus]$status, [Exception]$underlyingException, [HttpRequest]$request)
    {
        $this.Request = $request
        $this.Status = $status
        $this.Body = $status.StatusMessage
        $this.UnderlyingException = $underlyingException
        $this.ContentType = 'text/plain'

        $this.AddDefaultHeaders()
    }

    <#
        Called during initialisation to set the 'Server' header for responses
    #>

    static [void]SetServerIdentifier([string]$ServerIdentifier)
    {
        [HttpResponse]::ServerIdentifier = $ServerIdentifier
    }

    <#
        Gets the body text according to the request verb
        i.e. HEAD = don't return content
    #>

    [string]GetBodyText()
    {
        if (('OPTIONS', 'HEAD') -icontains $this.Request.RequestMethod -or [string]::IsNullOrEmpty($this.Body))
        {
            return [string]::Empty
        }

        return $this.Body
    }

    <#
        Add or replace a response header
    #>

    [HttpResponse]AddHeader([string]$name, [string]$value)
    {
        $this.Headers[$name] = $value
        return $this
    }

    [string]ToString()
    {
        return $this.GetHeaderString() + $this.GetBodyText()
    }

    <#
        Format the header block (up to where the content begins) as a single string
    #>

    [string]GetHeaderString()
    {
        $sb = New-Object System.Text.StringBuilder

        $stat = $this.Status.StatusCode.ToString() + ' ' + $this.Status.StatusMessage
        $sb.Append("HTTP/1.1 $($stat)`r`n") | Out-Null

        $this.Headers.Keys |
        ForEach-Object {
            $val = $this.Headers[$_]
            $sb.Append("$($_): $val`r`n") | Out-Null
        }

        $sb.Append("`r`n") | Out-Null

        return $sb.ToString()
    }

    <#
        Get the response as a byte array for transmission to client
    #>

    [byte[]]GetBytes()
    {
        $bytes = [System.Text.Encoding]::UTF8.GetBytes($this.GetHeaderString()) +
        $(
            if ($null -ne $this.BodyCompressed)
            {
                #Write-EventLog -LogName Application -Source PowerShellRest -EventId 0x0f -EntryType Information -Message "Returning compressed response"
                $this.BodyCompressed
            }
            else
            {
                #Write-EventLog -LogName Application -Source PowerShellRest -EventId 0x0f -EntryType Information -Message "Returning uncompressed response"
                [System.Text.Encoding]::UTF8.GetBytes($this.GetBodyText())
            }
        )

        $this.BytesSent = $bytes.Length
        return $bytes
    }

    <#
        Apply encoding according to Accept-Encoding request header
    #>

    hidden [void]ApplyEncoding()
    {
        $bodyText = $this.GetBodyText()

        if ([string]::IsNullOrEmpty($bodyText))
        {
            return
        }

        foreach ($encoding in $this.Request.PrioritisedAcceptEncodings)
        {
            $algorithm = $encoding.Value

            if (('*', 'identity') -icontains $algorithm)
            {
                return
            }

            $compressor = New-Compressor -Algorithm $algorithm

            if ($compressor)
            {
                $this.BodyCompressed = $compressor.Compress($bodyText)
                $this.AddHeader('Content-Encoding', $algorithm.ToLowerInvariant())
                $this.AddHeader('Content-Length', $this.BodyCompressed.Length.ToString())
                return
            }
        }

        # If we get here, then specific Accept-Encoding was supplied without 'identity' or '*' as an option
        throw [HttpException]::new([HttpStatus]::NotAcceptable)
    }

    <#
        Set the default headers on the response
    #>

    hidden [void]AddDefaultHeaders()
    {
        $dateAsString = [DateTime]::UtcNow.ToString('r', [HttpResponse]::CultureEnUS)
        $contentLength = $(
            if ([string]::IsNullOrEmpty($this.Body))
            {
                0
            }
            else
            {
                [System.Text.Encoding]::UTF8.GetBytes($this.Body).Length
            }
        )

        $this.Headers.Add('Content-Length', $contentLength.ToString())
        $this.Headers.Add('Server', [HttpResponse]::ServerIdentifier)
        $this.Headers.Add('Date', $dateAsString)
        $this.Headers.Add('Last-Modified', $dateAsString)
        $this.Headers.Add('Content-Type', $this.ContentType)
        $this.Headers.Add('Connection', 'Closed')
    }
}

<#
    Subclass of HttpResponse to indicate the server wants to terminate
#>

class TerminationResponse : HttpResponse
{
    TerminationResponse([HttpRequest]$request) : base([HttpStatus]::ServiceUnavailable, "The server is shutting down", $request)
    {
    }
}