Public/Test-KritOpenApiSpec.ps1

function Test-KritOpenApiSpec {
<#
.SYNOPSIS
    Validate the structural shape of an OpenAPI 3.0/3.1 document.

.DESCRIPTION
    Lightweight structural validator. Asserts:
      - File parses as JSON (or YAML when -AsYaml is passed and powershell-yaml present).
      - openapi field present and matches 3.x.
      - info.title and info.version present.
      - paths object present and non-empty.
      - Each operation under paths has either operationId OR (method+path) usable.

    Returns a [pscustomobject] with Pass / Findings / Stats. Throws when -Strict
    and any FAIL finding surfaces.

.PARAMETER Spec
    Path to the spec file (json by default).

.PARAMETER AsYaml
    Treat spec as YAML (requires powershell-yaml module).

.PARAMETER Strict
    Throw on any FAIL finding.

.EXAMPLE
    Test-KritOpenApiSpec -Spec ./pax8-openapi.json -Strict
#>

    [CmdletBinding()]
    param(
        [Parameter(Mandatory)][string]$Spec,
        [switch]$AsYaml,
        [switch]$Strict
    )

    if (-not (Test-Path -LiteralPath $Spec)) { throw "Spec not found: $Spec" }

    $raw = Get-Content -LiteralPath $Spec -Raw
    $doc = if ($AsYaml) {
        if (-not (Get-Module -ListAvailable -Name powershell-yaml)) {
            throw 'YAML mode requires the powershell-yaml module (Install-Module powershell-yaml).'
        }
        Import-Module powershell-yaml -ErrorAction Stop
        ConvertFrom-Yaml $raw
    } else {
        $raw | ConvertFrom-Json -Depth 100
    }

    $findings = New-Object System.Collections.Generic.List[object]
    function Add-Finding([string]$Severity, [string]$Rule, [string]$Detail) {
        $findings.Add([pscustomobject]@{
            Severity = $Severity; Rule = $Rule; Detail = $Detail
        })
    }

    if (-not $doc.openapi)      { Add-Finding 'FAIL' 'openapi-version' 'Top-level openapi field missing.' }
    elseif ($doc.openapi -notmatch '^3\.[01]')  { Add-Finding 'FAIL' 'openapi-version' "Unsupported openapi version: $($doc.openapi). Expected 3.0.x / 3.1.x." }

    if (-not $doc.info)         { Add-Finding 'FAIL' 'info-block' 'info block missing.' }
    else {
        if (-not $doc.info.title)   { Add-Finding 'WARN' 'info-title'   'info.title missing.' }
        if (-not $doc.info.version) { Add-Finding 'WARN' 'info-version' 'info.version missing.' }
    }

    $pathCount = 0; $opCount = 0; $missingOpIds = 0
    if (-not $doc.paths) {
        Add-Finding 'FAIL' 'paths-block' 'paths block missing or empty.'
    } else {
        foreach ($pathKey in $doc.paths.PSObject.Properties.Name) {
            $pathCount++
            $pathItem = $doc.paths.$pathKey
            foreach ($verb in 'get','post','put','delete','patch','head','options') {
                if ($pathItem.PSObject.Properties.Name -contains $verb) {
                    $opCount++
                    $op = $pathItem.$verb
                    $hasOpId = $false
                    if ($op -and $op.PSObject -and ($op.PSObject.Properties.Name -contains 'operationId')) {
                        $hasOpId = -not [string]::IsNullOrWhiteSpace($op.operationId)
                    }
                    if (-not $hasOpId) { $missingOpIds++ }
                }
            }
        }
        if ($pathCount -eq 0) { Add-Finding 'FAIL' 'paths-empty' 'paths block has zero entries.' }
        if ($missingOpIds -gt 0) {
            Add-Finding 'WARN' 'missing-operationId' "$missingOpIds operations lack operationId (generator will synthesize from method+path)."
        }
    }

    $failCount = @($findings | Where-Object Severity -eq 'FAIL').Count
    $warnCount = @($findings | Where-Object Severity -eq 'WARN').Count
    $pass      = $failCount -eq 0

    $result = [pscustomobject]@{
        Spec       = (Resolve-Path -LiteralPath $Spec).Path
        Pass       = $pass
        FailCount  = $failCount
        WarnCount  = $warnCount
        PathCount  = $pathCount
        OpCount    = $opCount
        Findings   = $findings.ToArray()
        TestedAtUtc= (Get-Date).ToUniversalTime().ToString('o')
    }

    if ($Strict -and -not $pass) {
        throw "Test-KritOpenApiSpec: $failCount FAIL finding(s). Run without -Strict to inspect."
    }
    $result
}