Component Headers
Demonstrates how to define reusable header components for OpenAPI responses.
Kestrun supports reusable response header components by:
- Defining headers under
components/headersusingNew-KrOpenApiHeader+Add-KrOpenApiComponent - Attaching them to responses with
OpenApiResponseHeaderRef(emits a$refto the header component) - Setting the actual HTTP headers at runtime via
$Context.Response.Headers[...]
Response headers vs header parameters
OpenAPI has two different (often confused) concepts:
- Response headers (this page): documented under
responses[status].headersand defined undercomponents.headers. - Header parameters (request inputs): documented under
parametersand (optionally) defined undercomponents.parameterswithin: header.
Use response headers for things like Location, ETag, Retry-After, X-Correlation-Id. Use header parameters for request inputs like X-Api-Key.
Full source
File: pwsh/tutorial/examples/10.9-OpenAPI-Component-Header.ps1
<#
Sample: OpenAPI Header Components
Purpose: Demonstrate reusable *response header components* and referencing them from responses.
File: 10.9-OpenAPI-Component-Header.ps1
Notes:
- Defines header components under components/headers via New-KrOpenApiHeader | Add-KrOpenApiComponent
- Applies them to responses via OpenApiResponseHeaderRef (component $ref or Inline clone)
- Uses OpenApiResponseHeader for one-off inline response headers
- Sets headers at runtime via $Context.Response.Headers
#>
param(
[int]$Port = 5000,
[IPAddress]$IPAddress = [IPAddress]::Loopback
)
if (-not (Get-Module Kestrun)) { Import-Module Kestrun }
# --- Logging / Server ---
New-KrLogger | Add-KrSinkConsole |
Set-KrLoggerLevel -Value Debug |
Register-KrLogger -Name 'console' -SetAsDefault
New-KrServer -Name 'OpenAPI Component Headers'
Add-KrEndpoint -Port $Port -IPAddress $IPAddress
# =========================================================
# TOP-LEVEL OPENAPI
# =========================================================
Add-KrOpenApiInfo -Title 'Component Headers API' `
-Version '1.0.0' `
-Description 'Demonstrates reusable response header components (correlation, location, etag, rate-limit, retry-after) and inline headers.'
Add-KrOpenApiTag -Name 'Users' -Description 'User CRUD operations.'
Add-KrOpenApiTag -Name 'Operational' -Description 'Operational behaviors (rate limit / retry).'
# =========================================================
# COMPONENT SCHEMAS
# =========================================================
[OpenApiSchemaComponent(Description = 'Create user request', RequiredProperties = ('firstName', 'lastName', 'email'))]
class CreateUserRequest {
[OpenApiPropertyAttribute(Description = 'First name', Example = 'Jane')]
[string]$firstName
[OpenApiPropertyAttribute(Description = 'Last name', Example = 'Doe')]
[string]$lastName
[OpenApiPropertyAttribute(Description = 'Email address', Format = 'email', Example = 'jane.doe@example.com')]
[string]$email
}
[OpenApiSchemaComponent(Description = 'User resource', RequiredProperties = ('id', 'firstName', 'lastName', 'email', 'updatedAt'))]
class UserResponse {
[OpenApiPropertyAttribute(Description = 'Unique user identifier', Format = 'int64', Example = 1)]
[long]$id
[OpenApiPropertyAttribute(Description = 'First name', Example = 'Jane')]
[string]$firstName
[OpenApiPropertyAttribute(Description = 'Last name', Example = 'Doe')]
[string]$lastName
[OpenApiPropertyAttribute(Description = 'Email address', Format = 'email', Example = 'jane.doe@example.com')]
[string]$email
[OpenApiPropertyAttribute(Description = 'ISO 8601 update timestamp', Format = 'date-time')]
[string]$updatedAt
}
# =========================================================
# IN-MEMORY STORE (THREAD-SAFE)
# =========================================================
# Variables defined before Enable-KrConfiguration are automatically available to routes.
# Because routes may execute concurrently, shared mutable state must be thread-safe.
$Users = [hashtable]::Synchronized(@{})
$UserCounters = [hashtable]::Synchronized(@{ NextUserId = 0 })
$ThrottleCounters = [hashtable]::Synchronized(@{}) # key: ip string, value: count
# =========================================================
# HELPERS (THROTTLING)
# =========================================================
<#
.SYNOPSIS
Gets a client key for throttling (based on IP address).
.OUTPUTS
[string] The client key.
#>
function Get-ClientKey {
$ip = $Context.Connection.RemoteIpAddress
if ($null -eq $ip) { return 'unknown' }
return $ip.ToString()
}
<#
.SYNOPSIS
Determines if the current request should be throttled.
.OUTPUTS
[bool] True if the request should be throttled; otherwise, false.
#>
function Test-Throttle {
# Demo throttle: allow first 3 requests per client, then return 429.
$key = Get-ClientKey
[System.Threading.Monitor]::Enter($ThrottleCounters.SyncRoot)
try {
if (-not $ThrottleCounters.ContainsKey($key)) { $ThrottleCounters[$key] = 0 }
$ThrottleCounters[$key] = [int]$ThrottleCounters[$key] + 1
return ([int]$ThrottleCounters[$key] -gt 3)
} finally {
[System.Threading.Monitor]::Exit($ThrottleCounters.SyncRoot)
}
}
<#
.SYNOPSIS
Sets operational headers for the response.
.PARAMETER Limit
The rate limit value.
.PARAMETER Remaining
The remaining requests value.
.PARAMETER ResetSeconds
The reset time in seconds.
.PARAMETER CorrelationId
The correlation ID value.
.OUTPUTS
None
#>
function Add-DemoOperationalHeader {
param(
[Parameter(Mandatory)]
[int]$Limit,
[Parameter(Mandatory)]
[int]$Remaining,
[Parameter(Mandatory)]
[int]$ResetSeconds,
[Parameter(Mandatory)]
[string]$CorrelationId
)
$Context.Response.Headers['X-Correlation-Id'] = $CorrelationId
$Context.Response.Headers['X-RateLimit-Limit'] = "$Limit"
$Context.Response.Headers['X-RateLimit-Remaining'] = "$Remaining"
$Context.Response.Headers['X-RateLimit-Reset'] = "$ResetSeconds"
}
# =========================================================
# COMPONENT HEADERS (reusable)
# =========================================================
# Correlation id (traceability)
$correlationExamples = @{
'uuid' = New-KrOpenApiExample -Summary 'Correlation id' -Value '7b2a8e5d-0d7c-4f0a-9b3c-3f9d0b8ad7b1'
}
New-KrOpenApiHeader `
-Description 'Correlation id for tracing the request across services.' `
-Schema ([string]) `
-Required `
-Examples $correlationExamples |
Add-KrOpenApiComponent -Name 'X-Correlation-Id'
# Location header for 201 Created
New-KrOpenApiHeader `
-Description 'Canonical URI of the created resource.' `
-Schema ([string]) `
-Required |
Add-KrOpenApiComponent -Name 'Location'
# ETag header for caching / optimistic concurrency
$etagExamples = @{
'weak' = New-KrOpenApiExample -Summary 'Weak ETag' -Value 'W/"user-1-v3"'
}
New-KrOpenApiHeader `
-Description 'Entity tag representing the current version of the resource.' `
-Schema ([string]) `
-Examples $etagExamples |
Add-KrOpenApiComponent -Name 'ETag'
# Simple rate limit headers (demo)
New-KrOpenApiHeader -Description 'Maximum requests allowed in the current window.' -Schema ([int]) |
Add-KrOpenApiComponent -Name 'X-RateLimit-Limit'
New-KrOpenApiHeader -Description 'Remaining requests in the current window.' -Schema ([int]) `
-Extensions ([ordered]@{
'x-kestrun-demo' = [ordered]@{
exampleRemaining = 1
computedPer = 'client-ip'
windowSeconds = 60
}
}) |
Add-KrOpenApiComponent -Name 'X-RateLimit-Remaining'
New-KrOpenApiHeader -Description 'Seconds until the window resets.' -Schema ([int]) `
-Extensions ([ordered]@{
'x-kestrun-demo' = [ordered]@{
resetSeconds = 60
correlationIdExample = '7b2a8e5d-0d7c-4f0a-9b3c-3f9d0b8ad7b1'
}
}) |
Add-KrOpenApiComponent -Name 'X-RateLimit-Reset'
# Retry-After for 429 Too Many Requests
New-KrOpenApiHeader -Description 'Seconds to wait before retrying the request.' -Schema ([int]) | Add-KrOpenApiComponent -Name 'Retry-After'
# =========================================================
# ENABLE SERVER + OPENAPI
# =========================================================
Enable-KrConfiguration
Add-KrApiDocumentationRoute -DocumentType Swagger
Add-KrApiDocumentationRoute -DocumentType Redoc
# =========================================================
# ROUTES / OPERATIONS
# =========================================================
<#
.SYNOPSIS
Create a new user.
.DESCRIPTION
Accepts user information and returns the created user with an assigned ID.
.PARAMETER body
User creation request payload
.NOTES
Returns 201 Created with UserResponse schema and a custom header.
POST endpoint: Accept CreateUserRequest, return UserResponse
#>
function createUser {
[OpenApiPath(HttpVerb = 'post', Pattern = '/users', Tags = 'Users')]
[OpenApiResponse(StatusCode = '201', Description = 'Created', Schema = [UserResponse], ContentType = ('application/json'))]
[OpenApiResponseHeaderRef(StatusCode = '201', Key = 'X-Correlation-Id', ReferenceId = 'X-Correlation-Id')]
[OpenApiResponseHeaderRef(StatusCode = '201', Key = 'Location', ReferenceId = 'Location')]
[OpenApiResponseHeaderRef(StatusCode = '201', Key = 'ETag', ReferenceId = 'ETag')]
[OpenApiResponseHeaderRef(StatusCode = '201', Key = 'X-RateLimit-Limit', ReferenceId = 'X-RateLimit-Limit')]
[OpenApiResponseHeaderRef(StatusCode = '201', Key = 'X-RateLimit-Remaining', ReferenceId = 'X-RateLimit-Remaining')]
[OpenApiResponseHeaderRef(StatusCode = '201', Key = 'X-RateLimit-Reset', ReferenceId = 'X-RateLimit-Reset')]
[OpenApiResponse(StatusCode = '400', Description = 'Invalid input')]
[OpenApiResponseHeader(StatusCode = '400', Key = 'X-Error-Code', Description = 'Machine-readable error code.', Schema = ([string]))]
[OpenApiResponse(StatusCode = '429', Description = 'Too many requests')]
[OpenApiResponseHeaderRef(StatusCode = '429', Key = 'Retry-After', ReferenceId = 'Retry-After')]
param(
[OpenApiRequestBody(ContentType = ('application/json', 'application/xml', 'application/yaml', 'application/x-www-form-urlencoded'))]
[CreateUserRequest]$body
)
$correlationId = [Guid]::NewGuid().ToString()
Add-DemoOperationalHeader -Limit 3 -Remaining 1 -ResetSeconds 60 -CorrelationId $correlationId
if (Test-Throttle) {
$Context.Response.Headers['Retry-After'] = '30'
Write-KrJsonResponse @{ error = 'Too many requests'; retryAfterSeconds = 30 } -StatusCode 429
return
}
# Simple validation
if (-not $body.firstName -or -not $body.lastName -or -not $body.email) {
$Context.Response.Headers['X-Error-Code'] = 'VALIDATION_FAILED'
Write-KrJsonResponse @{ error = 'firstName, lastName, and email are required' } -StatusCode 400
return
}
$newId = Update-KrSynchronizedCounter -Table $UserCounters -Key 'NextUserId' -By 1
$user = [UserResponse]::new()
$user.id = [long]$newId
$user.firstName = $body.firstName
$user.lastName = $body.lastName
$user.email = $body.email
$user.updatedAt = (Get-Date).ToUniversalTime().ToString('o')
[System.Threading.Monitor]::Enter($Users.SyncRoot)
try {
$Users[[string]$newId] = $user
} finally {
[System.Threading.Monitor]::Exit($Users.SyncRoot)
}
$Context.Response.Headers['Location'] = "/users/$newId"
$Context.Response.Headers['ETag'] = "W/`"user-$newId-v1`""
Write-KrResponse $user -StatusCode 201
}
<#
.SYNOPSIS
Get user by ID.
.DESCRIPTION
Retrieves a user resource by its identifier.
.PARAMETER userId
The user ID to retrieve
#>
function getUser {
[OpenApiPath(HttpVerb = 'get', Pattern = '/users/{userId}', Tags = 'Users')]
[OpenApiResponse(StatusCode = '200', Description = 'Found', Schema = [UserResponse], ContentType = ('application/json'))]
[OpenApiResponseHeaderRef(StatusCode = '200', Key = 'X-Correlation-Id', ReferenceId = 'X-Correlation-Id')]
[OpenApiResponseHeaderRef(StatusCode = '200', Key = 'ETag', ReferenceId = 'ETag')]
[OpenApiResponseHeaderRef(StatusCode = '200', Key = 'X-RateLimit-Limit', ReferenceId = 'X-RateLimit-Limit')]
[OpenApiResponseHeaderRef(StatusCode = '200', Key = 'X-RateLimit-Remaining', ReferenceId = 'X-RateLimit-Remaining')]
[OpenApiResponseHeaderRef(StatusCode = '200', Key = 'X-RateLimit-Reset', ReferenceId = 'X-RateLimit-Reset')]
[OpenApiResponse(StatusCode = '404', Description = 'User not found')]
[OpenApiResponseHeader(StatusCode = '404', Key = 'X-Error-Code', Description = 'Machine-readable error code.', Schema = ([string]))]
[OpenApiResponse(StatusCode = '429', Description = 'Too many requests')]
[OpenApiResponseHeaderRef(StatusCode = '429', Key = 'Retry-After', ReferenceId = 'Retry-After')]
param(
[OpenApiParameter(In = [OaParameterLocation]::Path, Required = $true)]
[int]$userId
)
$correlationId = [Guid]::NewGuid().ToString()
Add-DemoOperationalHeader -Limit 3 -Remaining 1 -ResetSeconds 60 -CorrelationId $correlationId
if (Test-Throttle) {
$Context.Response.Headers['Retry-After'] = '30'
Write-KrJsonResponse @{ error = 'Too many requests'; retryAfterSeconds = 30 } -StatusCode 429
return
}
$found = $null
[System.Threading.Monitor]::Enter($Users.SyncRoot)
try {
$found = $Users[[string]$userId]
} finally {
[System.Threading.Monitor]::Exit($Users.SyncRoot)
}
if ($null -eq $found) {
$Context.Response.Headers['X-Error-Code'] = 'USER_NOT_FOUND'
Write-KrJsonResponse @{ error = "User '$userId' not found" } -StatusCode 404
return
}
$v = [int]([DateTimeOffset]::UtcNow.ToUnixTimeSeconds() % 10)
$Context.Response.Headers['ETag'] = "W/`"user-$userId-v$v`""
Write-KrResponse $found -StatusCode 200
}
<#
.SYNOPSIS
Delete user by ID.
.DESCRIPTION
Deletes a user resource by its identifier.
.PARAMETER userId
The user ID to delete
#>
function deleteUser {
[OpenApiPath(HttpVerb = 'delete', Pattern = '/users/{userId}', Tags = 'Users')]
[OpenApiResponse(StatusCode = '204', Description = 'Deleted')]
[OpenApiResponseHeaderRef(StatusCode = '204', Key = 'X-Correlation-Id', ReferenceId = 'X-Correlation-Id')]
[OpenApiResponseHeaderRef(StatusCode = '204', Key = 'X-RateLimit-Limit', ReferenceId = 'X-RateLimit-Limit')]
[OpenApiResponseHeaderRef(StatusCode = '204', Key = 'X-RateLimit-Remaining', ReferenceId = 'X-RateLimit-Remaining')]
[OpenApiResponseHeaderRef(StatusCode = '204', Key = 'X-RateLimit-Reset', ReferenceId = 'X-RateLimit-Reset')]
[OpenApiResponse(StatusCode = '404', Description = 'User not found')]
param(
[OpenApiParameter(In = [OaParameterLocation]::Path, Required = $true)]
[int]$userId
)
$correlationId = [Guid]::NewGuid().ToString()
Add-DemoOperationalHeader -Limit 3 -Remaining 1 -ResetSeconds 60 -CorrelationId $correlationId
$removed = $false
[System.Threading.Monitor]::Enter($Users.SyncRoot)
try {
if ($Users.ContainsKey([string]$userId)) {
$Users.Remove([string]$userId) | Out-Null
$removed = $true
}
} finally {
[System.Threading.Monitor]::Exit($Users.SyncRoot)
}
if (-not $removed) {
Write-KrJsonResponse @{ error = "User '$userId' not found" } -StatusCode 404
return
}
Write-KrTextResponse '' -StatusCode 204
}
# =========================================================
# OPENAPI DOC ROUTE / BUILD
# =========================================================
Add-KrOpenApiRoute
Build-KrOpenApiDocument
# Test and log OpenAPI document validation result
if (Test-KrOpenApiDocument) {
Write-KrLog -Level Information -Message 'OpenAPI document built and validated successfully.'
} else {
Write-KrLog -Level Error -Message 'OpenAPI document validation failed.'
}
# =========================================================
# RUN SERVER
# =========================================================
Start-KrServer -CloseLogsOnExit
Step-by-step
- Define header components
- Reference headers from responses
- Define one-off inline headers
- Set headers at runtime
1) Define header components
Create a reusable header definition and store it under components/headers:
New-KrOpenApiHeader \
-Description 'Correlation id for tracing the request across services.' \
-Schema ([string]) \
-Required |
Add-KrOpenApiComponent -Name 'X-Correlation-Id'
Repeat for other reusable headers (e.g., Location, ETag, Retry-After, X-RateLimit-*).
Header extensions (x-*)
You can attach vendor extensions to a header component by using New-KrOpenApiHeader -Extensions.
Note: OpenAPI vendor extension keys must start with
x-. Keys that do not start withx-are ignored (Kestrun logs a warning).
Example:
New-KrOpenApiHeader -Description 'Remaining requests in the current window.' -Schema ([int]) `
-Extensions ([ordered]@{
'x-kestrun-demo' = [ordered]@{
exampleRemaining = 1
computedPer = 'client-ip'
windowSeconds = 60
}
}) |
Add-KrOpenApiComponent -Name 'X-RateLimit-Remaining'
Tip: You can attach examples to a header using the -Examples parameter:
$etagExamples = @{ weak = New-KrOpenApiExample -Summary 'Weak ETag' -Value 'W/"user-1-v3"' }
New-KrOpenApiHeader -Description 'Entity tag representing the current version of the resource.' -Schema ([string]) -Examples $etagExamples |
Add-KrOpenApiComponent -Name 'ETag'
2) Reference headers from responses
Use OpenApiResponseHeaderRef on the route function. Key is the header name in the response object, and ReferenceId is the component name:
function createUser {
[OpenApiPath(HttpVerb = 'post', Pattern = '/users', Tags = 'Users')]
[OpenApiResponse(StatusCode = '201', Description = 'Created')]
[OpenApiResponseHeaderRef(StatusCode = '201', Key = 'X-Correlation-Id', ReferenceId = 'X-Correlation-Id')]
[OpenApiResponseHeaderRef(StatusCode = '201', Key = 'Location', ReferenceId = 'Location')]
[OpenApiResponseHeaderRef(StatusCode = '201', Key = 'ETag', ReferenceId = 'ETag')]
param()
}
Key vs ReferenceId
Keybecomes the property name underresponses[status].headers[key]in the output.ReferenceIdmust match the name you used inAdd-KrOpenApiComponent -Name ....
If the ReferenceId does not exist, the header won’t be emitted (or you’ll see warnings during document generation).
3) Define one-off inline headers
For headers you don’t want to reuse, define them inline with OpenApiResponseHeader:
[OpenApiResponse(StatusCode = '400', Description = 'Invalid input')]
[OpenApiResponseHeader(StatusCode = '400', Key = 'X-Error-Code', Description = 'Machine-readable error code.', Schema = ([string]))]
If you have a reusable header component but want to embed it inline (no $ref), set Inline = $true on OpenApiResponseHeaderRef.
4) Set headers at runtime
OpenAPI documents the header; your route still needs to set the actual response header values:
$Context.Response.Headers['X-Correlation-Id'] = [Guid]::NewGuid().ToString()
$Context.Response.Headers['Location'] = "/users/$id"
$Context.Response.Headers['ETag'] = "W/`"user-$id-v1`""
Where to verify in openapi.json
After you run the sample, fetch:
http://127.0.0.1:5000/openapi/v3.1/openapi.json
Then inspect these locations:
components.headers.ETagcomponents.headers.X-Correlation-Idcomponents.headers.X-RateLimit-Remaining.x-kestrun-demopaths['/users'].post.responses['201'].headers.ETag.$refpaths['/users'].post.responses['400'].headers.X-Error-Code(inline header)
What the sample demonstrates
The included sample implements a small /users API and documents operational headers:
POST /usersreturns201+Location,ETag,X-Correlation-Id, and demoX-RateLimit-*headersGET /users/{userId}returns200+ETag,X-Correlation-Id,X-RateLimit-*POST /usersmay return400with inlineX-Error-Code- Some routes may return
429withRetry-After
Try it
From the repository root:
pwsh .\docs\_includes\examples\pwsh\10.9-OpenAPI-Component-Header.ps1
Then open:
- OpenAPI JSON:
http://127.0.0.1:5000/openapi/v3.1/openapi.json - Swagger UI:
http://127.0.0.1:5000/docs/swagger - Redoc:
http://127.0.0.1:5000/docs/redoc
Troubleshooting
Issue: Routes return 500 and logs show “The term ‘Some-Function’ is not recognized”.
- Cause: Kestrun captures caller-defined helper functions/variables when
Enable-KrConfigurationruns. - Fix: Ensure helper functions and shared variables used by routes are defined before calling
Enable-KrConfiguration.
References
Previous / Next
Previous: Document Info Next: Component Links