$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(?(?: - [^\n]+\n?)+)' ) if (-not $match.Success) { return @() } return @([regex]::Matches($match.Groups['rows'].Value, '(?m)^ - (?[^\s]+)\s*$') | ForEach-Object { $_.Groups['name'].Value }) } function Get-YamlFieldBlock { param([string]$SchemaBlock, [string]$Field) $pattern = '(?ms)^ ' + [regex]::Escape($Field) + ':\s*\n(?.*?)(?=^ [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(?(?: - [^\n]+\n?)+)' ) $yamlRoleValues = if ($enumMatch.Success) { @([regex]::Matches($enumMatch.Groups['rows'].Value, '(?m)^ - (?\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'