276 lines
10 KiB
PowerShell
276 lines
10 KiB
PowerShell
$ErrorActionPreference = 'Stop'
|
|
|
|
$root = Split-Path -Parent $PSScriptRoot
|
|
$jsonPath = Join-Path $root 'APP.openapi.json'
|
|
$yamlPath = Join-Path $root 'APP.openapi.yaml'
|
|
$document = Get-Content -Raw -Encoding UTF8 -LiteralPath $jsonPath | ConvertFrom-Json
|
|
$yaml = Get-Content -Raw -Encoding UTF8 -LiteralPath $yamlPath
|
|
$issues = New-Object System.Collections.Generic.List[string]
|
|
|
|
function Add-Issue {
|
|
param([string]$Message)
|
|
$script:issues.Add($Message)
|
|
}
|
|
|
|
function Get-JsonOperation {
|
|
param([string]$Path, [string]$Method)
|
|
$pathProperty = $document.paths.PSObject.Properties[$Path]
|
|
if (-not $pathProperty) {
|
|
Add-Issue "JSON missing path: $Path"
|
|
return $null
|
|
}
|
|
$operationProperty = $pathProperty.Value.PSObject.Properties[$Method]
|
|
if (-not $operationProperty) {
|
|
Add-Issue "JSON missing operation: $($Method.ToUpper()) $Path"
|
|
return $null
|
|
}
|
|
return $operationProperty.Value
|
|
}
|
|
|
|
function Get-JsonResponseSchemaRef {
|
|
param([object]$Operation, [string]$Label)
|
|
if (-not $Operation) { return '' }
|
|
$responseProperty = $Operation.responses.PSObject.Properties['200']
|
|
if (-not $responseProperty) {
|
|
Add-Issue "JSON $Label missing 200 response"
|
|
return ''
|
|
}
|
|
$response = $responseProperty.Value
|
|
if ($response.'$ref') {
|
|
$responseName = ([string]$response.'$ref').Split('/')[-1]
|
|
$responseOwner = $document.components.responses.PSObject.Properties[$responseName]
|
|
if (-not $responseOwner) {
|
|
Add-Issue "JSON $Label references missing response owner: $responseName"
|
|
return ''
|
|
}
|
|
$response = $responseOwner.Value
|
|
}
|
|
$media = @($response.content.PSObject.Properties)
|
|
if ($media.Count -eq 0) {
|
|
Add-Issue "JSON $Label 200 response has no content schema"
|
|
return ''
|
|
}
|
|
return [string]$media[0].Value.schema.'$ref'
|
|
}
|
|
|
|
function Get-JsonSchema {
|
|
param([string]$Name)
|
|
$property = $document.components.schemas.PSObject.Properties[$Name]
|
|
if (-not $property) {
|
|
Add-Issue "JSON missing schema owner: $Name"
|
|
return $null
|
|
}
|
|
return $property.Value
|
|
}
|
|
|
|
function Assert-JsonRequired {
|
|
param([object]$Schema, [string]$SchemaName, [string[]]$Fields)
|
|
if (-not $Schema) { return }
|
|
$required = @($Schema.required)
|
|
foreach ($field in $Fields) {
|
|
if ($field -notin $required) {
|
|
Add-Issue "JSON $SchemaName.required missing: $field"
|
|
}
|
|
}
|
|
}
|
|
|
|
function Assert-JsonProperty {
|
|
param(
|
|
[object]$Schema,
|
|
[string]$SchemaName,
|
|
[string]$Field,
|
|
[string]$Type,
|
|
[switch]$NonEmpty
|
|
)
|
|
if (-not $Schema) { return $null }
|
|
$property = $Schema.properties.PSObject.Properties[$Field]
|
|
if (-not $property) {
|
|
Add-Issue "JSON $SchemaName missing property: $Field"
|
|
return $null
|
|
}
|
|
if ($property.Value.type -ne $Type) {
|
|
Add-Issue "JSON $SchemaName.$Field must be $Type"
|
|
}
|
|
if ($NonEmpty -and [int]$property.Value.minLength -lt 1) {
|
|
Add-Issue "JSON $SchemaName.$Field must declare minLength >= 1"
|
|
}
|
|
return $property.Value
|
|
}
|
|
|
|
function Get-YamlBlock {
|
|
param([string]$Header, [string]$NextHeaderPattern)
|
|
$lines = @($yaml -split "`r?`n")
|
|
$start = [Array]::IndexOf($lines, $Header)
|
|
if ($start -lt 0) { return '' }
|
|
$end = $lines.Count
|
|
for ($index = $start + 1; $index -lt $lines.Count; $index += 1) {
|
|
if ($lines[$index] -match $NextHeaderPattern) {
|
|
$end = $index
|
|
break
|
|
}
|
|
}
|
|
return ($lines[$start..($end - 1)] -join "`n")
|
|
}
|
|
|
|
function Get-YamlRequiredFields {
|
|
param([string]$Block)
|
|
$match = [regex]::Match(
|
|
$Block,
|
|
'(?ms)^ required:\s*\n(?<rows>(?: - [^\n]+\n?)+)'
|
|
)
|
|
if (-not $match.Success) { return @() }
|
|
return @([regex]::Matches($match.Groups['rows'].Value, '(?m)^ - (?<name>[^\s]+)\s*$') |
|
|
ForEach-Object { $_.Groups['name'].Value })
|
|
}
|
|
|
|
function Get-YamlFieldBlock {
|
|
param([string]$SchemaBlock, [string]$Field)
|
|
$pattern = '(?ms)^ ' + [regex]::Escape($Field) + ':\s*\n(?<body>.*?)(?=^ [A-Za-z0-9_]+:\s*$|\z)'
|
|
$match = [regex]::Match($SchemaBlock, $pattern)
|
|
if (-not $match.Success) { return '' }
|
|
return $match.Value
|
|
}
|
|
|
|
function Assert-YamlRequired {
|
|
param([string]$Block, [string]$SchemaName, [string[]]$Fields)
|
|
if (-not $Block) { return }
|
|
$required = @(Get-YamlRequiredFields $Block)
|
|
foreach ($field in $Fields) {
|
|
if ($field -notin $required) {
|
|
Add-Issue "YAML $SchemaName.required missing: $field"
|
|
}
|
|
}
|
|
}
|
|
|
|
function Assert-YamlField {
|
|
param(
|
|
[string]$Block,
|
|
[string]$SchemaName,
|
|
[string]$Field,
|
|
[string]$Type,
|
|
[switch]$NonEmpty
|
|
)
|
|
if (-not $Block) { return '' }
|
|
$fieldBlock = Get-YamlFieldBlock $Block $Field
|
|
if (-not $fieldBlock) {
|
|
Add-Issue "YAML $SchemaName missing property: $Field"
|
|
return ''
|
|
}
|
|
if ($fieldBlock -notmatch "(?m)^ type: $([regex]::Escape($Type))\s*$") {
|
|
Add-Issue "YAML $SchemaName.$Field must be $Type"
|
|
}
|
|
if ($NonEmpty -and $fieldBlock -notmatch '(?m)^ minLength: [1-9][0-9]*\s*$') {
|
|
Add-Issue "YAML $SchemaName.$Field must declare minLength >= 1"
|
|
}
|
|
return $fieldBlock
|
|
}
|
|
|
|
$minePath = '/genealogy/app/genealogies/mine'
|
|
$overviewPath = '/genealogy/app/genealogies/{genealogyId}/overview'
|
|
$mine = Get-JsonOperation $minePath 'get'
|
|
$overview = Get-JsonOperation $overviewPath 'get'
|
|
|
|
$mineResponseRef = Get-JsonResponseSchemaRef $mine 'GET /mine'
|
|
if ($mineResponseRef -ne '#/components/schemas/RListAppGenealogyVo') {
|
|
Add-Issue "JSON GET /mine must return RListAppGenealogyVo; actual: $mineResponseRef"
|
|
}
|
|
$overviewResponseRef = Get-JsonResponseSchemaRef $overview 'GET /overview'
|
|
if ($overviewResponseRef -ne '#/components/schemas/RAppGenealogyVo') {
|
|
Add-Issue "JSON GET /overview must return RAppGenealogyVo; actual: $overviewResponseRef"
|
|
}
|
|
|
|
$listEnvelope = Get-JsonSchema 'RListAppGenealogyVo'
|
|
$objectEnvelope = Get-JsonSchema 'RAppGenealogyVo'
|
|
$genealogy = Get-JsonSchema 'AppGenealogyVo'
|
|
|
|
Assert-JsonRequired $listEnvelope 'RListAppGenealogyVo' @('code', 'data')
|
|
Assert-JsonRequired $objectEnvelope 'RAppGenealogyVo' @('code', 'data')
|
|
Assert-JsonProperty $listEnvelope 'RListAppGenealogyVo' 'code' 'integer' | Out-Null
|
|
Assert-JsonProperty $objectEnvelope 'RAppGenealogyVo' 'code' 'integer' | Out-Null
|
|
if ($listEnvelope) {
|
|
$listData = $listEnvelope.properties.PSObject.Properties['data'].Value
|
|
if (-not $listData -or $listData.type -ne 'array' -or $listData.items.'$ref' -ne '#/components/schemas/AppGenealogyVo') {
|
|
Add-Issue 'JSON RListAppGenealogyVo.data must be AppGenealogyVo[]'
|
|
}
|
|
}
|
|
if ($objectEnvelope) {
|
|
$objectData = $objectEnvelope.properties.PSObject.Properties['data'].Value
|
|
if (-not $objectData -or $objectData.'$ref' -ne '#/components/schemas/AppGenealogyVo') {
|
|
Add-Issue 'JSON RAppGenealogyVo.data must reference AppGenealogyVo'
|
|
}
|
|
}
|
|
|
|
$consumedFields = @('genealogyId', 'genealogyName', 'canView', 'canManage', 'canEditContent', 'roleType')
|
|
Assert-JsonRequired $genealogy 'AppGenealogyVo' $consumedFields
|
|
Assert-JsonProperty $genealogy 'AppGenealogyVo' 'genealogyId' 'string' -NonEmpty | Out-Null
|
|
Assert-JsonProperty $genealogy 'AppGenealogyVo' 'genealogyName' 'string' -NonEmpty | Out-Null
|
|
foreach ($field in @('canView', 'canManage', 'canEditContent')) {
|
|
Assert-JsonProperty $genealogy 'AppGenealogyVo' $field 'boolean' | Out-Null
|
|
}
|
|
$roleType = Assert-JsonProperty $genealogy 'AppGenealogyVo' 'roleType' 'string'
|
|
if ($roleType) {
|
|
$roleValues = @($roleType.enum | Where-Object { $_ -is [string] -and $_.Trim() }) | Select-Object -Unique
|
|
if ($roleValues.Count -lt 2) {
|
|
Add-Issue 'JSON AppGenealogyVo.roleType must declare at least two non-empty enum values'
|
|
}
|
|
}
|
|
|
|
$yamlMine = Get-YamlBlock " $minePath`:" '^ /.*:$'
|
|
$yamlOverview = Get-YamlBlock " $overviewPath`:" '^ /.*:$'
|
|
if (-not $yamlMine) {
|
|
Add-Issue "YAML missing path: $minePath"
|
|
} elseif ($yamlMine -notmatch [regex]::Escape('#/components/schemas/RListAppGenealogyVo')) {
|
|
Add-Issue 'YAML GET /mine must return RListAppGenealogyVo'
|
|
}
|
|
if (-not $yamlOverview) {
|
|
Add-Issue "YAML missing path: $overviewPath"
|
|
} elseif ($yamlOverview -notmatch [regex]::Escape('#/components/schemas/RAppGenealogyVo')) {
|
|
Add-Issue 'YAML GET /overview must return RAppGenealogyVo'
|
|
}
|
|
|
|
$yamlListEnvelope = Get-YamlBlock ' RListAppGenealogyVo:' '^ [A-Za-z0-9_]+:$'
|
|
$yamlObjectEnvelope = Get-YamlBlock ' RAppGenealogyVo:' '^ [A-Za-z0-9_]+:$'
|
|
$yamlGenealogy = Get-YamlBlock ' AppGenealogyVo:' '^ [A-Za-z0-9_]+:$'
|
|
foreach ($schema in @(
|
|
@{ Name = 'RListAppGenealogyVo'; Block = $yamlListEnvelope },
|
|
@{ Name = 'RAppGenealogyVo'; Block = $yamlObjectEnvelope },
|
|
@{ Name = 'AppGenealogyVo'; Block = $yamlGenealogy }
|
|
)) {
|
|
if (-not $schema.Block) { Add-Issue "YAML missing schema owner: $($schema.Name)" }
|
|
}
|
|
Assert-YamlRequired $yamlListEnvelope 'RListAppGenealogyVo' @('code', 'data')
|
|
Assert-YamlRequired $yamlObjectEnvelope 'RAppGenealogyVo' @('code', 'data')
|
|
Assert-YamlRequired $yamlGenealogy 'AppGenealogyVo' $consumedFields
|
|
Assert-YamlField $yamlListEnvelope 'RListAppGenealogyVo' 'code' 'integer' | Out-Null
|
|
Assert-YamlField $yamlObjectEnvelope 'RAppGenealogyVo' 'code' 'integer' | Out-Null
|
|
Assert-YamlField $yamlGenealogy 'AppGenealogyVo' 'genealogyId' 'string' -NonEmpty | Out-Null
|
|
Assert-YamlField $yamlGenealogy 'AppGenealogyVo' 'genealogyName' 'string' -NonEmpty | Out-Null
|
|
foreach ($field in @('canView', 'canManage', 'canEditContent')) {
|
|
Assert-YamlField $yamlGenealogy 'AppGenealogyVo' $field 'boolean' | Out-Null
|
|
}
|
|
$yamlRoleType = Assert-YamlField $yamlGenealogy 'AppGenealogyVo' 'roleType' 'string'
|
|
if ($yamlRoleType) {
|
|
$enumMatch = [regex]::Match(
|
|
$yamlRoleType,
|
|
'(?ms)^ enum:\s*\n(?<rows>(?: - [^\n]+\n?)+)'
|
|
)
|
|
$yamlRoleValues = if ($enumMatch.Success) {
|
|
@([regex]::Matches($enumMatch.Groups['rows'].Value, '(?m)^ - (?<value>\S.*?)\s*$') |
|
|
ForEach-Object { $_.Groups['value'].Value } | Select-Object -Unique)
|
|
} else { @() }
|
|
if ($yamlRoleValues.Count -lt 2) {
|
|
Add-Issue 'YAML AppGenealogyVo.roleType must declare at least two enum values'
|
|
}
|
|
}
|
|
|
|
if ($issues.Count -gt 0) {
|
|
$lines = New-Object System.Collections.Generic.List[string]
|
|
$lines.Add('GENEALOGY-WORKSPACE-OPENAPI-CONTRACT BLOCKED')
|
|
foreach ($issue in $issues) { $lines.Add("- $issue") }
|
|
$lines.Add('- Release behavior still requires authenticated tests for current-account filtering, revocation, cross-account access, 401/403/404/5xx, malformed JSON, and cancellation.')
|
|
$lines.Add('- Replace both protected exports from one backend version; do not hand-edit APP.openapi.json or APP.openapi.yaml.')
|
|
throw ($lines -join [Environment]::NewLine)
|
|
}
|
|
|
|
Write-Output 'GENEALOGY-WORKSPACE-OPENAPI-CONTRACT PASS'
|