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 } |