Content Negotiation

Write-KrResponse provides content negotiation and automatic format selection based on the client’s Accept header. It chooses the appropriate response format (JSON, XML, text, etc.) and sets the correct Content-Type header.

Full source

File: pwsh/tutorial/examples/9.9-Content-Negotiation.ps1

<#
    Sample: Content Negotiation
    Purpose: Demonstrate content negotiation and automatic format selection in a Kestrun server.
    File:    9.9-Content-Negotiation.ps1
    Notes:   Shows how Write-KrResponse chooses format based on Accept header.
#>
param(
    [int]$Port = 5000,
    [IPAddress]$IPAddress = [IPAddress]::Loopback
)
# 1. Logging
New-KrLogger | Add-KrSinkConsole | Register-KrLogger -Name 'console' -SetAsDefault

# 2. Server
New-KrServer -Name 'Responses 9.9'

# 3. Listener
Add-KrEndpoint -IPAddress $IPAddress -Port $Port


# Finalize configuration
Enable-KrConfiguration

# Content negotiation route
Add-KrMapRoute -Pattern '/negotiate' -Verbs GET -ScriptBlock {
    $data = @{
        message = 'Hello World'
        timestamp = (Get-Date).ToUniversalTime()
        id = 123
    }
    Write-KrResponse -InputObject $data -StatusCode 200
}

# Explicit format route
Add-KrMapRoute -Pattern '/explicit' -Verbs GET -ScriptBlock {
    $text = 'Explicit plain text response'
    Write-KrResponse -InputObject $text -StatusCode 200 -ContentType 'text/plain'
}

# Error response route
Add-KrMapRoute -Pattern '/error' -Verbs GET -ScriptBlock {
    $errorData = @{
        error = 'Not Found'
        code = 404
        details = 'The requested resource was not found'
    }
    Write-KrResponse -InputObject $errorData -StatusCode 404
}

# Start the server
Start-KrServer

Step-by-step

  1. Logging: Register console logger as default.
  2. Server: Create server named ‘Responses 9.9’.
  3. Listener: Listen on 127.0.0.1:5000.
  4. /negotiate: Uses Write-KrResponse with object, format determined by Accept header.
  5. /explicit: Uses Write-KrResponse with explicit ContentType override.
  6. /error: Uses Write-KrResponse with error object and 404 status code.
  7. Enable configuration and start server.

Try it

# Test content negotiation - JSON (default)
curl -i http://127.0.0.1:5000/negotiate

# Request JSON explicitly
curl -i -H "Accept: application/json" http://127.0.0.1:5000/negotiate

# Request XML format
curl -i -H "Accept: application/xml" http://127.0.0.1:5000/negotiate

# Request plain text
curl -i -H "Accept: text/plain" http://127.0.0.1:5000/negotiate

# Explicit content type
curl -i http://127.0.0.1:5000/explicit

# Error response
curl -i http://127.0.0.1:5000/error

PowerShell equivalents:

# Test content negotiation - JSON (default)
Invoke-WebRequest -Uri http://127.0.0.1:5000/negotiate | Select-Object -ExpandProperty Headers | Format-List

# Request JSON explicitly
$headers = @{ 'Accept' = 'application/json' }
Invoke-WebRequest -Uri http://127.0.0.1:5000/negotiate -Headers $headers | Select-Object Content, Headers

# Request XML format
$headers = @{ 'Accept' = 'application/xml' }
Invoke-WebRequest -Uri http://127.0.0.1:5000/negotiate -Headers $headers | Select-Object Content, Headers

# Request plain text
$headers = @{ 'Accept' = 'text/plain' }
Invoke-WebRequest -Uri http://127.0.0.1:5000/negotiate -Headers $headers | Select-Object Content, Headers

# Explicit content type
Invoke-WebRequest -Uri http://127.0.0.1:5000/explicit | Select-Object Content, Headers

# Error response (use SkipHttpErrorCheck to avoid exception on 404)
Invoke-WebRequest -Uri http://127.0.0.1:5000/error -SkipHttpErrorCheck | Select-Object StatusCode, Content

Content Format Selection

Automatic Format Selection

Write-KrResponse examines the client’s Accept header and chooses the best matching format:

Accept Header Response Format Content-Type
application/json JSON application/json; charset=utf-8
application/xml XML application/xml; charset=utf-8
text/plain Plain text text/plain; charset=utf-8
text/html HTML (if supported) text/html; charset=utf-8
*/* or missing JSON (default) application/json; charset=utf-8

Format Examples

$data = @{ name = 'John'; age = 30; active = $true }

# With Accept: application/json
# Response: {"name":"John","age":30,"active":true}
# Content-Type: application/json; charset=utf-8

# With Accept: application/xml
# Response: <Object><name>John</name><age>30</age><active>true</active></Object>
# Content-Type: application/xml; charset=utf-8

# With Accept: text/plain
# Response: name=John, age=30, active=True
# Content-Type: text/plain; charset=utf-8

Advanced Usage

Explicit Content-Type Override

Add-KrMapRoute -Pattern '/custom' -Verbs GET -ScriptBlock {
    $csv = "Name,Age`nJohn,30`nJane,25"
    Write-KrResponse -InputObject $csv -ContentType 'text/csv' -StatusCode 200
}

Error Responses with Negotiation

Add-KrMapRoute -Pattern '/api/users/:id' -Verbs GET -ScriptBlock {
    $id = $Context.Request.RouteValues['id']
    $user = Get-User -Id $id

    if (-not $user) {
        $errorResponse = @{
            error = 'User not found'
            code = 'USER_NOT_FOUND'
            id = $id
            timestamp = (Get-Date).ToUniversalTime()
        }
        Write-KrResponse -InputObject $errorResponse -StatusCode 404
        return
    }

    Write-KrResponse -InputObject $user -StatusCode 200
}

Complex Object Serialization

Add-KrMapRoute -Pattern '/api/report' -Verbs GET -ScriptBlock {
    $report = @{
        summary = @{
            totalUsers = 1250
            activeUsers = 980
            newUsers = 45
        }
        data = @(
            @{ date = '2024-01-01'; count = 100 }
            @{ date = '2024-01-02'; count = 150 }
            @{ date = '2024-01-03'; count = 120 }
        )
        metadata = @{
            generatedAt = (Get-Date).ToUniversalTime()
            version = '1.0'
        }
    }

    Write-KrResponse -InputObject $report -StatusCode 200
}

When to Use Write-KrResponse

Scenario Benefit
REST APIs Automatic JSON/XML negotiation
Multi-format endpoints Single endpoint serves multiple formats
Error responses Consistent error format across clients
Complex objects Automatic serialization handling
Client flexibility Let clients choose preferred format

When to Use Specialized Helpers Instead

Use This Instead of Write-KrResponse When
Write-KrJsonResponse Write-KrResponse with JSON objects Always want JSON, regardless of Accept header
Write-KrTextResponse Write-KrResponse with strings Always want plain text
Write-KrXmlResponse Write-KrResponse with XML Always want XML format
Write-KrHtmlResponse Write-KrResponse with HTML Always want HTML

Status Codes

# Success responses
Write-KrResponse -InputObject $data -StatusCode 200    # OK
Write-KrResponse -InputObject $newData -StatusCode 201 # Created
Write-KrResponse -InputObject $null -StatusCode 204    # No Content

# Client error responses
Write-KrResponse -InputObject $errorData -StatusCode 400 # Bad Request
Write-KrResponse -InputObject $authError -StatusCode 401 # Unauthorized
Write-KrResponse -InputObject $notFound -StatusCode 404  # Not Found
Write-KrResponse -InputObject $conflict -StatusCode 409  # Conflict

# Server error responses
Write-KrResponse -InputObject $serverError -StatusCode 500 # Internal Server Error
Write-KrResponse -InputObject $maintenance -StatusCode 503 # Service Unavailable

Best Practices

1. Consistent Error Format

function Write-ApiError {
    param(
        [string]$Message,
        [string]$Code,
        [int]$StatusCode = 400,
        [hashtable]$Details = @{}
    )

    $errorResponse = @{
        error = @{
            message = $Message
            code = $Code
            timestamp = (Get-Date).ToUniversalTime().ToString('O')
            details = $Details
        }
    }

    Write-KrResponse -InputObject $errorResponse -StatusCode $StatusCode
}

# Usage
Add-KrMapRoute -Pattern '/api/validate' -Verbs POST -ScriptBlock {
    $input = $Context.Request.Body | ConvertFrom-Json

    if (-not $input.email) {
        Write-ApiError -Message 'Email is required' -Code 'MISSING_EMAIL' -StatusCode 400
        return
    }

    if ($input.email -notmatch '^[^@]+@[^@]+\.[^@]+$') {
        Write-ApiError -Message 'Invalid email format' -Code 'INVALID_EMAIL' -StatusCode 400 -Details @{ email = $input.email }
        return
    }

    Write-KrResponse -InputObject @{ message = 'Valid email' } -StatusCode 200
}

2. Pagination Support

Add-KrMapRoute -Pattern '/api/users' -Verbs GET -ScriptBlock {
    $page = [int]($Context.Request.Query['page'] ?? 1)
    $limit = [int]($Context.Request.Query['limit'] ?? 10)
    $offset = ($page - 1) * $limit

    $users = Get-Users -Offset $offset -Limit $limit
    $totalCount = Get-UserCount

    $response = @{
        data = $users
        pagination = @{
            page = $page
            limit = $limit
            total = $totalCount
            pages = [Math]::Ceiling($totalCount / $limit)
            hasNext = ($page * $limit) -lt $totalCount
            hasPrev = $page -gt 1
        }
    }

    Write-KrResponse -InputObject $response -StatusCode 200
}

Troubleshooting

Symptom Cause Fix
Wrong format returned Accept header not sent Send appropriate Accept header
Unexpected content type Explicit ContentType overrides Remove or adjust -ContentType
XML missing fields Complex objects serialization Simplify object shape for XML

References


Previous / Next

Previous: Caching & Revalidation Next: Low-Level Response Stream