XML Modeling

Demonstrate OpenAPI 3.2 XML modeling with attributes, namespaces, and wrapped arrays using the OpenApiXml attribute.

Full source

File: pwsh/tutorial/examples/10.22-OpenAPI-XML-Modeling.ps1

<#
    Sample: OpenAPI XML Modeling
    Purpose: Demonstrate OpenAPI 3.2 XML modeling with attributes, namespaces, and wrapped arrays.
    File:    10.22-OpenAPI-XML-Modeling.ps1
    Notes:
            - Uses OpenApiXml attribute for XML-specific configuration
      - Demonstrates:
          * XML attributes (vs. elements)
          * Custom element names
          * Namespace handling with prefixes
          * Wrapped array serialization
#>
param(
    [int]$Port = 5000,
    [IPAddress]$IPAddress = [IPAddress]::Loopback
)

# --- Logging / Server ---
New-KrLogger | Add-KrSinkConsole |
    Set-KrLoggerLevel -Value Debug |
    Register-KrLogger -Name 'console' -SetAsDefault

New-KrServer -Name 'OpenAPI XML Modeling'

Add-KrEndpoint -Port $Port -IPAddress $IPAddress

# =========================================================
#                 TOP-LEVEL OPENAPI
# =========================================================

Add-KrOpenApiInfo -Title 'Product API with XML Modeling' `
    -Version '1.0.0' `
    -Description 'Demonstrates OpenAPI 3.2 XML modeling with attributes, namespaces, and wrapped arrays.'

# =========================================================
#                      COMPONENT SCHEMAS
# =========================================================

# Product schema with XML-specific attributes
[OpenApiSchemaComponent(
    Title = 'Product',
    Description = 'A product with XML-specific serialization',
    RequiredProperties = ('Id', 'Name', 'Price')
)]
class Product {
    # Product ID - rendered as XML attribute
    [OpenApiProperty(Description = 'Unique product identifier', Example = 123)]
    [OpenApiXml(Name = 'id', Attribute = $true)]
    [int]$Id

    # Product name with custom element name
    [OpenApiProperty(Description = 'Product name', Example = 'Widget')]
    [OpenApiXml(Name = 'ProductName')]
    [string]$Name

    # Product price with custom namespace and prefix
    [OpenApiProperty(Description = 'Product price in USD', Example = 19.99)]
    [OpenApiXml(Name = 'Price', Namespace = 'http://example.com/pricing', Prefix = 'price')]
    [decimal]$Price

    # Array of items with wrapped XML
    [OpenApiProperty(Description = 'List of product items', Example = ('Item1', 'Item2', 'Item3'))]
    [OpenApiXml(Name = 'Item', Wrapped = $true)]
    [string[]]$Items
}

# =========================================================
#                 ROUTES / OPERATIONS
# =========================================================

Enable-KrConfiguration

Add-KrApiDocumentationRoute -DocumentType Swagger
Add-KrApiDocumentationRoute -DocumentType Redoc

<#
.SYNOPSIS
    Get product by ID.
.DESCRIPTION
    Returns a product with XML-specific serialization.
.PARAMETER id
    Product identifier.
.NOTES
    GET endpoint: Return a product by ID
#>
function getProduct {
    [OpenApiPath(HttpVerb = 'get', Pattern = '/products/{id}')]
    [OpenApiResponse(
        StatusCode = '200',
        Description = 'Product found',
        Schema = [Product],
        ContentType = ('application/json', 'application/xml', 'application/yaml')
    )]
    [OpenApiResponse(
        StatusCode = '404',
        Description = 'Product not found',
        ContentType = ('application/json', 'application/xml')
    )]
    param(
        [OpenApiParameter(In = 'path', Description = 'Product identifier', Required = $true)]
        [int]$id
    )

    if ($id -lt 1 -or $id -gt 100) {
        Write-KrResponse @{ error = 'Product not found' } -StatusCode 404
        return
    }

    $product = [Product]@{
        Id = $id
        Name = "Sample Product $id"
        Price = [decimal](19.99 + ($id * 0.50))
        Items = @('Item1', 'Item2', 'Item3')
    }

    Write-KrResponse $product -StatusCode 200
}

<#
.SYNOPSIS
    Create a new product.
.DESCRIPTION
    Accepts a product payload and returns the created product.
.PARAMETER body
    Product data.
.NOTES
    POST endpoint: Create a new product
#>
function createProduct {
    [OpenApiPath(HttpVerb = 'post', Pattern = '/products')]
    [OpenApiResponse(
        StatusCode = '201',
        Description = 'Product created',
        Schema = [Product],
        ContentType = ('application/json', 'application/xml', 'application/yaml')
    )]
    [OpenApiResponse(
        StatusCode = '400',
        Description = 'Invalid input',
        ContentType = ('application/json', 'application/xml')
    )]
    param(
        [OpenApiRequestBody(
            Description = 'Product data',
            Required = $true,
            ContentType = ('application/json', 'application/xml', 'application/yaml')
        )]
        [Product]$body
    )

    Expand-KrObject -InputObject $body -Label 'Received Product'

    if ($null -eq $body -or -not $body.Name) {
        Write-KrResponse @{ error = 'Name is required' } -StatusCode 400
        return
    }

    # Assign a new ID
    $body.Id = Get-Random -Minimum 1 -Maximum 1000

    Write-KrResponse $body -StatusCode 201
}

# =========================================================
#                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

  1. Logging: Register console logger as default.
  2. Server: Create server named ‘OpenAPI XML Modeling’.
  3. OpenAPI Info: Add title, version, and description.
  4. Product Schema: Define Product class with XML-specific attributes:
    • Id: XML attribute (not element) with custom name ‘id’
    • Name: Custom element name ‘ProductName’
    • Price: Custom namespace and prefix
    • Items: Wrapped array with custom item name
  5. GET Endpoint: Return a product by ID with XML serialization.
  6. POST Endpoint: Accept product payload and return created product.
  7. Build OpenAPI Document: Build and validate the OpenAPI specification.
  8. Start Server: Run the server with XML-enabled endpoints.

Try it

# Get product by ID (returns XML)
curl -H "Accept: application/xml" http://127.0.0.1:5000/products/5

# Get product by ID (returns JSON)
curl -H "Accept: application/json" http://127.0.0.1:5000/products/5

# Create a new product (POST with XML)
curl -X POST http://127.0.0.1:5000/products `
  -H "Content-Type: application/xml" `
  -d '<Product id="123">
    <ProductName>Widget</ProductName>
    <price:Price xmlns:price="http://example.com/pricing">19.99</price:Price>
    <Items>
      <Item>Item1</Item>
      <Item>Item2</Item>
    </Items>
  </Product>'

# Create a new product (POST with JSON)
curl -X POST http://127.0.0.1:5000/products `
  -H "Content-Type: application/json" `
  -d '{
    "Id": 123,
    "Name": "Widget",
    "Price": 19.99,
    "Items": ["Item1", "Item2", "Item3"]
  }'

# View the OpenAPI specification with XML metadata
curl http://127.0.0.1:5000/openapi/v3.1/openapi.json | ConvertFrom-Json | ConvertTo-Json -Depth 10

PowerShell equivalent for POST:

$body = @{
    Id = 123
    Name = 'Widget'
    Price = 19.99
    Items = @('Item1', 'Item2', 'Item3')
} | ConvertTo-Json

Invoke-WebRequest -Uri http://127.0.0.1:5000/products `
    -Method Post `
    -Headers @{ 'Content-Type' = 'application/json' } `
    -Body $body | Select-Object StatusCode, Content

OpenApiXml Attribute

The OpenApiXml attribute provides XML-specific configuration for OpenAPI 3.2:

[OpenApiSchemaComponent(Title = 'Product')]
class Product {
    # XML attribute (not element)
    [OpenApiXml(Name = 'id', Attribute = $true)]
    [int]$Id

    # Custom element name
    [OpenApiXml(Name = 'ProductName')]
    [string]$Name

    # Namespace and prefix
    [OpenApiXml(Name = 'Price', Namespace = 'http://example.com/pricing', Prefix = 'price')]
    [decimal]$Price

    # Wrapped array
    [OpenApiXml(Name = 'Item', Wrapped = $true)]
    [string[]]$Items
}

Properties

  • Name: Overrides the element/attribute name in XML
  • Attribute: When $true, renders as XML attribute instead of element
  • Namespace: XML namespace URI for the element
  • Prefix: Namespace prefix for the element
  • Wrapped: When $true, wraps array items in an enclosing element

XML Output Example

With the attributes above, the Product schema generates XML like:

<Product id="123">
    <ProductName>Widget</ProductName>
    <price:Price xmlns:price="http://example.com/pricing">19.99</price:Price>
    <Items>
        <Item>Item1</Item>
        <Item>Item2</Item>
        <Item>Item3</Item>
    </Items>
</Product>

Key Concepts

XML Attributes vs Elements

By default, properties are serialized as XML elements. Use Attribute = $true to render as XML attributes:

# Element (default)
[OpenApiXml(Name = 'ProductName')]
[string]$Name
# Generates: <ProductName>Widget</ProductName>

# Attribute
[OpenApiXml(Name = 'id', Attribute = $true)]
[int]$Id
# Generates: <Product id="123">

Namespaces and Prefixes

Add XML namespaces to prevent naming conflicts:

[OpenApiXml(Name = 'Price', Namespace = 'http://example.com/pricing', Prefix = 'price')]
[decimal]$Price
# Generates: <price:Price xmlns:price="http://example.com/pricing">19.99</price:Price>

Array Wrapping

Control how arrays are serialized:

# Unwrapped (default)
[string[]]$Items
# Generates: <Item>Item1</Item><Item>Item2</Item>

# Wrapped
[OpenApiXml(Name = 'Item', Wrapped = $true)]
[string[]]$Items
# Generates: <Items><Item>Item1</Item><Item>Item2</Item></Items>

OpenAPI Schema Output

The XML metadata is included in the OpenAPI schema:

{
  "components": {
    "schemas": {
      "Product": {
        "type": "object",
        "properties": {
          "Id": {
            "type": "integer",
            "xml": {
              "name": "id",
              "attribute": true
            }
          },
          "Name": {
            "type": "string",
            "xml": {
              "name": "ProductName"
            }
          },
          "Price": {
            "type": "number",
            "xml": {
              "name": "Price",
              "namespace": "http://example.com/pricing",
              "prefix": "price"
            }
          },
          "Items": {
            "type": "array",
            "items": {
              "type": "string"
            },
            "xml": {
              "name": "Item",
              "wrapped": true
            }
          }
        }
      }
    }
  }
}

Troubleshooting

Symptom Cause Fix
XML binds but properties are empty XML element/attribute names don’t match the OpenApiXml mapping Update OpenApiXml(Name = '...') to match the payload, or update the payload to match the modeled names.
Server returns 415 Unsupported Media Type Missing or incorrect Content-Type header Send Content-Type: application/xml for XML request bodies.
Namespaced elements don’t serialize with expected prefix Namespace URI/prefix not declared Ensure OpenApiXml(Namespace=..., Prefix=...) is set and the XML payload declares xmlns:prefix="...".
Error like Cannot convert the "Product" value of type "Product" to type "Product" PowerShell script-defined classes are runspace-scoped; when an object instance originates from a different runspace, PowerShell can treat it as a different type even if the name matches Prefer letting Kestrun map XML into a plain hashtable/object and let PowerShell bind in the active request runspace. If you still hit this, use [object] for the body and cast/convert inside the route, or define the model class in a module that is imported into all request runspaces.

References


Previous / Next

Previous: SignalR (OpenAPI) Next: RFC 6570 Variable Mapping