PowerShell Universal API with [APIEndpoint()]

I am always trying to make automation easier to scale in real teams. Recently I built a small module, synedgy.universal.helper, to simplify how I define and import PowerShell Universal API endpoints.

The key idea is to keep endpoint metadata close to the PowerShell function it belongs to, instead of duplicating definitions in separate files.

Custom [APIEndpoint()] illustration for this post

PowerShell Universal API in practice

PowerShell Universal can expose REST APIs directly from PowerShell and generate Swagger/OpenAPI documentation from your endpoint definitions.

The documented endpoint patterns are:

Both work, but if an API grows, endpoint sprawl in endpoints.ps1 quickly becomes difficult to maintain.

PowerShell Universal API endpoint definition and generated Swagger view

Production tips before custom patterns

Two habits help early:

Environment isolation improves startup and operational control because modules can be preloaded and API workloads can be restarted independently.

$newPSUEnvironmentParams = @{
    Name                     = 'PSUModuleEnv'
    Variables                = @('*')
    Description              = 'Environment for PSUModule API'
    Type                     = 'PowerShell7'
    Path                     = 'pwsh'
    Arguments                = '-NoLogo'
    Modules                  = @('PSUModule')
    DisableImplicitWinCompat = $true
    PSModulePath             = (Split-Path -Path $PSScriptRoot -Parent)
    PersistentRunspace       = $true
}

New-PSUEnvironment @newPSUEnvironmentParams

Where the usual endpoint model gets awkward

I found three recurring friction points:

  1. Parameter casing mismatch: PowerShell uses PascalCase while many API consumers expect camelCase.
  2. Split ownership: function logic sits in one file while endpoint metadata sits elsewhere.
  3. Bulk environment changes: moving endpoints between environments can become repetitive search-and-replace work.

The [APIEndpoint()] attribute pattern

The pattern is to annotate the function itself with endpoint metadata:

function Get-Something {
    [CmdletBinding()]
    [ApiEndpoint(
        Path = '/getSomething',
        Method = @('GET'),
        Description = 'Get a string using the someThing parameter.',
        Environment = 'PowerShell 7',
        ContentType = 'text/event-stream; charset=utf-8'
    )]
    param (
        [Parameter()]
        [string]
        $SomeThing
    )

    Get-ResultFromSomewhere -SomeThing $SomeThing
}

Then import all attributed endpoints from the module:

# .universal/endpoints.ps1
# Assumes synedgy.universal.helper is available in PSModulePath.
Import-PSUEndpoint -Module PSUModule -Environment PSUModuleEnv

This gives one place for endpoint intent and function implementation.

What Import-PSUEndpoint does

Import-PSUEndpoint discovers exported functions with [APIEndpoint()] where IsEndpoint = $true, then generates endpoint scriptblocks that:

Example generated shape:

{
    [CmdletBinding()]
    param (
        [Parameter()]
        [string]
        $someThing
    )

    Get-Something @PSBoundParameters
}

How the custom attribute is defined

The attribute is a normal PowerShell class inheriting from System.Attribute.

class APIEndpoint : System.Attribute {
    [bool]$IsEndpoint = $true
    [string]$Name
    [string]$Version = 'v1'
    [string]$Path
    [string]$Description
    [ValidateSet('GET', 'POST', 'PUT', 'DELETE', 'PATCH', 'HEAD')]
    [string[]]$Method
    [bool]$Authentication = $false
    [string[]]$Role
    [string]$Tag
    [int]$Timeout
    [string]$Environment
    [string]$ContentType = 'application/json; charset=utf-8'
    [ValidateSet('Information', 'Warning', 'Error', 'Verbose', 'Debug')]
    [string[]]$LogLevel = @('Information')
    [scriptblock]$Parameters

    APIEndpoint () {}
}

A simple discovery pass looks like this:

Get-Command -Module PSUModule | Where-Object {
    $_.ScriptBlock.Attributes.Where{
        $_.TypeId.ToString() -eq 'APIEndpoint' -and
        $_.IsEndpoint -eq $true
    }
}

Dependency and reuse

To reuse the attribute across modules, expose it via type accelerators as described in PowerShell Modules Exporting classes, then declare synedgy.universal.helper as a required module.

Limitations and future evolutions

Two areas remain important:

References