1203 lines
83 KiB
PowerShell
1203 lines
83 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
|
|
$yamlLines = @($yaml -split "`r?`n")
|
|
$openApi = [string]$document.openapi
|
|
$isOpenApi30 = $openApi -match '^3\.0\.'
|
|
$isOpenApi31 = $openApi -match '^3\.1\.'
|
|
if (-not $isOpenApi30 -and -not $isOpenApi31) { throw "Unsupported OpenAPI version: $openApi" }
|
|
if ($isOpenApi31 -and $null -ne $document.PSObject.Properties['jsonSchemaDialect']) {
|
|
$dialect = [string]$document.PSObject.Properties['jsonSchemaDialect'].Value
|
|
if ($dialect -ne 'https://spec.openapis.org/oas/3.1/dialect/base') {
|
|
throw "Unsupported OpenAPI 3.1 jsonSchemaDialect override: $dialect"
|
|
}
|
|
}
|
|
$componentSections = @('schemas', 'responses', 'parameters', 'examples', 'requestBodies', 'headers', 'securitySchemes', 'links', 'callbacks')
|
|
if ($isOpenApi31) { $componentSections += 'pathItems' }
|
|
|
|
$operations = @(
|
|
@{ Method = 'get'; Path = '/genealogy/app/v2/genealogies/{genealogyId}/lineage/tree'; RootSchema = 'LineageGraphWindow' },
|
|
@{ Method = 'get'; Path = '/genealogy/app/v2/genealogies/{genealogyId}/lineage/tree/overview'; RootSchema = 'LineageOverview' },
|
|
@{ Method = 'get'; Path = '/genealogy/app/v2/genealogies/{genealogyId}/lineage/persons/{personId}/locator'; RootSchema = 'LineageLocator' },
|
|
@{ Method = 'patch'; Path = '/genealogy/app/v2/genealogies/{genealogyId}/lineage/relationships/{relationshipId}'; RootSchema = $null }
|
|
)
|
|
$rootSchemaNames = @('LineageGraphWindow', 'LineageOverview', 'LineageLocator')
|
|
|
|
function Get-PropertyValue {
|
|
param([object]$Object, [string]$Name)
|
|
if ($null -eq $Object) { return $null }
|
|
$property = $Object.PSObject.Properties[$Name]
|
|
if ($null -eq $property) { return $null }
|
|
return $property.Value
|
|
}
|
|
|
|
function Test-HasProperty {
|
|
param([object]$Object, [string]$Name)
|
|
return $null -ne $Object -and $null -ne $Object.PSObject.Properties[$Name]
|
|
}
|
|
|
|
function Assert-Contract {
|
|
param([bool]$Condition, [string]$Message)
|
|
if (-not $Condition) { throw $Message }
|
|
}
|
|
|
|
function Assert-ExactSet {
|
|
param([object[]]$Actual, [object[]]$Expected, [string]$Label)
|
|
$actualValues = @($Actual | ForEach-Object { [string]$_ } | Sort-Object -Unique)
|
|
$expectedValues = @($Expected | ForEach-Object { [string]$_ } | Sort-Object -Unique)
|
|
if (($actualValues -join ',') -ne ($expectedValues -join ',')) {
|
|
throw "$Label drifted: actual=[$($actualValues -join ',')] expected=[$($expectedValues -join ',')]"
|
|
}
|
|
}
|
|
|
|
function Get-YamlRange {
|
|
param([string]$Header, [string]$EndPattern)
|
|
$start = [Array]::IndexOf($yamlLines, $Header)
|
|
if ($start -lt 0) { return $null }
|
|
$end = $yamlLines.Count
|
|
for ($index = $start + 1; $index -lt $yamlLines.Count; $index += 1) {
|
|
if ($yamlLines[$index] -match $EndPattern) {
|
|
$end = $index
|
|
break
|
|
}
|
|
}
|
|
return ($yamlLines[$start..($end - 1)] -join "`n")
|
|
}
|
|
|
|
function Get-YamlPathBlock {
|
|
param([string]$Path)
|
|
foreach ($header in @(" $Path`:", " '$Path`':", " `"$Path`":")) {
|
|
$block = Get-YamlRange -Header $header -EndPattern '^ [''"]?/.*[''"]?:$|^[A-Za-z][A-Za-z0-9_-]*:$'
|
|
if ($null -ne $block) { return $block }
|
|
}
|
|
return $null
|
|
}
|
|
|
|
function Get-YamlOperationBlock {
|
|
param([string]$Path, [string]$Method)
|
|
$pathBlock = Get-YamlPathBlock -Path $Path
|
|
if ($null -eq $pathBlock) { return $null }
|
|
$lines = @($pathBlock -split "`n")
|
|
$header = " $Method`:"
|
|
$start = [Array]::IndexOf($lines, $header)
|
|
if ($start -lt 0) { return $null }
|
|
$end = $lines.Count
|
|
for ($index = $start + 1; $index -lt $lines.Count; $index += 1) {
|
|
if ($lines[$index] -match '^ (get|put|post|delete|patch|options|head|trace):$') {
|
|
$end = $index
|
|
break
|
|
}
|
|
}
|
|
return ($lines[$start..($end - 1)] -join "`n")
|
|
}
|
|
|
|
function Get-YamlComponentSection {
|
|
param([string]$Section)
|
|
return Get-YamlRange -Header " $Section`:" -EndPattern '^ [A-Za-z][A-Za-z0-9_-]*:$|^[A-Za-z][A-Za-z0-9_-]*:$'
|
|
}
|
|
|
|
function Get-YamlComponentNames {
|
|
param([string]$Section)
|
|
$sectionBlock = Get-YamlComponentSection -Section $Section
|
|
if ($null -eq $sectionBlock) { return @() }
|
|
return @(
|
|
[regex]::Matches($sectionBlock, '(?m)^ [''"]?(?<name>[A-Za-z0-9_.-]+)[''"]?:$') |
|
|
ForEach-Object { $_.Groups['name'].Value }
|
|
)
|
|
}
|
|
|
|
function Get-YamlComponentBlock {
|
|
param([string]$Section, [string]$Name)
|
|
$sectionBlock = Get-YamlComponentSection -Section $Section
|
|
if ($null -eq $sectionBlock) { return $null }
|
|
$lines = @($sectionBlock -split "`n")
|
|
$start = -1
|
|
foreach ($header in @(" $Name`:", " '$Name`':", " `"$Name`":")) {
|
|
$start = [Array]::IndexOf($lines, $header)
|
|
if ($start -ge 0) { break }
|
|
}
|
|
if ($start -lt 0) { return $null }
|
|
$end = $lines.Count
|
|
for ($index = $start + 1; $index -lt $lines.Count; $index += 1) {
|
|
if ($lines[$index] -match '^ [''"]?[A-Za-z0-9_.-]+[''"]?:$') {
|
|
$end = $index
|
|
break
|
|
}
|
|
}
|
|
return ($lines[$start..($end - 1)] -join "`n")
|
|
}
|
|
|
|
function Get-ComponentRefSet {
|
|
param([object]$Value)
|
|
if ($null -eq $Value) { return @() }
|
|
$refs = New-Object 'System.Collections.Generic.HashSet[string]'
|
|
if ($Value -is [string]) {
|
|
foreach ($refLine in [regex]::Matches($Value, '(?m)^\s+\$ref:\s*[''"]?(?<ref>[^\s''"]+)[''"]?\s*$')) {
|
|
$match = [regex]::Match($refLine.Groups['ref'].Value, '^#/components/(?<section>[A-Za-z][A-Za-z0-9]*)/(?<name>[A-Za-z0-9_.-]+)$')
|
|
if (-not $match.Success -or $match.Groups['section'].Value -notin $componentSections) {
|
|
throw "Unsupported component reference: $($refLine.Groups['ref'].Value)"
|
|
}
|
|
[void]$refs.Add("$($match.Groups['section'].Value)/$($match.Groups['name'].Value)")
|
|
}
|
|
return @($refs | Sort-Object)
|
|
}
|
|
|
|
function Visit-ComponentRefs {
|
|
param([object]$Current, [System.Collections.Generic.HashSet[string]]$Result)
|
|
if ($null -eq $Current -or $Current -is [string] -or $Current -is [ValueType]) { return }
|
|
if ($Current -is [System.Collections.IEnumerable] -and -not ($Current -is [pscustomobject])) {
|
|
foreach ($item in $Current) { Visit-ComponentRefs -Current $item -Result $Result }
|
|
return
|
|
}
|
|
foreach ($property in $Current.PSObject.Properties) {
|
|
if ($property.Name -eq '$ref') {
|
|
$match = [regex]::Match([string]$property.Value, '^#/components/(?<section>[A-Za-z][A-Za-z0-9]*)/(?<name>[A-Za-z0-9_.-]+)$')
|
|
if (-not $match.Success -or $match.Groups['section'].Value -notin $componentSections) {
|
|
throw "Unsupported component reference: $($property.Value)"
|
|
}
|
|
[void]$Result.Add("$($match.Groups['section'].Value)/$($match.Groups['name'].Value)")
|
|
} else {
|
|
Visit-ComponentRefs -Current $property.Value -Result $Result
|
|
}
|
|
}
|
|
}
|
|
|
|
Visit-ComponentRefs -Current $Value -Result $refs
|
|
return @($refs | Sort-Object)
|
|
}
|
|
|
|
function Resolve-LocalRef {
|
|
param([object]$Value)
|
|
$current = $Value
|
|
$visited = New-Object 'System.Collections.Generic.HashSet[string]'
|
|
while (Test-HasProperty -Object $current -Name '$ref') {
|
|
$ref = [string](Get-PropertyValue -Object $current -Name '$ref')
|
|
if (-not $visited.Add($ref)) { throw "Circular component reference: $ref" }
|
|
$match = [regex]::Match($ref, '^#/components/(?<section>schemas|responses|parameters|requestBodies)/(?<name>[^/]+)$')
|
|
if (-not $match.Success) { throw "Unsupported component reference: $ref" }
|
|
$section = Get-PropertyValue -Object $document.components -Name $match.Groups['section'].Value
|
|
$property = if ($null -eq $section) { $null } else { $section.PSObject.Properties[$match.Groups['name'].Value] }
|
|
if ($null -eq $property) { throw "Component reference target missing: $ref" }
|
|
$current = $property.Value
|
|
}
|
|
return $current
|
|
}
|
|
|
|
function Resolve-Schema {
|
|
param([object]$Schema)
|
|
if (Test-HasProperty -Object $Schema -Name '$ref') {
|
|
# 本门禁只接受独立引用以及不改变校验语义的注解旁项。OpenAPI 3.1 虽允许
|
|
# Schema `$ref` 旁项,但若在这里静默丢弃 type/allOf/not 等约束会造成假绿。
|
|
$annotationKeywords = @('$ref', 'title', 'summary', 'description', 'deprecated', 'readOnly', 'writeOnly', 'examples', 'externalDocs')
|
|
$validationSiblings = @($Schema.PSObject.Properties.Name | Where-Object { $_ -notin $annotationKeywords })
|
|
if ($validationSiblings.Count -gt 0) {
|
|
throw "Schema reference contains unsupported validation siblings: $($validationSiblings -join ',')"
|
|
}
|
|
}
|
|
$resolved = Resolve-LocalRef -Value $Schema
|
|
return $resolved
|
|
}
|
|
|
|
function Get-SchemaFragments {
|
|
param([object]$Schema, [string[]]$RefStack = @(), [int]$Depth = 0)
|
|
if ($Depth -gt 50) { throw 'Schema allOf nesting exceeds 50 levels' }
|
|
$nextStack = @($RefStack)
|
|
if (Test-HasProperty -Object $Schema -Name '$ref') {
|
|
$ref = [string](Get-PropertyValue -Object $Schema -Name '$ref')
|
|
if ($ref -in $RefStack) { throw "Circular allOf schema reference: $ref" }
|
|
$nextStack += $ref
|
|
}
|
|
$resolved = Resolve-Schema -Schema $Schema
|
|
$result = @($resolved)
|
|
foreach ($child in @(Get-PropertyValue -Object $resolved -Name 'allOf')) {
|
|
$result += @(Get-SchemaFragments -Schema $child -RefStack $nextStack -Depth ($Depth + 1))
|
|
}
|
|
return $result
|
|
}
|
|
|
|
function Get-SchemaPropertyNames {
|
|
param([object]$Schema)
|
|
$names = @()
|
|
foreach ($fragment in @(Get-SchemaFragments -Schema $Schema)) {
|
|
$properties = Get-PropertyValue -Object $fragment -Name 'properties'
|
|
if ($null -ne $properties) { $names += @($properties.PSObject.Properties.Name) }
|
|
}
|
|
return @($names | Sort-Object -Unique)
|
|
}
|
|
|
|
function Get-SchemaRequiredNames {
|
|
param([object]$Schema)
|
|
$names = @()
|
|
foreach ($fragment in @(Get-SchemaFragments -Schema $Schema)) {
|
|
$names += @(Get-PropertyValue -Object $fragment -Name 'required')
|
|
}
|
|
return @($names | Sort-Object -Unique)
|
|
}
|
|
|
|
function Get-SchemaProperty {
|
|
param([object]$Schema, [string]$Name)
|
|
$matches = @()
|
|
foreach ($fragment in @(Get-SchemaFragments -Schema $Schema)) {
|
|
$properties = Get-PropertyValue -Object $fragment -Name 'properties'
|
|
if ($null -ne $properties -and $null -ne $properties.PSObject.Properties[$Name]) {
|
|
$matches += ,$properties.PSObject.Properties[$Name].Value
|
|
}
|
|
}
|
|
if ($matches.Count -gt 1) { throw "Schema property is defined more than once across allOf: $Name" }
|
|
if ($matches.Count -eq 1) { return $matches[0] }
|
|
return $null
|
|
}
|
|
|
|
function Test-SchemaIsClosed {
|
|
param([object]$Schema)
|
|
$resolved = Resolve-Schema -Schema $Schema
|
|
if ($isOpenApi31 -and (Test-HasProperty -Object $resolved -Name 'unevaluatedProperties') -and
|
|
(Get-PropertyValue -Object $resolved -Name 'unevaluatedProperties') -eq $false) {
|
|
return $true
|
|
}
|
|
$allFields = @(Get-SchemaPropertyNames -Schema $Schema)
|
|
foreach ($fragment in @(Get-SchemaFragments -Schema $Schema)) {
|
|
if ((Test-HasProperty -Object $fragment -Name 'additionalProperties') -and
|
|
(Get-PropertyValue -Object $fragment -Name 'additionalProperties') -eq $false) {
|
|
$fragmentFields = @()
|
|
$properties = Get-PropertyValue -Object $fragment -Name 'properties'
|
|
if ($null -ne $properties) { $fragmentFields = @($properties.PSObject.Properties.Name | Sort-Object -Unique) }
|
|
if (($fragmentFields -join ',') -eq (@($allFields | Sort-Object) -join ',')) { return $true }
|
|
}
|
|
}
|
|
return $false
|
|
}
|
|
|
|
function Assert-ObjectTypedSchema {
|
|
param([object]$Schema, [string]$Label, [bool]$AllowNullable = $false)
|
|
$objectTypes = @()
|
|
foreach ($fragment in @(Get-SchemaFragments -Schema $Schema)) { $objectTypes += @(Get-TypeNames -Schema $fragment) }
|
|
Assert-ExactSet -Actual $objectTypes -Expected @('object') -Label "$Label type"
|
|
if (-not $AllowNullable) {
|
|
Assert-Contract -Condition (-not (Test-IsNullableSchema -Schema $Schema)) -Message "$Label must not allow null"
|
|
}
|
|
}
|
|
|
|
function Assert-ExactObjectSchema {
|
|
param([object]$Schema, [string[]]$Fields, [string[]]$Required, [string]$Label, [bool]$AllowNullable = $false)
|
|
Assert-ObjectTypedSchema -Schema $Schema -Label $Label -AllowNullable $AllowNullable
|
|
Assert-ExactSet -Actual @(Get-SchemaPropertyNames -Schema $Schema) -Expected $Fields -Label "$Label properties"
|
|
Assert-ExactSet -Actual @(Get-SchemaRequiredNames -Schema $Schema) -Expected $Required -Label "$Label required"
|
|
Assert-Contract -Condition (Test-SchemaIsClosed -Schema $Schema) -Message "$Label must reject additional properties"
|
|
}
|
|
|
|
function Get-TypeNames {
|
|
param([object]$Schema)
|
|
$resolved = Resolve-Schema -Schema $Schema
|
|
return @((Get-PropertyValue -Object $resolved -Name 'type') | ForEach-Object { [string]$_ })
|
|
}
|
|
|
|
function Get-NonNullTypeNames {
|
|
param([object]$Schema)
|
|
return @((Get-TypeNames -Schema $Schema) | Where-Object { $_ -ne 'null' } | Sort-Object -Unique)
|
|
}
|
|
|
|
function Assert-ExactNonNullType {
|
|
param([object]$Schema, [string]$Type, [string]$Label, [bool]$AllowNullable = $false)
|
|
$resolved = Resolve-Schema -Schema $Schema
|
|
foreach ($keyword in @('allOf', 'oneOf', 'anyOf', 'not', 'if', 'then', 'else')) {
|
|
if (Test-HasProperty -Object $resolved -Name $keyword) {
|
|
throw "$Label scalar schema must not hide validation semantics in $keyword"
|
|
}
|
|
}
|
|
Assert-ExactSet -Actual @(Get-TypeNames -Schema $resolved) -Expected @($Type) -Label "$Label type"
|
|
if (-not $AllowNullable) {
|
|
Assert-Contract -Condition (-not (Test-IsNullableSchema -Schema $Schema)) -Message "$Label must not allow null"
|
|
}
|
|
}
|
|
|
|
function Test-IsNullOnlySchema {
|
|
param([object]$Schema)
|
|
$resolved = Resolve-Schema -Schema $Schema
|
|
$types = @(Get-TypeNames -Schema $resolved)
|
|
if ($types.Count -eq 1 -and $types[0] -eq 'null') { return $true }
|
|
if ($isOpenApi31 -and (Test-HasProperty -Object $resolved -Name 'const') -and $null -eq (Get-PropertyValue -Object $resolved -Name 'const')) { return $true }
|
|
if (Test-HasProperty -Object $resolved -Name 'enum') {
|
|
$enumValue = $resolved.PSObject.Properties['enum'].Value
|
|
$isSingleNullEnum = $enumValue -is [System.Array] -and $enumValue.Count -eq 1 -and $null -eq $enumValue[0]
|
|
if ($isSingleNullEnum -and
|
|
($isOpenApi31 -or ((Get-PropertyValue -Object $resolved -Name 'nullable') -eq $true))) { return $true }
|
|
}
|
|
return $false
|
|
}
|
|
|
|
function Test-IsNullableSchema {
|
|
param([object]$Schema)
|
|
$resolved = Resolve-Schema -Schema $Schema
|
|
if ($isOpenApi30) {
|
|
foreach ($fragment in @(Get-SchemaFragments -Schema $resolved)) {
|
|
if ((Test-HasProperty -Object $fragment -Name 'nullable') -and (Get-PropertyValue -Object $fragment -Name 'nullable') -eq $true) { return $true }
|
|
}
|
|
}
|
|
if ($isOpenApi31 -and 'null' -in @(Get-TypeNames -Schema $resolved)) { return $true }
|
|
foreach ($keyword in @('oneOf', 'anyOf')) {
|
|
foreach ($branch in @(Get-PropertyValue -Object $resolved -Name $keyword)) {
|
|
if ((Test-IsNullOnlySchema -Schema $branch) -or (Test-IsNullableSchema -Schema $branch)) { return $true }
|
|
}
|
|
}
|
|
return $false
|
|
}
|
|
|
|
function Get-NonNullSchema {
|
|
param([object]$Schema)
|
|
$resolved = Resolve-Schema -Schema $Schema
|
|
foreach ($keyword in @('oneOf', 'anyOf')) {
|
|
$branches = @(Get-PropertyValue -Object $resolved -Name $keyword)
|
|
if ($branches.Count -gt 0) {
|
|
$nonNull = @($branches | Where-Object { -not (Test-IsNullOnlySchema -Schema $_) })
|
|
if ($nonNull.Count -ne 1) { throw "Nullable union must expose exactly one non-null branch" }
|
|
return $nonNull[0]
|
|
}
|
|
}
|
|
return $Schema
|
|
}
|
|
|
|
function Get-NonNullEnumValues {
|
|
param([object]$Schema)
|
|
$resolved = Resolve-Schema -Schema $Schema
|
|
return @((Get-PropertyValue -Object $resolved -Name 'enum') | Where-Object { $null -ne $_ } | ForEach-Object { [string]$_ })
|
|
}
|
|
|
|
function Assert-ExactEnum {
|
|
param([object]$Schema, [string[]]$Values, [string]$Label, [bool]$AllowNullable = $false)
|
|
Assert-ExactNonNullType -Schema $Schema -Type 'string' -Label $Label -AllowNullable $AllowNullable
|
|
Assert-ExactSet -Actual @(Get-NonNullEnumValues -Schema $Schema) -Expected $Values -Label "$Label enum"
|
|
}
|
|
|
|
function Get-SingleLiteral {
|
|
param([object]$Schema, [string]$Label)
|
|
$resolved = Resolve-Schema -Schema $Schema
|
|
Assert-ExactNonNullType -Schema $resolved -Type 'string' -Label $Label
|
|
if ($isOpenApi31 -and (Test-HasProperty -Object $resolved -Name 'const')) { return [string](Get-PropertyValue -Object $resolved -Name 'const') }
|
|
$values = @(Get-NonNullEnumValues -Schema $resolved)
|
|
if ($values.Count -ne 1) { throw "$Label must use const or a single-value enum" }
|
|
return $values[0]
|
|
}
|
|
|
|
function Assert-StringSchema {
|
|
param([object]$Schema, [string]$Label, [bool]$Nullable = $false)
|
|
$candidate = Get-NonNullSchema -Schema $Schema
|
|
Assert-ExactNonNullType -Schema $candidate -Type 'string' -Label $Label -AllowNullable $Nullable
|
|
$resolved = Resolve-Schema -Schema $candidate
|
|
Assert-Contract -Condition ((Get-PropertyValue -Object $resolved -Name 'minLength') -ge 1) -Message "$Label must set minLength >= 1"
|
|
if ($Nullable) {
|
|
Assert-Contract -Condition (Test-IsNullableSchema -Schema $Schema) -Message "$Label must allow null"
|
|
} else {
|
|
Assert-Contract -Condition (-not (Test-IsNullableSchema -Schema $Schema)) -Message "$Label must not allow null"
|
|
}
|
|
}
|
|
|
|
function Assert-StablePersonIdSchema {
|
|
param([object]$Schema, [string]$Label, [bool]$Nullable = $false)
|
|
Assert-StringSchema -Schema $Schema -Label $Label -Nullable $Nullable
|
|
$candidate = Resolve-Schema -Schema (Get-NonNullSchema -Schema $Schema)
|
|
$pattern = [string](Get-PropertyValue -Object $candidate -Name 'pattern')
|
|
Assert-Contract -Condition (-not [string]::IsNullOrWhiteSpace($pattern)) -Message "$Label must reject redacted opaque IDs with a pattern"
|
|
try { $regex = [regex]::new($pattern) } catch { throw "$Label has an invalid pattern: $pattern" }
|
|
foreach ($accepted in @('p103', '900719925474099312345', 'person:alpha-1')) {
|
|
$match = $regex.Match($accepted)
|
|
Assert-Contract -Condition ($match.Success -and $match.Index -eq 0 -and $match.Length -eq $accepted.Length) -Message "$Label pattern rejects stable ID: $accepted"
|
|
}
|
|
foreach ($rejected in @('redacted:t-1:17', 'redacted:t-20260722-1:opaque-token', 'redacted:x:y')) {
|
|
Assert-Contract -Condition (-not $regex.IsMatch($rejected)) -Message "$Label pattern accepts redacted opaque ID: $rejected"
|
|
}
|
|
}
|
|
|
|
function Assert-IntegerSchema {
|
|
param([object]$Schema, [string]$Label, [Nullable[int]]$Minimum = $null, [Nullable[int]]$Maximum = $null, [Nullable[int]]$Default = $null, [bool]$AllowNullable = $false)
|
|
$resolved = Resolve-Schema -Schema (Get-NonNullSchema -Schema $Schema)
|
|
Assert-ExactNonNullType -Schema $resolved -Type 'integer' -Label $Label -AllowNullable $AllowNullable
|
|
if ($null -ne $Minimum) { Assert-Contract -Condition ((Get-PropertyValue -Object $resolved -Name 'minimum') -eq $Minimum) -Message "$Label minimum must be $Minimum" }
|
|
if ($null -ne $Maximum) { Assert-Contract -Condition ((Get-PropertyValue -Object $resolved -Name 'maximum') -eq $Maximum) -Message "$Label maximum must be $Maximum" }
|
|
if ($null -ne $Default) { Assert-Contract -Condition ((Get-PropertyValue -Object $resolved -Name 'default') -eq $Default) -Message "$Label default must be $Default" }
|
|
}
|
|
|
|
function Assert-ArraySchema {
|
|
param([object]$Schema, [string]$Label, [Nullable[int]]$MinimumItems = $null, [Nullable[int]]$MaximumItems = $null)
|
|
$resolved = Resolve-Schema -Schema $Schema
|
|
Assert-ExactNonNullType -Schema $resolved -Type 'array' -Label $Label
|
|
Assert-Contract -Condition (Test-HasProperty -Object $resolved -Name 'items') -Message "$Label must define items"
|
|
if ($null -ne $MinimumItems) { Assert-Contract -Condition ((Get-PropertyValue -Object $resolved -Name 'minItems') -eq $MinimumItems) -Message "$Label minItems must be $MinimumItems" }
|
|
if ($null -ne $MaximumItems) { Assert-Contract -Condition ((Get-PropertyValue -Object $resolved -Name 'maxItems') -eq $MaximumItems) -Message "$Label maxItems must be $MaximumItems" }
|
|
return Get-PropertyValue -Object $resolved -Name 'items'
|
|
}
|
|
|
|
function Assert-GenerationRangeSchema {
|
|
param([object]$Schema, [string]$Label)
|
|
Assert-Contract -Condition (-not (Test-IsNullableSchema -Schema $Schema)) -Message "$Label must be non-null"
|
|
Assert-ExactObjectSchema -Schema $Schema -Fields @('minGeneration', 'maxGeneration') -Required @('minGeneration', 'maxGeneration') -Label $Label
|
|
Assert-IntegerSchema -Schema (Get-SchemaProperty -Schema $Schema -Name 'minGeneration') -Label "$Label.minGeneration" -Minimum 1
|
|
Assert-IntegerSchema -Schema (Get-SchemaProperty -Schema $Schema -Name 'maxGeneration') -Label "$Label.maxGeneration" -Minimum 1
|
|
}
|
|
|
|
function Assert-PureOneOfOwner {
|
|
param([object]$Schema, [string]$Label, [bool]$AllowDiscriminator = $false)
|
|
$resolved = Resolve-Schema -Schema $Schema
|
|
$allowed = @('oneOf', 'title', 'summary', 'description', 'deprecated', 'readOnly', 'writeOnly', 'examples', 'externalDocs')
|
|
if ($AllowDiscriminator) { $allowed += 'discriminator' }
|
|
$conflicts = @($resolved.PSObject.Properties.Name | Where-Object { $_ -notin $allowed })
|
|
if ($conflicts.Count -gt 0) {
|
|
throw "$Label oneOf owner contains conflicting validation keywords: $($conflicts -join ',')"
|
|
}
|
|
}
|
|
|
|
function Get-DiscriminatedBranches {
|
|
param([object]$Schema, [string]$PropertyName, [string[]]$Values, [string]$Label)
|
|
$resolved = Resolve-Schema -Schema $Schema
|
|
Assert-PureOneOfOwner -Schema $resolved -Label $Label -AllowDiscriminator $true
|
|
$branches = @(Get-PropertyValue -Object $resolved -Name 'oneOf')
|
|
Assert-Contract -Condition ($branches.Count -eq $Values.Count) -Message "$Label must use oneOf with $($Values.Count) branches"
|
|
$discriminator = Get-PropertyValue -Object $resolved -Name 'discriminator'
|
|
Assert-Contract -Condition ($null -ne $discriminator -and (Get-PropertyValue -Object $discriminator -Name 'propertyName') -eq $PropertyName) -Message "$Label discriminator must use $PropertyName"
|
|
$mapping = Get-PropertyValue -Object $discriminator -Name 'mapping'
|
|
if ($null -ne $mapping) { Assert-ExactSet -Actual @($mapping.PSObject.Properties.Name) -Expected $Values -Label "$Label discriminator mapping" }
|
|
|
|
$result = @{}
|
|
foreach ($branch in $branches) {
|
|
$ref = [string](Get-PropertyValue -Object $branch -Name '$ref')
|
|
$candidate = Resolve-Schema -Schema $branch
|
|
$value = Get-SingleLiteral -Schema (Get-SchemaProperty -Schema $candidate -Name $PropertyName) -Label "$Label.$PropertyName"
|
|
Assert-Contract -Condition ($value -in $Values) -Message "$Label contains unexpected discriminator value: $value"
|
|
Assert-Contract -Condition (-not $result.ContainsKey($value)) -Message "$Label repeats discriminator value: $value"
|
|
if ($null -ne $mapping) {
|
|
Assert-Contract -Condition (-not [string]::IsNullOrWhiteSpace($ref) -and [string](Get-PropertyValue -Object $mapping -Name $value) -eq $ref) -Message "$Label mapping for $value does not point to its branch"
|
|
}
|
|
$result[$value] = $candidate
|
|
}
|
|
Assert-ExactSet -Actual @($result.Keys) -Expected $Values -Label "$Label branch values"
|
|
return $result
|
|
}
|
|
|
|
function Get-OperationParameters {
|
|
param([object]$PathItem, [object]$Operation)
|
|
$parameters = @()
|
|
foreach ($candidate in @(@(Get-PropertyValue -Object $PathItem -Name 'parameters') + @(Get-PropertyValue -Object $Operation -Name 'parameters'))) {
|
|
if ($null -ne $candidate) { $parameters += ,(Resolve-LocalRef -Value $candidate) }
|
|
}
|
|
return $parameters
|
|
}
|
|
|
|
function Get-Parameter {
|
|
param([object[]]$Parameters, [string]$Name, [string]$In)
|
|
$matches = @($Parameters | Where-Object { (Get-PropertyValue -Object $_ -Name 'name') -eq $Name -and (Get-PropertyValue -Object $_ -Name 'in') -eq $In })
|
|
if ($matches.Count -ne 1) { throw "Parameter must exist exactly once: $In $Name" }
|
|
return $matches[0]
|
|
}
|
|
|
|
function Assert-PathIdParameter {
|
|
param([object[]]$Parameters, [string]$Name, [string]$Label)
|
|
$parameter = Get-Parameter -Parameters $Parameters -Name $Name -In 'path'
|
|
Assert-Contract -Condition ((Get-PropertyValue -Object $parameter -Name 'required') -eq $true) -Message "$Label must be required"
|
|
Assert-StringSchema -Schema (Get-PropertyValue -Object $parameter -Name 'schema') -Label $Label
|
|
}
|
|
|
|
function Assert-ExactParameters {
|
|
param([object[]]$Parameters, [string[]]$Expected, [string]$Label)
|
|
$actual = @($Parameters | ForEach-Object { "$(Get-PropertyValue -Object $_ -Name 'in'):$(Get-PropertyValue -Object $_ -Name 'name')" })
|
|
Assert-Contract -Condition ($actual.Count -eq $Expected.Count) -Message "$Label count drifted: actual=$($actual.Count) expected=$($Expected.Count)"
|
|
Assert-ExactSet -Actual $actual -Expected $Expected -Label $Label
|
|
}
|
|
|
|
function Get-ResponseObject {
|
|
param([object]$Operation, [string]$Status)
|
|
$responses = Get-PropertyValue -Object $Operation -Name 'responses'
|
|
$property = if ($null -eq $responses) { $null } else { $responses.PSObject.Properties[$Status] }
|
|
if ($null -eq $property) { throw "Response missing: HTTP $Status" }
|
|
return Resolve-LocalRef -Value $property.Value
|
|
}
|
|
|
|
function Get-ResponseSchema {
|
|
param([object]$Operation, [string]$Status)
|
|
$response = Get-ResponseObject -Operation $Operation -Status $Status
|
|
$content = Get-PropertyValue -Object $response -Name 'content'
|
|
$jsonContent = if ($null -eq $content) { $null } else { $content.PSObject.Properties['application/json'] }
|
|
if ($null -eq $jsonContent) { throw "HTTP $Status must define application/json content" }
|
|
$schema = Get-PropertyValue -Object $jsonContent.Value -Name 'schema'
|
|
if ($null -eq $schema) { throw "HTTP $Status application/json schema missing" }
|
|
return $schema
|
|
}
|
|
|
|
function Assert-ResponseStatusMatrix {
|
|
param([object]$Operation, [string]$Label, [bool]$RequiresConflict)
|
|
$responses = Get-PropertyValue -Object $Operation -Name 'responses'
|
|
$names = @($responses.PSObject.Properties.Name)
|
|
foreach ($status in @('200', '400', '401', '403', '404', '422', '429')) {
|
|
Assert-Contract -Condition ($status -in $names) -Message "$Label response missing HTTP $status"
|
|
}
|
|
if ($RequiresConflict) { Assert-Contract -Condition ('409' -in $names) -Message "$Label response missing HTTP 409" }
|
|
Assert-Contract -Condition ('5XX' -in $names) -Message "$Label must declare the 5XX wildcard response"
|
|
}
|
|
|
|
function Assert-ResponseDataRoot {
|
|
param([object]$Operation, [string]$RootSchemaName, [string]$Label)
|
|
$schema = Get-ResponseSchema -Operation $Operation -Status '200'
|
|
$wrapper = Resolve-Schema -Schema $schema
|
|
Assert-ObjectTypedSchema -Schema $wrapper -Label "$Label response wrapper"
|
|
$data = Get-SchemaProperty -Schema $wrapper -Name 'data'
|
|
Assert-Contract -Condition ($null -ne $data) -Message "$Label response wrapper must define data"
|
|
Assert-Contract -Condition ('data' -in @(Get-SchemaRequiredNames -Schema $wrapper)) -Message "$Label response wrapper data must be required"
|
|
Assert-Contract -Condition ([string](Get-PropertyValue -Object $data -Name '$ref') -eq "#/components/schemas/$RootSchemaName") -Message "$Label response data must reference $RootSchemaName"
|
|
return Resolve-Schema -Schema $data
|
|
}
|
|
|
|
function Assert-ResponseBusinessCode {
|
|
param([object]$Operation, [string]$Status, [string]$Code, [string]$Label)
|
|
$schema = Resolve-Schema -Schema (Get-ResponseSchema -Operation $Operation -Status $Status)
|
|
Assert-PureOneOfOwner -Schema $schema -Label "$Label HTTP $Status error"
|
|
$variants = @(Get-PropertyValue -Object $schema -Name 'oneOf')
|
|
Assert-Contract -Condition ($variants.Count -gt 0) -Message "$Label HTTP $Status must use oneOf error variants"
|
|
$codes = @()
|
|
foreach ($variant in $variants) {
|
|
$resolvedVariant = Resolve-Schema -Schema $variant
|
|
Assert-ObjectTypedSchema -Schema $resolvedVariant -Label "$Label HTTP $Status error variant"
|
|
$businessCode = Get-SchemaProperty -Schema $resolvedVariant -Name 'businessCode'
|
|
if ($null -eq $businessCode -or 'businessCode' -notin @(Get-SchemaRequiredNames -Schema $resolvedVariant)) { continue }
|
|
Assert-ExactNonNullType -Schema $businessCode -Type 'string' -Label "$Label HTTP $Status businessCode"
|
|
Assert-Contract -Condition (-not (Test-IsNullableSchema -Schema $businessCode)) -Message "$Label HTTP $Status businessCode must not be nullable"
|
|
try { $codes += Get-SingleLiteral -Schema $businessCode -Label "$Label HTTP $Status businessCode" } catch { continue }
|
|
}
|
|
Assert-Contract -Condition ($Code -in $codes) -Message "$Label HTTP $Status must expose required root businessCode=$Code as a typed oneOf variant"
|
|
}
|
|
|
|
function Add-ReachableSchemaNames {
|
|
param([object]$Value, [System.Collections.Generic.HashSet[string]]$Names)
|
|
if ($null -eq $Value) { return }
|
|
$serialized = $Value | ConvertTo-Json -Depth 100 -Compress
|
|
foreach ($match in [regex]::Matches($serialized, '#/components/schemas/(?<name>[A-Za-z0-9_.-]+)')) {
|
|
$name = $match.Groups['name'].Value
|
|
if ($Names.Add($name)) {
|
|
$property = $document.components.schemas.PSObject.Properties[$name]
|
|
if ($null -eq $property) { throw "Referenced schema missing: $name" }
|
|
Add-ReachableSchemaNames -Value $property.Value -Names $Names
|
|
}
|
|
}
|
|
}
|
|
|
|
function Add-ReachableComponentKeys {
|
|
param([object]$Value, [System.Collections.Generic.HashSet[string]]$Keys)
|
|
foreach ($key in @(Get-ComponentRefSet -Value $Value)) {
|
|
if ($Keys.Add($key)) {
|
|
$parts = $key -split '/', 2
|
|
$section = Get-PropertyValue -Object $document.components -Name $parts[0]
|
|
$property = if ($null -eq $section) { $null } else { $section.PSObject.Properties[$parts[1]] }
|
|
if ($null -eq $property) { throw "Referenced component missing: $key" }
|
|
Add-ReachableComponentKeys -Value $property.Value -Keys $Keys
|
|
}
|
|
}
|
|
}
|
|
|
|
function Test-SchemaGraphContainsFields {
|
|
param([object]$Schema, [string[]]$Fields, [System.Collections.Generic.HashSet[string]]$VisitedRefs)
|
|
if ($null -eq $Schema) { return $false }
|
|
if (Test-HasProperty -Object $Schema -Name '$ref') {
|
|
$ref = [string](Get-PropertyValue -Object $Schema -Name '$ref')
|
|
if (-not $VisitedRefs.Add($ref)) { return $false }
|
|
}
|
|
$resolved = Resolve-Schema -Schema $Schema
|
|
$propertyNames = @(Get-SchemaPropertyNames -Schema $resolved)
|
|
if (@($Fields | Where-Object { $_ -notin $propertyNames }).Count -eq 0) { return $true }
|
|
foreach ($keyword in @('allOf', 'oneOf', 'anyOf')) {
|
|
foreach ($branch in @(Get-PropertyValue -Object $resolved -Name $keyword)) {
|
|
if (Test-SchemaGraphContainsFields -Schema $branch -Fields $Fields -VisitedRefs $VisitedRefs) { return $true }
|
|
}
|
|
}
|
|
$properties = Get-PropertyValue -Object $resolved -Name 'properties'
|
|
if ($null -ne $properties) {
|
|
foreach ($property in $properties.PSObject.Properties) {
|
|
if (Test-SchemaGraphContainsFields -Schema $property.Value -Fields $Fields -VisitedRefs $VisitedRefs) { return $true }
|
|
}
|
|
}
|
|
$items = Get-PropertyValue -Object $resolved -Name 'items'
|
|
if ($null -ne $items -and (Test-SchemaGraphContainsFields -Schema $items -Fields $Fields -VisitedRefs $VisitedRefs)) { return $true }
|
|
return $false
|
|
}
|
|
|
|
function Assert-SchemaDialect {
|
|
param([object]$Schema, [string]$Label)
|
|
if ($null -eq $Schema) { return }
|
|
if (Test-HasProperty -Object $Schema -Name '$schema') { throw "$Label overrides the document JSON Schema dialect" }
|
|
if (Test-HasProperty -Object $Schema -Name '$ref') {
|
|
$annotationKeywords = @('$ref', 'title', 'summary', 'description', 'deprecated', 'readOnly', 'writeOnly', 'examples', 'externalDocs')
|
|
$validationSiblings = @($Schema.PSObject.Properties.Name | Where-Object { $_ -notin $annotationKeywords })
|
|
if ($validationSiblings.Count -gt 0) { throw "$Label uses validation siblings beside a schema reference: $($validationSiblings -join ',')" }
|
|
}
|
|
if ($isOpenApi30) {
|
|
if (Test-HasProperty -Object $Schema -Name 'const') { throw "$Label uses const, which is not valid in OpenAPI 3.0" }
|
|
if (Test-HasProperty -Object $Schema -Name 'unevaluatedProperties') { throw "$Label uses unevaluatedProperties, which is not valid in OpenAPI 3.0" }
|
|
$typeValue = Get-PropertyValue -Object $Schema -Name 'type'
|
|
if ($typeValue -is [System.Array] -or $typeValue -eq 'null') { throw "$Label uses a JSON Schema type form that is not valid in OpenAPI 3.0" }
|
|
} else {
|
|
if (Test-HasProperty -Object $Schema -Name 'nullable') { throw "$Label uses the retired nullable keyword in OpenAPI 3.1" }
|
|
}
|
|
foreach ($keyword in @('allOf', 'oneOf', 'anyOf', 'prefixItems')) {
|
|
foreach ($branch in @(Get-PropertyValue -Object $Schema -Name $keyword)) { Assert-SchemaDialect -Schema $branch -Label "$Label.$keyword" }
|
|
}
|
|
foreach ($keyword in @('not', 'if', 'then', 'else', 'contains', 'propertyNames', 'contentSchema')) {
|
|
$child = Get-PropertyValue -Object $Schema -Name $keyword
|
|
if ($null -ne $child) { Assert-SchemaDialect -Schema $child -Label "$Label.$keyword" }
|
|
}
|
|
$properties = Get-PropertyValue -Object $Schema -Name 'properties'
|
|
if ($null -ne $properties) {
|
|
foreach ($property in $properties.PSObject.Properties) { Assert-SchemaDialect -Schema $property.Value -Label "$Label.$($property.Name)" }
|
|
}
|
|
$items = Get-PropertyValue -Object $Schema -Name 'items'
|
|
if ($null -ne $items) { Assert-SchemaDialect -Schema $items -Label "$Label.items" }
|
|
$additional = Get-PropertyValue -Object $Schema -Name 'additionalProperties'
|
|
if ($null -ne $additional -and -not ($additional -is [bool])) { Assert-SchemaDialect -Schema $additional -Label "$Label.additionalProperties" }
|
|
$unevaluated = Get-PropertyValue -Object $Schema -Name 'unevaluatedProperties'
|
|
if ($null -ne $unevaluated -and -not ($unevaluated -is [bool])) { Assert-SchemaDialect -Schema $unevaluated -Label "$Label.unevaluatedProperties" }
|
|
foreach ($mapKeyword in @('patternProperties', 'dependentSchemas', '$defs', 'definitions')) {
|
|
$map = Get-PropertyValue -Object $Schema -Name $mapKeyword
|
|
if ($null -ne $map) {
|
|
foreach ($property in $map.PSObject.Properties) { Assert-SchemaDialect -Schema $property.Value -Label "$Label.$mapKeyword.$($property.Name)" }
|
|
}
|
|
}
|
|
}
|
|
|
|
function Assert-OpenApiValueDialect {
|
|
param([object]$Value, [string]$Label)
|
|
if ($null -eq $Value -or $Value -is [string] -or $Value -is [ValueType]) { return }
|
|
if ($Value -is [System.Collections.IEnumerable] -and -not ($Value -is [pscustomobject])) {
|
|
$index = 0
|
|
foreach ($item in $Value) {
|
|
Assert-OpenApiValueDialect -Value $item -Label "$Label[$index]"
|
|
$index += 1
|
|
}
|
|
return
|
|
}
|
|
foreach ($property in $Value.PSObject.Properties) {
|
|
if ($property.Name -eq 'schema') {
|
|
Assert-SchemaDialect -Schema $property.Value -Label "$Label.schema"
|
|
} else {
|
|
Assert-OpenApiValueDialect -Value $property.Value -Label "$Label.$($property.Name)"
|
|
}
|
|
}
|
|
}
|
|
|
|
# 门禁自检:可空类型、allOf 字段冲突、伪引用和遗漏的组件类别都不能绕过合同。
|
|
if ($isOpenApi30) {
|
|
$nullableStringProbe = '{"type":"string","nullable":true}' | ConvertFrom-Json
|
|
$nullOnlyProbe = '{"type":"string","nullable":true,"enum":[null]}' | ConvertFrom-Json
|
|
} else {
|
|
$nullableStringProbe = '{"type":["string","null"]}' | ConvertFrom-Json
|
|
$nullOnlyProbe = '{"type":"null"}' | ConvertFrom-Json
|
|
}
|
|
Assert-Contract -Condition (-not (Test-IsNullOnlySchema -Schema $nullableStringProbe)) -Message 'Contract self-check failed: nullable string was treated as null-only'
|
|
Assert-Contract -Condition (Test-IsNullOnlySchema -Schema $nullOnlyProbe) -Message 'Contract self-check failed: null-only schema was rejected'
|
|
$nullableIntegerProbe = if ($isOpenApi30) { '{"type":"integer","nullable":true}' } else { '{"type":["integer","null"]}' }
|
|
$nullableArrayProbe = if ($isOpenApi30) { '{"type":"array","nullable":true,"items":{"type":"string"}}' } else { '{"type":["array","null"],"items":{"type":"string"}}' }
|
|
$nullableObjectProbe = if ($isOpenApi30) { '{"type":"object","nullable":true,"additionalProperties":false,"properties":{}}' } else { '{"type":["object","null"],"additionalProperties":false,"properties":{}}' }
|
|
$nullableEnumProbe = if ($isOpenApi30) { '{"type":"string","nullable":true,"enum":["A"]}' } else { '{"type":["string","null"],"enum":["A",null]}' }
|
|
foreach ($probe in @(
|
|
@{ Label = 'integer'; Run = { Assert-IntegerSchema -Schema ($nullableIntegerProbe | ConvertFrom-Json) -Label 'probe' } },
|
|
@{ Label = 'array'; Run = { [void](Assert-ArraySchema -Schema ($nullableArrayProbe | ConvertFrom-Json) -Label 'probe') } },
|
|
@{ Label = 'object'; Run = { Assert-ExactObjectSchema -Schema ($nullableObjectProbe | ConvertFrom-Json) -Fields @() -Required @() -Label 'probe' } },
|
|
@{ Label = 'enum'; Run = { Assert-ExactEnum -Schema ($nullableEnumProbe | ConvertFrom-Json) -Values @('A') -Label 'probe' } }
|
|
)) {
|
|
$rejected = $false
|
|
try { & $probe.Run } catch { $rejected = $true }
|
|
Assert-Contract -Condition $rejected -Message "Contract self-check failed: nullable $($probe.Label) was accepted"
|
|
}
|
|
$duplicatePropertyProbe = '{"allOf":[{"type":"object","properties":{"value":{"type":"integer"}}},{"type":"object","properties":{"value":{"type":"string"}}}]}' | ConvertFrom-Json
|
|
$duplicateRejected = $false
|
|
try { [void](Get-SchemaProperty -Schema $duplicatePropertyProbe -Name 'value') } catch { $duplicateRejected = $true }
|
|
Assert-Contract -Condition $duplicateRejected -Message 'Contract self-check failed: conflicting allOf property was accepted'
|
|
$refSiblingRejected = $false
|
|
try { Assert-StringSchema -Schema ('{"$ref":"#/components/schemas/NoSuchProbe","type":"null"}' | ConvertFrom-Json) -Label 'probe' } catch { $refSiblingRejected = $true }
|
|
Assert-Contract -Condition $refSiblingRejected -Message 'Contract self-check failed: schema reference validation sibling was accepted'
|
|
$scalarCombinationRejected = $false
|
|
try { Assert-IntegerSchema -Schema ('{"type":"integer","allOf":[{"type":"string"}]}' | ConvertFrom-Json) -Label 'probe' } catch { $scalarCombinationRejected = $true }
|
|
Assert-Contract -Condition $scalarCombinationRejected -Message 'Contract self-check failed: contradictory scalar allOf was accepted'
|
|
$inlineDialectRejected = $false
|
|
try { Assert-OpenApiValueDialect -Value ('{"parameters":[{"schema":{"type":"string","not":{"const":"forbidden"}}}]}' | ConvertFrom-Json) -Label 'probe' } catch { $inlineDialectRejected = $true }
|
|
if ($isOpenApi30) { Assert-Contract -Condition $inlineDialectRejected -Message 'Contract self-check failed: illegal inline OpenAPI 3.0 const was accepted' }
|
|
$unionOwnerRejected = $false
|
|
try { Assert-PureOneOfOwner -Schema ('{"type":"string","oneOf":[{"type":"object"}]}' | ConvertFrom-Json) -Label 'probe' } catch { $unionOwnerRejected = $true }
|
|
Assert-Contract -Condition $unionOwnerRejected -Message 'Contract self-check failed: conflicting oneOf owner validation was accepted'
|
|
$refProbe = '{"description":"#/components/schemas/Fake","schema":{"$ref":"#/components/schemas/Real"},"header":{"$ref":"#/components/headers/TraceId"}}' | ConvertFrom-Json
|
|
Assert-ExactSet -Actual @(Get-ComponentRefSet -Value $refProbe) -Expected @('headers/TraceId', 'schemas/Real') -Label 'structured component ref self-check'
|
|
|
|
# 第一阶段只聚合前置缺口,避免在后端尚未发布 v2 时产生级联空引用错误。
|
|
$jsonMissingOperations = @()
|
|
$yamlMissingOperations = @()
|
|
foreach ($target in $operations) {
|
|
$pathProperty = $document.paths.PSObject.Properties[$target.Path]
|
|
if ($null -eq $pathProperty -or $null -eq $pathProperty.Value.PSObject.Properties[$target.Method]) {
|
|
$jsonMissingOperations += "$($target.Method.ToUpperInvariant()) $($target.Path)"
|
|
}
|
|
if ($null -eq (Get-YamlOperationBlock -Path $target.Path -Method $target.Method)) {
|
|
$yamlMissingOperations += "$($target.Method.ToUpperInvariant()) $($target.Path)"
|
|
}
|
|
}
|
|
$yamlSchemaNames = @(Get-YamlComponentNames -Section 'schemas')
|
|
$jsonMissingSchemas = @($rootSchemaNames | Where-Object { $null -eq $document.components.schemas.PSObject.Properties[$_] })
|
|
$yamlMissingSchemas = @($rootSchemaNames | Where-Object { $_ -notin $yamlSchemaNames })
|
|
|
|
if ($jsonMissingOperations.Count -gt 0 -or $yamlMissingOperations.Count -gt 0 -or $jsonMissingSchemas.Count -gt 0 -or $yamlMissingSchemas.Count -gt 0) {
|
|
$lines = @('LINEAGE-OPENAPI-CONTRACT BLOCKED')
|
|
$lines += 'JSON missing operations:'
|
|
$lines += @($jsonMissingOperations | Sort-Object | ForEach-Object { "- $_" })
|
|
$lines += 'YAML missing operations:'
|
|
$lines += @($yamlMissingOperations | Sort-Object | ForEach-Object { "- $_" })
|
|
$lines += 'JSON missing schema owners:'
|
|
$lines += @($jsonMissingSchemas | Sort-Object | ForEach-Object { "- $_" })
|
|
$lines += 'YAML missing schema owners:'
|
|
$lines += @($yamlMissingSchemas | Sort-Object | ForEach-Object { "- $_" })
|
|
$lines += 'Protected-local legacy evidence:'
|
|
$lines += '- GET /genealogy/app/genealogies/{genealogyId}/lineage/tree -> LineagePersonTreeResult -> RLineagePersonTreeList -> LineagePersonTreeView[]'
|
|
throw ($lines -join [Environment]::NewLine)
|
|
}
|
|
|
|
$resolvedOperations = @{}
|
|
$operationIds = @()
|
|
$allOperationIds = @()
|
|
foreach ($pathProperty in $document.paths.PSObject.Properties) {
|
|
foreach ($method in @('get', 'put', 'post', 'delete', 'patch', 'options', 'head', 'trace')) {
|
|
$methodProperty = $pathProperty.Value.PSObject.Properties[$method]
|
|
if ($null -ne $methodProperty) {
|
|
$candidateId = [string](Get-PropertyValue -Object $methodProperty.Value -Name 'operationId')
|
|
if (-not [string]::IsNullOrWhiteSpace($candidateId)) { $allOperationIds += $candidateId }
|
|
}
|
|
}
|
|
}
|
|
foreach ($target in $operations) {
|
|
$pathItem = $document.paths.PSObject.Properties[$target.Path].Value
|
|
$operation = $pathItem.PSObject.Properties[$target.Method].Value
|
|
$key = "$($target.Method.ToUpperInvariant()) $($target.Path)"
|
|
$resolvedOperations[$key] = @{ PathItem = $pathItem; Operation = $operation }
|
|
$operationId = [string](Get-PropertyValue -Object $operation -Name 'operationId')
|
|
Assert-Contract -Condition (-not [string]::IsNullOrWhiteSpace($operationId)) -Message "$key operationId must be non-empty"
|
|
Assert-Contract -Condition (@($allOperationIds | Where-Object { $_ -eq $operationId }).Count -eq 1) -Message "$key operationId collides with another operation: $operationId"
|
|
$operationIds += $operationId
|
|
Assert-OpenApiValueDialect -Value $operation -Label $key
|
|
}
|
|
Assert-Contract -Condition (@($operationIds | Sort-Object -Unique).Count -eq $operationIds.Count) -Message 'Lineage v2 operationIds must be unique'
|
|
|
|
$treeKey = 'GET /genealogy/app/v2/genealogies/{genealogyId}/lineage/tree'
|
|
$overviewKey = 'GET /genealogy/app/v2/genealogies/{genealogyId}/lineage/tree/overview'
|
|
$locatorKey = 'GET /genealogy/app/v2/genealogies/{genealogyId}/lineage/persons/{personId}/locator'
|
|
$patchKey = 'PATCH /genealogy/app/v2/genealogies/{genealogyId}/lineage/relationships/{relationshipId}'
|
|
$tree = $resolvedOperations[$treeKey]
|
|
$overview = $resolvedOperations[$overviewKey]
|
|
$locator = $resolvedOperations[$locatorKey]
|
|
$patch = $resolvedOperations[$patchKey]
|
|
|
|
$treeParameters = @(Get-OperationParameters -PathItem $tree.PathItem -Operation $tree.Operation)
|
|
Assert-ExactParameters -Parameters $treeParameters -Expected @(
|
|
'path:genealogyId',
|
|
'query:mode', 'query:focusPersonId', 'query:ancestorDepth', 'query:descendantDepth',
|
|
'query:boundaryId', 'query:cursor', 'query:limit', 'query:treeVersion'
|
|
) -Label 'tree parameters'
|
|
Assert-PathIdParameter -Parameters $treeParameters -Name 'genealogyId' -Label 'tree genealogyId'
|
|
$treeQueryNames = @($treeParameters | Where-Object { (Get-PropertyValue -Object $_ -Name 'in') -eq 'query' } | ForEach-Object { Get-PropertyValue -Object $_ -Name 'name' })
|
|
$treeQueryExpected = @('mode', 'focusPersonId', 'ancestorDepth', 'descendantDepth', 'boundaryId', 'cursor', 'limit', 'treeVersion')
|
|
Assert-ExactSet -Actual $treeQueryNames -Expected $treeQueryExpected -Label 'tree query parameters'
|
|
$mode = Get-Parameter -Parameters $treeParameters -Name 'mode' -In 'query'
|
|
Assert-Contract -Condition ((Get-PropertyValue -Object $mode -Name 'required') -eq $true) -Message 'tree mode must be required'
|
|
Assert-ExactEnum -Schema (Get-PropertyValue -Object $mode -Name 'schema') -Values @('FOCUS', 'BOUNDARY') -Label 'tree mode'
|
|
foreach ($name in @('focusPersonId', 'ancestorDepth', 'descendantDepth', 'boundaryId', 'cursor', 'limit', 'treeVersion')) {
|
|
$parameter = Get-Parameter -Parameters $treeParameters -Name $name -In 'query'
|
|
Assert-Contract -Condition ((Get-PropertyValue -Object $parameter -Name 'required') -ne $true) -Message "tree $name must not be globally required; mode-specific requirements need deployment tests"
|
|
}
|
|
Assert-StablePersonIdSchema -Schema (Get-PropertyValue -Object (Get-Parameter -Parameters $treeParameters -Name 'focusPersonId' -In 'query') -Name 'schema') -Label 'tree focusPersonId'
|
|
foreach ($name in @('ancestorDepth', 'descendantDepth')) {
|
|
$parameter = Get-Parameter -Parameters $treeParameters -Name $name -In 'query'
|
|
Assert-IntegerSchema -Schema (Get-PropertyValue -Object $parameter -Name 'schema') -Label "tree $name" -Minimum 0 -Maximum 20 -Default 2
|
|
}
|
|
foreach ($name in @('boundaryId', 'cursor', 'treeVersion')) {
|
|
$parameter = Get-Parameter -Parameters $treeParameters -Name $name -In 'query'
|
|
Assert-StringSchema -Schema (Get-PropertyValue -Object $parameter -Name 'schema') -Label "tree $name"
|
|
}
|
|
$limit = Get-Parameter -Parameters $treeParameters -Name 'limit' -In 'query'
|
|
Assert-IntegerSchema -Schema (Get-PropertyValue -Object $limit -Name 'schema') -Label 'tree limit' -Minimum 1 -Maximum 500 -Default 200
|
|
Assert-ResponseStatusMatrix -Operation $tree.Operation -Label $treeKey -RequiresConflict $true
|
|
Assert-ResponseBusinessCode -Operation $tree.Operation -Status '404' -Code 'LINEAGE_FOCUS_NOT_AVAILABLE' -Label $treeKey
|
|
Assert-ResponseBusinessCode -Operation $tree.Operation -Status '409' -Code 'TREE_VERSION_CHANGED' -Label $treeKey
|
|
Assert-ResponseBusinessCode -Operation $tree.Operation -Status '422' -Code 'LINEAGE_QUERY_INVALID' -Label $treeKey
|
|
|
|
$overviewParameters = @(Get-OperationParameters -PathItem $overview.PathItem -Operation $overview.Operation)
|
|
Assert-ExactParameters -Parameters $overviewParameters -Expected @('path:genealogyId', 'query:treeVersion') -Label 'overview parameters'
|
|
Assert-PathIdParameter -Parameters $overviewParameters -Name 'genealogyId' -Label 'overview genealogyId'
|
|
$overviewQueryNames = @($overviewParameters | Where-Object { (Get-PropertyValue -Object $_ -Name 'in') -eq 'query' } | ForEach-Object { Get-PropertyValue -Object $_ -Name 'name' })
|
|
Assert-ExactSet -Actual $overviewQueryNames -Expected @('treeVersion') -Label 'overview query parameters'
|
|
$overviewVersion = Get-Parameter -Parameters $overviewParameters -Name 'treeVersion' -In 'query'
|
|
Assert-Contract -Condition ((Get-PropertyValue -Object $overviewVersion -Name 'required') -eq $true) -Message 'overview treeVersion must be required'
|
|
Assert-StringSchema -Schema (Get-PropertyValue -Object $overviewVersion -Name 'schema') -Label 'overview treeVersion'
|
|
Assert-ResponseStatusMatrix -Operation $overview.Operation -Label $overviewKey -RequiresConflict $true
|
|
Assert-ResponseBusinessCode -Operation $overview.Operation -Status '409' -Code 'TREE_VERSION_CHANGED' -Label $overviewKey
|
|
|
|
$locatorParameters = @(Get-OperationParameters -PathItem $locator.PathItem -Operation $locator.Operation)
|
|
Assert-ExactParameters -Parameters $locatorParameters -Expected @('path:genealogyId', 'path:personId') -Label 'locator parameters'
|
|
Assert-PathIdParameter -Parameters $locatorParameters -Name 'genealogyId' -Label 'locator genealogyId'
|
|
Assert-PathIdParameter -Parameters $locatorParameters -Name 'personId' -Label 'locator personId'
|
|
$locatorPersonParameter = Get-Parameter -Parameters $locatorParameters -Name 'personId' -In 'path'
|
|
Assert-StablePersonIdSchema -Schema (Get-PropertyValue -Object $locatorPersonParameter -Name 'schema') -Label 'locator personId'
|
|
$locatorQueryNames = @($locatorParameters | Where-Object { (Get-PropertyValue -Object $_ -Name 'in') -eq 'query' } | ForEach-Object { Get-PropertyValue -Object $_ -Name 'name' })
|
|
Assert-ExactSet -Actual $locatorQueryNames -Expected @() -Label 'locator query parameters'
|
|
Assert-ResponseStatusMatrix -Operation $locator.Operation -Label $locatorKey -RequiresConflict $false
|
|
|
|
$patchParameters = @(Get-OperationParameters -PathItem $patch.PathItem -Operation $patch.Operation)
|
|
Assert-ExactParameters -Parameters $patchParameters -Expected @('path:genealogyId', 'path:relationshipId', 'header:If-Match') -Label 'relationship patch parameters'
|
|
Assert-PathIdParameter -Parameters $patchParameters -Name 'genealogyId' -Label 'relationship patch genealogyId'
|
|
Assert-PathIdParameter -Parameters $patchParameters -Name 'relationshipId' -Label 'relationship patch relationshipId'
|
|
$patchQueryNames = @($patchParameters | Where-Object { (Get-PropertyValue -Object $_ -Name 'in') -eq 'query' } | ForEach-Object { Get-PropertyValue -Object $_ -Name 'name' })
|
|
Assert-ExactSet -Actual $patchQueryNames -Expected @() -Label 'relationship patch query parameters'
|
|
$ifMatch = Get-Parameter -Parameters $patchParameters -Name 'If-Match' -In 'header'
|
|
Assert-Contract -Condition ((Get-PropertyValue -Object $ifMatch -Name 'required') -eq $true) -Message 'relationship patch If-Match must be required'
|
|
Assert-StringSchema -Schema (Get-PropertyValue -Object $ifMatch -Name 'schema') -Label 'relationship patch If-Match'
|
|
Assert-ResponseStatusMatrix -Operation $patch.Operation -Label $patchKey -RequiresConflict $true
|
|
Assert-ResponseBusinessCode -Operation $patch.Operation -Status '409' -Code 'TREE_VERSION_CHANGED' -Label $patchKey
|
|
Assert-ResponseBusinessCode -Operation $patch.Operation -Status '422' -Code 'RELATIONSHIP_PATCH_EMPTY' -Label $patchKey
|
|
|
|
$graphRoot = Assert-ResponseDataRoot -Operation $tree.Operation -RootSchemaName 'LineageGraphWindow' -Label $treeKey
|
|
$graphBranches = Get-DiscriminatedBranches -Schema $graphRoot -PropertyName 'state' -Values @('EMPTY', 'POPULATED') -Label 'LineageGraphWindow'
|
|
$graphFields = @('version', 'state', 'genealogyId', 'nodes', 'familyUnits', 'edges', 'window')
|
|
foreach ($state in @('EMPTY', 'POPULATED')) {
|
|
Assert-ExactObjectSchema -Schema $graphBranches[$state] -Fields $graphFields -Required $graphFields -Label "LineageGraphWindow.$state"
|
|
Assert-StringSchema -Schema (Get-SchemaProperty -Schema $graphBranches[$state] -Name 'genealogyId') -Label "LineageGraphWindow.$state.genealogyId"
|
|
}
|
|
|
|
$versionFields = @('schemaVersion', 'treeVersion', 'generatedAt')
|
|
foreach ($state in @('EMPTY', 'POPULATED')) {
|
|
$branchVersion = Get-SchemaProperty -Schema $graphBranches[$state] -Name 'version'
|
|
Assert-ExactObjectSchema -Schema $branchVersion -Fields $versionFields -Required $versionFields -Label "LineageVersion.$state"
|
|
Assert-Contract -Condition ((Get-SingleLiteral -Schema (Get-SchemaProperty -Schema $branchVersion -Name 'schemaVersion') -Label "LineageVersion.$state.schemaVersion") -eq '2.0') -Message "LineageVersion.$state.schemaVersion must be 2.0"
|
|
Assert-StringSchema -Schema (Get-SchemaProperty -Schema $branchVersion -Name 'treeVersion') -Label "LineageVersion.$state.treeVersion"
|
|
$branchGeneratedAt = Resolve-Schema -Schema (Get-SchemaProperty -Schema $branchVersion -Name 'generatedAt')
|
|
Assert-StringSchema -Schema $branchGeneratedAt -Label "LineageVersion.$state.generatedAt"
|
|
Assert-Contract -Condition ((Get-PropertyValue -Object $branchGeneratedAt -Name 'format') -eq 'date-time') -Message "LineageVersion.$state.generatedAt must use date-time format"
|
|
}
|
|
|
|
$emptyGraph = $graphBranches['EMPTY']
|
|
foreach ($name in @('nodes', 'familyUnits', 'edges')) {
|
|
[void](Assert-ArraySchema -Schema (Get-SchemaProperty -Schema $emptyGraph -Name $name) -Label "LineageGraphWindow.EMPTY.$name" -MaximumItems 0)
|
|
}
|
|
$emptyWindow = Get-SchemaProperty -Schema $emptyGraph -Name 'window'
|
|
$windowFields = @('focusPersonId', 'entryPersonIds', 'scope', 'generationRange', 'returnedNodeCount', 'boundaries')
|
|
Assert-ExactObjectSchema -Schema $emptyWindow -Fields $windowFields -Required $windowFields -Label 'LineageWindow.EMPTY'
|
|
Assert-Contract -Condition (Test-IsNullOnlySchema -Schema (Get-SchemaProperty -Schema $emptyWindow -Name 'focusPersonId')) -Message 'EMPTY focusPersonId must be null-only'
|
|
[void](Assert-ArraySchema -Schema (Get-SchemaProperty -Schema $emptyWindow -Name 'entryPersonIds') -Label 'EMPTY entryPersonIds' -MaximumItems 0)
|
|
Assert-Contract -Condition (Test-IsNullOnlySchema -Schema (Get-SchemaProperty -Schema $emptyWindow -Name 'generationRange')) -Message 'EMPTY generationRange must be null-only'
|
|
$emptyCount = Resolve-Schema -Schema (Get-SchemaProperty -Schema $emptyWindow -Name 'returnedNodeCount')
|
|
Assert-IntegerSchema -Schema $emptyCount -Label 'EMPTY returnedNodeCount' -Minimum 0 -Maximum 0
|
|
[void](Assert-ArraySchema -Schema (Get-SchemaProperty -Schema $emptyWindow -Name 'boundaries') -Label 'EMPTY boundaries' -MaximumItems 0)
|
|
$emptyScope = Get-SchemaProperty -Schema $emptyWindow -Name 'scope'
|
|
Assert-ExactObjectSchema -Schema $emptyScope -Fields @('ancestorDepth', 'descendantDepth') -Required @('ancestorDepth', 'descendantDepth') -Label 'LineageWindow.EMPTY.scope'
|
|
Assert-IntegerSchema -Schema (Get-SchemaProperty -Schema $emptyScope -Name 'ancestorDepth') -Label 'EMPTY scope.ancestorDepth' -Minimum 0 -Maximum 20
|
|
Assert-IntegerSchema -Schema (Get-SchemaProperty -Schema $emptyScope -Name 'descendantDepth') -Label 'EMPTY scope.descendantDepth' -Minimum 0 -Maximum 20
|
|
|
|
$populatedGraph = $graphBranches['POPULATED']
|
|
$nodeSchema = Assert-ArraySchema -Schema (Get-SchemaProperty -Schema $populatedGraph -Name 'nodes') -Label 'POPULATED nodes' -MinimumItems 1
|
|
[void](Assert-ArraySchema -Schema (Get-SchemaProperty -Schema $populatedGraph -Name 'familyUnits') -Label 'POPULATED familyUnits')
|
|
[void](Assert-ArraySchema -Schema (Get-SchemaProperty -Schema $populatedGraph -Name 'edges') -Label 'POPULATED edges')
|
|
$populatedWindow = Get-SchemaProperty -Schema $populatedGraph -Name 'window'
|
|
Assert-ExactObjectSchema -Schema $populatedWindow -Fields $windowFields -Required $windowFields -Label 'LineageWindow.POPULATED'
|
|
Assert-StablePersonIdSchema -Schema (Get-SchemaProperty -Schema $populatedWindow -Name 'focusPersonId') -Label 'POPULATED focusPersonId'
|
|
$entryId = Assert-ArraySchema -Schema (Get-SchemaProperty -Schema $populatedWindow -Name 'entryPersonIds') -Label 'POPULATED entryPersonIds' -MinimumItems 1
|
|
Assert-StringSchema -Schema $entryId -Label 'POPULATED entryPersonIds item'
|
|
Assert-GenerationRangeSchema -Schema (Get-SchemaProperty -Schema $populatedWindow -Name 'generationRange') -Label 'POPULATED generationRange'
|
|
Assert-IntegerSchema -Schema (Get-SchemaProperty -Schema $populatedWindow -Name 'returnedNodeCount') -Label 'POPULATED returnedNodeCount' -Minimum 1
|
|
|
|
$scope = Get-SchemaProperty -Schema $populatedWindow -Name 'scope'
|
|
Assert-ExactObjectSchema -Schema $scope -Fields @('ancestorDepth', 'descendantDepth') -Required @('ancestorDepth', 'descendantDepth') -Label 'LineageWindow.scope'
|
|
Assert-IntegerSchema -Schema (Get-SchemaProperty -Schema $scope -Name 'ancestorDepth') -Label 'scope.ancestorDepth' -Minimum 0 -Maximum 20
|
|
Assert-IntegerSchema -Schema (Get-SchemaProperty -Schema $scope -Name 'descendantDepth') -Label 'scope.descendantDepth' -Minimum 0 -Maximum 20
|
|
|
|
$nodeBranches = Get-DiscriminatedBranches -Schema $nodeSchema -PropertyName 'visibility' -Values @('VISIBLE', 'REDACTED') -Label 'LineagePerson'
|
|
$visibleFields = @('id', 'generation', 'displayName', 'sex', 'avatarOssId', 'branchId', 'branchPath', 'order', 'visibility', 'entryReason')
|
|
$redactedFields = @('id', 'generation', 'displayName', 'order', 'visibility', 'entryReason')
|
|
Assert-ExactObjectSchema -Schema $nodeBranches['VISIBLE'] -Fields $visibleFields -Required $visibleFields -Label 'VisibleLineagePerson'
|
|
Assert-ExactObjectSchema -Schema $nodeBranches['REDACTED'] -Fields $redactedFields -Required $redactedFields -Label 'RedactedLineagePerson'
|
|
Assert-StablePersonIdSchema -Schema (Get-SchemaProperty -Schema $nodeBranches['VISIBLE'] -Name 'id') -Label 'VisibleLineagePerson.id'
|
|
Assert-StringSchema -Schema (Get-SchemaProperty -Schema $nodeBranches['VISIBLE'] -Name 'displayName') -Label 'VisibleLineagePerson.displayName'
|
|
$redactedId = Resolve-Schema -Schema (Get-SchemaProperty -Schema $nodeBranches['REDACTED'] -Name 'id')
|
|
Assert-StringSchema -Schema $redactedId -Label 'RedactedLineagePerson.id'
|
|
$redactedPattern = [string](Get-PropertyValue -Object $redactedId -Name 'pattern')
|
|
try { $redactedRegex = [regex]::new($redactedPattern) } catch { throw "RedactedLineagePerson.id has an invalid pattern: $redactedPattern" }
|
|
foreach ($accepted in @('redacted:t-1:17', 'redacted:t-20260722-1:opaque-token')) {
|
|
$match = $redactedRegex.Match($accepted)
|
|
Assert-Contract -Condition ($match.Success -and $match.Index -eq 0 -and $match.Length -eq $accepted.Length) -Message "RedactedLineagePerson.id rejects opaque ID: $accepted"
|
|
}
|
|
foreach ($rejected in @('p103', 'xredacted:t-1:17', 'redacted:t-1:17:extra', 'redacted::17', 'redacted:t-1:')) {
|
|
Assert-Contract -Condition (-not $redactedRegex.IsMatch($rejected)) -Message "RedactedLineagePerson.id accepts invalid opaque ID: $rejected"
|
|
}
|
|
foreach ($branchName in @('VISIBLE', 'REDACTED')) {
|
|
$branch = $nodeBranches[$branchName]
|
|
Assert-IntegerSchema -Schema (Get-SchemaProperty -Schema $branch -Name 'generation') -Label "$branchName generation" -Minimum 1
|
|
Assert-IntegerSchema -Schema (Get-SchemaProperty -Schema $branch -Name 'order') -Label "$branchName order" -Minimum 0
|
|
$entryReason = Get-SchemaProperty -Schema $branch -Name 'entryReason'
|
|
Assert-Contract -Condition (Test-IsNullableSchema -Schema $entryReason) -Message "$branchName entryReason must be nullable"
|
|
Assert-ExactEnum -Schema (Get-NonNullSchema -Schema $entryReason) -Values @('GENEALOGY_ROOT', 'WINDOW_CUT', 'DISCONNECTED_COMPONENT') -Label "$branchName entryReason" -AllowNullable $true
|
|
}
|
|
Assert-ExactEnum -Schema (Get-SchemaProperty -Schema $nodeBranches['VISIBLE'] -Name 'sex') -Values @('MALE', 'FEMALE', 'UNKNOWN') -Label 'VisibleLineagePerson.sex'
|
|
Assert-StringSchema -Schema (Get-SchemaProperty -Schema $nodeBranches['VISIBLE'] -Name 'avatarOssId') -Label 'VisibleLineagePerson.avatarOssId' -Nullable $true
|
|
Assert-StringSchema -Schema (Get-SchemaProperty -Schema $nodeBranches['VISIBLE'] -Name 'branchId') -Label 'VisibleLineagePerson.branchId'
|
|
$branchPathItem = Assert-ArraySchema -Schema (Get-SchemaProperty -Schema $nodeBranches['VISIBLE'] -Name 'branchPath') -Label 'VisibleLineagePerson.branchPath' -MinimumItems 1
|
|
Assert-StringSchema -Schema $branchPathItem -Label 'VisibleLineagePerson.branchPath item'
|
|
$redactedName = Get-SchemaProperty -Schema $nodeBranches['REDACTED'] -Name 'displayName'
|
|
Assert-Contract -Condition ((Get-SingleLiteral -Schema $redactedName -Label 'RedactedLineagePerson.displayName') -eq '隐私成员') -Message 'RedactedLineagePerson.displayName must be 隐私成员'
|
|
|
|
$familyItem = Assert-ArraySchema -Schema (Get-SchemaProperty -Schema $populatedGraph -Name 'familyUnits') -Label 'familyUnits'
|
|
$familyFields = @('id', 'anchorPersonId', 'partnerRelationship', 'partners', 'order')
|
|
Assert-ExactObjectSchema -Schema $familyItem -Fields $familyFields -Required $familyFields -Label 'FamilyUnit'
|
|
Assert-StringSchema -Schema (Get-SchemaProperty -Schema $familyItem -Name 'id') -Label 'FamilyUnit.id'
|
|
Assert-StringSchema -Schema (Get-SchemaProperty -Schema $familyItem -Name 'anchorPersonId') -Label 'FamilyUnit.anchorPersonId'
|
|
Assert-IntegerSchema -Schema (Get-SchemaProperty -Schema $familyItem -Name 'order') -Label 'FamilyUnit.order' -Minimum 0
|
|
$partnerRelationshipProperty = Get-SchemaProperty -Schema $familyItem -Name 'partnerRelationship'
|
|
Assert-Contract -Condition (Test-IsNullableSchema -Schema $partnerRelationshipProperty) -Message 'FamilyUnit.partnerRelationship must allow null for single-parent families'
|
|
$partnerRelationship = Get-NonNullSchema -Schema $partnerRelationshipProperty
|
|
$partnerRelationFields = @('relationshipId', 'relationshipKind', 'relationType', 'status')
|
|
Assert-ExactObjectSchema -Schema $partnerRelationship -Fields $partnerRelationFields -Required $partnerRelationFields -Label 'PartnerRelationship' -AllowNullable $true
|
|
Assert-StringSchema -Schema (Get-SchemaProperty -Schema $partnerRelationship -Name 'relationshipId') -Label 'PartnerRelationship.relationshipId'
|
|
Assert-Contract -Condition ((Get-SingleLiteral -Schema (Get-SchemaProperty -Schema $partnerRelationship -Name 'relationshipKind') -Label 'PartnerRelationship.relationshipKind') -eq 'PARTNER') -Message 'PartnerRelationship.relationshipKind must be PARTNER'
|
|
Assert-ExactEnum -Schema (Get-SchemaProperty -Schema $partnerRelationship -Name 'relationType') -Values @('MARRIAGE', 'PARTNERSHIP', 'UNKNOWN') -Label 'PartnerRelationship.relationType'
|
|
Assert-ExactEnum -Schema (Get-SchemaProperty -Schema $partnerRelationship -Name 'status') -Values @('ACTIVE', 'ENDED', 'UNKNOWN') -Label 'PartnerRelationship.status'
|
|
$partnerItem = Assert-ArraySchema -Schema (Get-SchemaProperty -Schema $familyItem -Name 'partners') -Label 'FamilyUnit.partners' -MinimumItems 1 -MaximumItems 2
|
|
$partnerFields = @('personId', 'partnerRole', 'order')
|
|
Assert-ExactObjectSchema -Schema $partnerItem -Fields $partnerFields -Required $partnerFields -Label 'FamilyUnitPartner'
|
|
Assert-StringSchema -Schema (Get-SchemaProperty -Schema $partnerItem -Name 'personId') -Label 'FamilyUnitPartner.personId'
|
|
Assert-ExactEnum -Schema (Get-SchemaProperty -Schema $partnerItem -Name 'partnerRole') -Values @('ANCHOR', 'PARTNER') -Label 'FamilyUnitPartner.partnerRole'
|
|
Assert-IntegerSchema -Schema (Get-SchemaProperty -Schema $partnerItem -Name 'order') -Label 'FamilyUnitPartner.order' -Minimum 0
|
|
|
|
$edgeItem = Assert-ArraySchema -Schema (Get-SchemaProperty -Schema $populatedGraph -Name 'edges') -Label 'edges'
|
|
$edgeFields = @('id', 'familyUnitId', 'childId', 'lineageParentId', 'parentRelations', 'primary', 'order')
|
|
Assert-ExactObjectSchema -Schema $edgeItem -Fields $edgeFields -Required $edgeFields -Label 'ParentChildEdge'
|
|
foreach ($idField in @('id', 'familyUnitId', 'childId', 'lineageParentId')) { Assert-StringSchema -Schema (Get-SchemaProperty -Schema $edgeItem -Name $idField) -Label "ParentChildEdge.$idField" }
|
|
Assert-IntegerSchema -Schema (Get-SchemaProperty -Schema $edgeItem -Name 'order') -Label 'ParentChildEdge.order' -Minimum 0
|
|
Assert-ExactNonNullType -Schema (Get-SchemaProperty -Schema $edgeItem -Name 'primary') -Type 'boolean' -Label 'ParentChildEdge.primary'
|
|
$parentRelationItem = Assert-ArraySchema -Schema (Get-SchemaProperty -Schema $edgeItem -Name 'parentRelations') -Label 'ParentChildEdge.parentRelations' -MinimumItems 1
|
|
$parentRelationFields = @('relationshipId', 'relationshipKind', 'personId', 'parentRole', 'relationType')
|
|
Assert-ExactObjectSchema -Schema $parentRelationItem -Fields $parentRelationFields -Required $parentRelationFields -Label 'ParentRelation'
|
|
Assert-StringSchema -Schema (Get-SchemaProperty -Schema $parentRelationItem -Name 'relationshipId') -Label 'ParentRelation.relationshipId'
|
|
Assert-StringSchema -Schema (Get-SchemaProperty -Schema $parentRelationItem -Name 'personId') -Label 'ParentRelation.personId'
|
|
Assert-Contract -Condition ((Get-SingleLiteral -Schema (Get-SchemaProperty -Schema $parentRelationItem -Name 'relationshipKind') -Label 'ParentRelation.relationshipKind') -eq 'PARENT_CHILD') -Message 'ParentRelation.relationshipKind must be PARENT_CHILD'
|
|
Assert-ExactEnum -Schema (Get-SchemaProperty -Schema $parentRelationItem -Name 'parentRole') -Values @('FATHER', 'MOTHER', 'PARENT', 'GUARDIAN', 'UNKNOWN') -Label 'ParentRelation.parentRole'
|
|
Assert-ExactEnum -Schema (Get-SchemaProperty -Schema $parentRelationItem -Name 'relationType') -Values @('BIOLOGICAL', 'ADOPTIVE', 'STEP', 'GUARDIAN', 'UNKNOWN') -Label 'ParentRelation.relationType'
|
|
|
|
$boundaryItem = Assert-ArraySchema -Schema (Get-SchemaProperty -Schema $populatedWindow -Name 'boundaries') -Label 'LineageWindow.boundaries'
|
|
$boundaryFields = @('id', 'anchorType', 'anchorId', 'direction', 'reason', 'hiddenCount', 'cursor')
|
|
Assert-ExactObjectSchema -Schema $boundaryItem -Fields $boundaryFields -Required $boundaryFields -Label 'LineageBoundary'
|
|
Assert-StringSchema -Schema (Get-SchemaProperty -Schema $boundaryItem -Name 'id') -Label 'LineageBoundary.id'
|
|
Assert-ExactEnum -Schema (Get-SchemaProperty -Schema $boundaryItem -Name 'anchorType') -Values @('PERSON', 'FAMILY_UNIT', 'WINDOW') -Label 'LineageBoundary.anchorType'
|
|
Assert-ExactEnum -Schema (Get-SchemaProperty -Schema $boundaryItem -Name 'direction') -Values @('ANCESTORS', 'DESCENDANTS', 'LATERAL') -Label 'LineageBoundary.direction'
|
|
Assert-ExactEnum -Schema (Get-SchemaProperty -Schema $boundaryItem -Name 'reason') -Values @('MISSING', 'REDACTED', 'UNLOADED') -Label 'LineageBoundary.reason'
|
|
Assert-StringSchema -Schema (Get-SchemaProperty -Schema $boundaryItem -Name 'anchorId') -Label 'LineageBoundary.anchorId' -Nullable $true
|
|
Assert-Contract -Condition (Test-IsNullableSchema -Schema (Get-SchemaProperty -Schema $boundaryItem -Name 'hiddenCount')) -Message 'LineageBoundary.hiddenCount must allow null'
|
|
Assert-IntegerSchema -Schema (Get-SchemaProperty -Schema $boundaryItem -Name 'hiddenCount') -Label 'LineageBoundary.hiddenCount' -Minimum 0 -AllowNullable $true
|
|
Assert-StringSchema -Schema (Get-SchemaProperty -Schema $boundaryItem -Name 'cursor') -Label 'LineageBoundary.cursor' -Nullable $true
|
|
|
|
$overviewRoot = Assert-ResponseDataRoot -Operation $overview.Operation -RootSchemaName 'LineageOverview' -Label $overviewKey
|
|
$overviewBranches = Get-DiscriminatedBranches -Schema $overviewRoot -PropertyName 'state' -Values @('EMPTY', 'POPULATED') -Label 'LineageOverview'
|
|
$overviewFields = @('schemaVersion', 'treeVersion', 'generatedAt', 'state', 'genealogyId', 'genealogyPersonCount', 'genealogyRootPersonIds', 'redactedGenealogyRootCount', 'generationRange', 'buckets')
|
|
foreach ($state in @('EMPTY', 'POPULATED')) {
|
|
$branch = $overviewBranches[$state]
|
|
Assert-ExactObjectSchema -Schema $branch -Fields $overviewFields -Required $overviewFields -Label "LineageOverview.$state"
|
|
Assert-Contract -Condition ((Get-SingleLiteral -Schema (Get-SchemaProperty -Schema $branch -Name 'schemaVersion') -Label "LineageOverview.$state.schemaVersion") -eq '2.0') -Message "LineageOverview.$state.schemaVersion must be 2.0"
|
|
Assert-StringSchema -Schema (Get-SchemaProperty -Schema $branch -Name 'treeVersion') -Label "LineageOverview.$state.treeVersion"
|
|
Assert-StringSchema -Schema (Get-SchemaProperty -Schema $branch -Name 'genealogyId') -Label "LineageOverview.$state.genealogyId"
|
|
$overviewGeneratedAt = Resolve-Schema -Schema (Get-SchemaProperty -Schema $branch -Name 'generatedAt')
|
|
Assert-StringSchema -Schema $overviewGeneratedAt -Label "LineageOverview.$state.generatedAt"
|
|
Assert-Contract -Condition ((Get-PropertyValue -Object $overviewGeneratedAt -Name 'format') -eq 'date-time') -Message "LineageOverview.$state.generatedAt must use date-time format"
|
|
}
|
|
$emptyOverview = $overviewBranches['EMPTY']
|
|
Assert-IntegerSchema -Schema (Get-SchemaProperty -Schema $emptyOverview -Name 'genealogyPersonCount') -Label 'EMPTY genealogyPersonCount' -Minimum 0 -Maximum 0
|
|
[void](Assert-ArraySchema -Schema (Get-SchemaProperty -Schema $emptyOverview -Name 'genealogyRootPersonIds') -Label 'EMPTY genealogyRootPersonIds' -MaximumItems 0)
|
|
Assert-IntegerSchema -Schema (Get-SchemaProperty -Schema $emptyOverview -Name 'redactedGenealogyRootCount') -Label 'EMPTY redactedGenealogyRootCount' -Minimum 0 -Maximum 0
|
|
Assert-Contract -Condition (Test-IsNullOnlySchema -Schema (Get-SchemaProperty -Schema $emptyOverview -Name 'generationRange')) -Message 'EMPTY overview generationRange must be null-only'
|
|
[void](Assert-ArraySchema -Schema (Get-SchemaProperty -Schema $emptyOverview -Name 'buckets') -Label 'EMPTY overview buckets' -MaximumItems 0)
|
|
$populatedOverview = $overviewBranches['POPULATED']
|
|
Assert-IntegerSchema -Schema (Get-SchemaProperty -Schema $populatedOverview -Name 'genealogyPersonCount') -Label 'POPULATED genealogyPersonCount' -Minimum 1
|
|
$rootId = Assert-ArraySchema -Schema (Get-SchemaProperty -Schema $populatedOverview -Name 'genealogyRootPersonIds') -Label 'POPULATED genealogyRootPersonIds'
|
|
Assert-StablePersonIdSchema -Schema $rootId -Label 'genealogyRootPersonIds item'
|
|
Assert-IntegerSchema -Schema (Get-SchemaProperty -Schema $populatedOverview -Name 'redactedGenealogyRootCount') -Label 'POPULATED redactedGenealogyRootCount' -Minimum 0
|
|
Assert-GenerationRangeSchema -Schema (Get-SchemaProperty -Schema $populatedOverview -Name 'generationRange') -Label 'POPULATED overview generationRange'
|
|
$bucket = Assert-ArraySchema -Schema (Get-SchemaProperty -Schema $populatedOverview -Name 'buckets') -Label 'POPULATED overview buckets' -MinimumItems 1
|
|
$bucketFields = @('id', 'branchId', 'generation', 'visibleCount', 'redactedCount', 'unloadedCount', 'focusPersonId')
|
|
Assert-ExactObjectSchema -Schema $bucket -Fields $bucketFields -Required $bucketFields -Label 'LineageOverviewBucket'
|
|
Assert-StringSchema -Schema (Get-SchemaProperty -Schema $bucket -Name 'id') -Label 'LineageOverviewBucket.id'
|
|
Assert-StringSchema -Schema (Get-SchemaProperty -Schema $bucket -Name 'branchId') -Label 'LineageOverviewBucket.branchId'
|
|
Assert-IntegerSchema -Schema (Get-SchemaProperty -Schema $bucket -Name 'generation') -Label 'LineageOverviewBucket.generation' -Minimum 1
|
|
foreach ($name in @('visibleCount', 'redactedCount', 'unloadedCount')) { Assert-IntegerSchema -Schema (Get-SchemaProperty -Schema $bucket -Name $name) -Label "LineageOverviewBucket.$name" -Minimum 0 }
|
|
Assert-StablePersonIdSchema -Schema (Get-SchemaProperty -Schema $bucket -Name 'focusPersonId') -Label 'LineageOverviewBucket.focusPersonId' -Nullable $true
|
|
|
|
$locatorRoot = Assert-ResponseDataRoot -Operation $locator.Operation -RootSchemaName 'LineageLocator' -Label $locatorKey
|
|
$locatorBranches = Get-DiscriminatedBranches -Schema $locatorRoot -PropertyName 'rootVisibility' -Values @('VISIBLE', 'REDACTED') -Label 'LineageLocator'
|
|
$locatorFields = @('treeVersion', 'genealogyId', 'personId', 'rootVisibility', 'rootPersonId', 'pathCompleteness', 'ancestorPathSegments', 'generation', 'branchId')
|
|
foreach ($visibility in @('VISIBLE', 'REDACTED')) {
|
|
$branch = $locatorBranches[$visibility]
|
|
Assert-ExactObjectSchema -Schema $branch -Fields $locatorFields -Required $locatorFields -Label "LineageLocator.$visibility"
|
|
foreach ($name in @('treeVersion', 'genealogyId', 'branchId')) { Assert-StringSchema -Schema (Get-SchemaProperty -Schema $branch -Name $name) -Label "LineageLocator.$visibility.$name" }
|
|
Assert-StablePersonIdSchema -Schema (Get-SchemaProperty -Schema $branch -Name 'personId') -Label "LineageLocator.$visibility.personId"
|
|
Assert-IntegerSchema -Schema (Get-SchemaProperty -Schema $branch -Name 'generation') -Label "LineageLocator.$visibility.generation" -Minimum 1
|
|
}
|
|
Assert-StablePersonIdSchema -Schema (Get-SchemaProperty -Schema $locatorBranches['VISIBLE'] -Name 'rootPersonId') -Label 'LineageLocator.VISIBLE.rootPersonId'
|
|
Assert-ExactEnum -Schema (Get-SchemaProperty -Schema $locatorBranches['VISIBLE'] -Name 'pathCompleteness') -Values @('COMPLETE', 'REDACTED_GAPS') -Label 'LineageLocator.VISIBLE.pathCompleteness'
|
|
Assert-Contract -Condition (Test-IsNullOnlySchema -Schema (Get-SchemaProperty -Schema $locatorBranches['REDACTED'] -Name 'rootPersonId')) -Message 'LineageLocator.REDACTED.rootPersonId must be null-only'
|
|
Assert-Contract -Condition ((Get-SingleLiteral -Schema (Get-SchemaProperty -Schema $locatorBranches['REDACTED'] -Name 'pathCompleteness') -Label 'LineageLocator.REDACTED.pathCompleteness') -eq 'REDACTED_GAPS') -Message 'REDACTED root cannot claim a COMPLETE path'
|
|
$segmentUnion = Assert-ArraySchema -Schema (Get-SchemaProperty -Schema $locatorBranches['VISIBLE'] -Name 'ancestorPathSegments') -Label 'LineageLocator.ancestorPathSegments' -MinimumItems 1
|
|
$redactedSegmentUnion = Assert-ArraySchema -Schema (Get-SchemaProperty -Schema $locatorBranches['REDACTED'] -Name 'ancestorPathSegments') -Label 'LineageLocator.REDACTED.ancestorPathSegments' -MinimumItems 1
|
|
$segmentBranches = Get-DiscriminatedBranches -Schema $segmentUnion -PropertyName 'kind' -Values @('VISIBLE', 'REDACTED') -Label 'LineageLocatorSegment'
|
|
Assert-ExactObjectSchema -Schema $segmentBranches['VISIBLE'] -Fields @('kind', 'personIds') -Required @('kind', 'personIds') -Label 'VisibleLocatorSegment'
|
|
$segmentPersonId = Assert-ArraySchema -Schema (Get-SchemaProperty -Schema $segmentBranches['VISIBLE'] -Name 'personIds') -Label 'VisibleLocatorSegment.personIds' -MinimumItems 1
|
|
Assert-StablePersonIdSchema -Schema $segmentPersonId -Label 'VisibleLocatorSegment.personIds item'
|
|
Assert-ExactObjectSchema -Schema $segmentBranches['REDACTED'] -Fields @('kind', 'hiddenCount') -Required @('kind', 'hiddenCount') -Label 'RedactedLocatorSegment'
|
|
Assert-Contract -Condition (Test-IsNullableSchema -Schema (Get-SchemaProperty -Schema $segmentBranches['REDACTED'] -Name 'hiddenCount')) -Message 'RedactedLocatorSegment.hiddenCount must allow null'
|
|
Assert-IntegerSchema -Schema (Get-SchemaProperty -Schema $segmentBranches['REDACTED'] -Name 'hiddenCount') -Label 'RedactedLocatorSegment.hiddenCount' -Minimum 1 -AllowNullable $true
|
|
$redactedLocatorSegmentBranches = Get-DiscriminatedBranches -Schema $redactedSegmentUnion -PropertyName 'kind' -Values @('VISIBLE', 'REDACTED') -Label 'LineageLocator.REDACTED segment'
|
|
Assert-ExactObjectSchema -Schema $redactedLocatorSegmentBranches['VISIBLE'] -Fields @('kind', 'personIds') -Required @('kind', 'personIds') -Label 'REDACTED locator visible segment'
|
|
$redactedLocatorPersonId = Assert-ArraySchema -Schema (Get-SchemaProperty -Schema $redactedLocatorSegmentBranches['VISIBLE'] -Name 'personIds') -Label 'REDACTED locator visible segment personIds' -MinimumItems 1
|
|
Assert-StablePersonIdSchema -Schema $redactedLocatorPersonId -Label 'REDACTED locator visible segment personIds item'
|
|
Assert-ExactObjectSchema -Schema $redactedLocatorSegmentBranches['REDACTED'] -Fields @('kind', 'hiddenCount') -Required @('kind', 'hiddenCount') -Label 'REDACTED locator redacted segment'
|
|
Assert-Contract -Condition (Test-IsNullableSchema -Schema (Get-SchemaProperty -Schema $redactedLocatorSegmentBranches['REDACTED'] -Name 'hiddenCount')) -Message 'REDACTED locator hiddenCount must allow null'
|
|
Assert-IntegerSchema -Schema (Get-SchemaProperty -Schema $redactedLocatorSegmentBranches['REDACTED'] -Name 'hiddenCount') -Label 'REDACTED locator hiddenCount' -Minimum 1 -AllowNullable $true
|
|
|
|
$requestBody = Get-PropertyValue -Object $patch.Operation -Name 'requestBody'
|
|
Assert-Contract -Condition ($null -ne $requestBody -and (Get-PropertyValue -Object $requestBody -Name 'required') -eq $true) -Message 'relationship patch requestBody must be required'
|
|
$requestContent = Get-PropertyValue -Object $requestBody -Name 'content'
|
|
$requestJson = if ($null -eq $requestContent) { $null } else { $requestContent.PSObject.Properties['application/json'] }
|
|
Assert-Contract -Condition ($null -ne $requestJson) -Message 'relationship patch must consume application/json'
|
|
$patchRequestSchema = Get-PropertyValue -Object $requestJson.Value -Name 'schema'
|
|
$patchBranches = Get-DiscriminatedBranches -Schema $patchRequestSchema -PropertyName 'relationshipKind' -Values @('PARTNER', 'PARENT_CHILD') -Label 'RelationshipPatchBody'
|
|
$patchDefinitions = @{
|
|
PARTNER = @{ Fields = @('relationshipKind', 'relationType', 'status'); Mutable = @('relationType', 'status'); RelationTypes = @('MARRIAGE', 'PARTNERSHIP', 'UNKNOWN') }
|
|
PARENT_CHILD = @{ Fields = @('relationshipKind', 'relationType', 'parentRole'); Mutable = @('relationType', 'parentRole'); RelationTypes = @('BIOLOGICAL', 'ADOPTIVE', 'STEP', 'GUARDIAN', 'UNKNOWN') }
|
|
}
|
|
foreach ($kind in @('PARTNER', 'PARENT_CHILD')) {
|
|
$branch = $patchBranches[$kind]
|
|
Assert-ExactObjectSchema -Schema $branch -Fields $patchDefinitions[$kind].Fields -Required @('relationshipKind') -Label "RelationshipPatchBody.$kind"
|
|
$resolvedBranch = Resolve-Schema -Schema $branch
|
|
$hasMinProperties = (Get-PropertyValue -Object $resolvedBranch -Name 'minProperties') -eq 2
|
|
$anyOf = @(Get-PropertyValue -Object $resolvedBranch -Name 'anyOf')
|
|
$anyOfRequired = @($anyOf | ForEach-Object { @(Get-PropertyValue -Object $_ -Name 'required') } | Sort-Object -Unique)
|
|
$hasRequiredAlternatives = $anyOf.Count -eq $patchDefinitions[$kind].Mutable.Count -and
|
|
(@($anyOf | Where-Object { @(Get-PropertyValue -Object $_ -Name 'required').Count -ne 1 }).Count -eq 0) -and
|
|
(($anyOfRequired -join ',') -eq (@($patchDefinitions[$kind].Mutable | Sort-Object) -join ','))
|
|
Assert-Contract -Condition ($hasMinProperties -or $hasRequiredAlternatives) -Message "RelationshipPatchBody.$kind must reject a discriminator-only empty patch"
|
|
Assert-ExactEnum -Schema (Get-SchemaProperty -Schema $branch -Name 'relationType') -Values $patchDefinitions[$kind].RelationTypes -Label "RelationshipPatchBody.$kind.relationType"
|
|
}
|
|
Assert-ExactEnum -Schema (Get-SchemaProperty -Schema $patchBranches['PARTNER'] -Name 'status') -Values @('ACTIVE', 'ENDED', 'UNKNOWN') -Label 'RelationshipPatchBody.PARTNER.status'
|
|
Assert-ExactEnum -Schema (Get-SchemaProperty -Schema $patchBranches['PARENT_CHILD'] -Name 'parentRole') -Values @('FATHER', 'MOTHER', 'PARENT', 'GUARDIAN', 'UNKNOWN') -Label 'RelationshipPatchBody.PARENT_CHILD.parentRole'
|
|
|
|
$mutationSchema = Get-ResponseSchema -Operation $patch.Operation -Status '200'
|
|
$mutationWrapper = Resolve-Schema -Schema $mutationSchema
|
|
Assert-ObjectTypedSchema -Schema $mutationWrapper -Label 'relationship patch response wrapper'
|
|
$mutationData = Get-SchemaProperty -Schema $mutationWrapper -Name 'data'
|
|
if ($null -eq $mutationData) {
|
|
throw 'relationship patch HTTP 200 response wrapper must define required data'
|
|
}
|
|
Assert-Contract -Condition ('data' -in @(Get-SchemaRequiredNames -Schema $mutationWrapper)) -Message 'relationship patch HTTP 200 response data must be required'
|
|
$mutationFields = @('treeVersion', 'affectedPersonIds', 'affectedFamilyUnitIds', 'affectedRelationshipIds')
|
|
Assert-ExactObjectSchema -Schema $mutationData -Fields $mutationFields -Required $mutationFields -Label 'LineageMutationResult'
|
|
Assert-StringSchema -Schema (Get-SchemaProperty -Schema $mutationData -Name 'treeVersion') -Label 'LineageMutationResult.treeVersion'
|
|
foreach ($name in @('affectedPersonIds', 'affectedFamilyUnitIds', 'affectedRelationshipIds')) {
|
|
$item = Assert-ArraySchema -Schema (Get-SchemaProperty -Schema $mutationData -Name $name) -Label "LineageMutationResult.$name"
|
|
if ($name -eq 'affectedPersonIds') {
|
|
Assert-StablePersonIdSchema -Schema $item -Label "LineageMutationResult.$name item"
|
|
} else {
|
|
Assert-StringSchema -Schema $item -Label "LineageMutationResult.$name item"
|
|
}
|
|
}
|
|
|
|
# 新 v2 操作的全部 component 闭包必须存在、符合当前 OpenAPI 方言,并与 YAML 的直接引用逐组件一致。
|
|
$newComponentKeys = New-Object 'System.Collections.Generic.HashSet[string]'
|
|
foreach ($target in $operations) {
|
|
$operation = $resolvedOperations["$($target.Method.ToUpperInvariant()) $($target.Path)"].Operation
|
|
Add-ReachableComponentKeys -Value $operation -Keys $newComponentKeys
|
|
}
|
|
foreach ($name in $rootSchemaNames) {
|
|
[void]$newComponentKeys.Add("schemas/$name")
|
|
Add-ReachableComponentKeys -Value $document.components.schemas.PSObject.Properties[$name].Value -Keys $newComponentKeys
|
|
}
|
|
Add-ReachableComponentKeys -Value $patchRequestSchema -Keys $newComponentKeys
|
|
Add-ReachableComponentKeys -Value $mutationData -Keys $newComponentKeys
|
|
|
|
$newSchemaNames = New-Object 'System.Collections.Generic.HashSet[string]'
|
|
foreach ($key in $newComponentKeys) {
|
|
$parts = $key -split '/', 2
|
|
if ($parts[0] -eq 'schemas') {
|
|
[void]$newSchemaNames.Add($parts[1])
|
|
Assert-SchemaDialect -Schema $document.components.schemas.PSObject.Properties[$parts[1]].Value -Label $parts[1]
|
|
} else {
|
|
$componentSection = Get-PropertyValue -Object $document.components -Name $parts[0]
|
|
Assert-OpenApiValueDialect -Value $componentSection.PSObject.Properties[$parts[1]].Value -Label $key
|
|
}
|
|
}
|
|
|
|
# 旧 v1 树响应必须继续是递归数组,且不得以内联、改名或引用方式混入任何新图根结构。
|
|
$legacyPath = '/genealogy/app/genealogies/{genealogyId}/lineage/tree'
|
|
$legacyPathProperty = $document.paths.PSObject.Properties[$legacyPath]
|
|
Assert-Contract -Condition ($null -ne $legacyPathProperty -and $null -ne $legacyPathProperty.Value.PSObject.Properties['get']) -Message 'Legacy v1 lineage tree contract unexpectedly disappeared'
|
|
$legacyOperation = $legacyPathProperty.Value.get
|
|
$legacySchema = Get-ResponseSchema -Operation $legacyOperation -Status '200'
|
|
$legacyWrapper = Resolve-Schema -Schema $legacySchema
|
|
$legacyData = Get-SchemaProperty -Schema $legacyWrapper -Name 'data'
|
|
Assert-Contract -Condition ($null -ne $legacyData) -Message 'Legacy v1 lineage response wrapper must retain data'
|
|
$legacyItem = Assert-ArraySchema -Schema $legacyData -Label 'Legacy v1 lineage data'
|
|
$legacyItemRef = [string](Get-PropertyValue -Object $legacyItem -Name '$ref')
|
|
Assert-Contract -Condition (-not [string]::IsNullOrWhiteSpace($legacyItemRef)) -Message 'Legacy v1 lineage array item must use a stable recursive schema reference'
|
|
$legacyItemFields = @(Get-SchemaPropertyNames -Schema $legacyItem)
|
|
foreach ($recursiveField in @('children', 'spouses')) {
|
|
Assert-Contract -Condition ($recursiveField -in $legacyItemFields) -Message "Legacy v1 lineage item lost recursive field: $recursiveField"
|
|
$recursiveItem = Assert-ArraySchema -Schema (Get-SchemaProperty -Schema $legacyItem -Name $recursiveField) -Label "Legacy v1 lineage $recursiveField"
|
|
Assert-Contract -Condition ([string](Get-PropertyValue -Object $recursiveItem -Name '$ref') -eq $legacyItemRef) -Message "Legacy v1 lineage $recursiveField items must recurse to $legacyItemRef"
|
|
}
|
|
$legacyNames = New-Object 'System.Collections.Generic.HashSet[string]'
|
|
Add-ReachableSchemaNames -Value $legacySchema -Names $legacyNames
|
|
$legacyLeak = @($legacyNames | Where-Object { $newSchemaNames.Contains($_) } | Sort-Object)
|
|
Assert-Contract -Condition ($legacyLeak.Count -eq 0) -Message "Legacy v1 lineage tree reaches new schemas: $($legacyLeak -join ',')"
|
|
$newRootSignatures = @(
|
|
@('version', 'state', 'nodes', 'familyUnits', 'edges', 'window'),
|
|
@('schemaVersion', 'treeVersion', 'genealogyPersonCount', 'buckets'),
|
|
@('rootVisibility', 'pathCompleteness', 'ancestorPathSegments'),
|
|
@('treeVersion', 'affectedPersonIds', 'affectedFamilyUnitIds', 'affectedRelationshipIds')
|
|
)
|
|
foreach ($signature in $newRootSignatures) {
|
|
$visitedLegacyRefs = New-Object 'System.Collections.Generic.HashSet[string]'
|
|
if (Test-SchemaGraphContainsFields -Schema $legacySchema -Fields $signature -VisitedRefs $visitedLegacyRefs) {
|
|
throw "Legacy v1 lineage response contains a copied v2 root signature: $($signature -join ',')"
|
|
}
|
|
}
|
|
|
|
# YAML 不是用全文关键词冒充绿灯:在限定的 paths/components 块内对齐操作和完整 component 引用闭包。
|
|
foreach ($target in $operations) {
|
|
$jsonOperation = $resolvedOperations["$($target.Method.ToUpperInvariant()) $($target.Path)"].Operation
|
|
$yamlOperation = Get-YamlOperationBlock -Path $target.Path -Method $target.Method
|
|
$yamlOperationId = [regex]::Match($yamlOperation, '(?m)^ operationId:\s*["'']?(?<id>[^\s"'']+)["'']?\s*$')
|
|
Assert-Contract -Condition ($yamlOperationId.Success -and $yamlOperationId.Groups['id'].Value -eq [string](Get-PropertyValue -Object $jsonOperation -Name 'operationId')) -Message "YAML operationId drifted: $($target.Method.ToUpperInvariant()) $($target.Path)"
|
|
Assert-ExactSet -Actual @(Get-ComponentRefSet -Value $yamlOperation) -Expected @(Get-ComponentRefSet -Value $jsonOperation) -Label "YAML operation refs $($target.Method.ToUpperInvariant()) $($target.Path)"
|
|
}
|
|
foreach ($key in $newComponentKeys) {
|
|
$parts = $key -split '/', 2
|
|
$section = Get-PropertyValue -Object $document.components -Name $parts[0]
|
|
$jsonComponent = $section.PSObject.Properties[$parts[1]]
|
|
Assert-Contract -Condition ($null -ne $jsonComponent) -Message "JSON component missing from v2 closure: $key"
|
|
$yamlBlock = Get-YamlComponentBlock -Section $parts[0] -Name $parts[1]
|
|
Assert-Contract -Condition ($null -ne $yamlBlock) -Message "YAML component missing from v2 closure: $key"
|
|
Assert-ExactSet -Actual @(Get-ComponentRefSet -Value $yamlBlock) -Expected @(Get-ComponentRefSet -Value $jsonComponent.Value) -Label "YAML component refs $key"
|
|
}
|
|
|
|
$yamlLegacyOperation = Get-YamlOperationBlock -Path $legacyPath -Method 'get'
|
|
Assert-Contract -Condition ($null -ne $yamlLegacyOperation) -Message 'YAML legacy v1 lineage GET disappeared'
|
|
Assert-ExactSet -Actual @(Get-ComponentRefSet -Value $yamlLegacyOperation) -Expected @(Get-ComponentRefSet -Value $legacyOperation) -Label 'YAML legacy v1 operation refs'
|
|
$legacyComponentKeys = New-Object 'System.Collections.Generic.HashSet[string]'
|
|
Add-ReachableComponentKeys -Value $legacyOperation -Keys $legacyComponentKeys
|
|
foreach ($key in $legacyComponentKeys) {
|
|
$parts = $key -split '/', 2
|
|
$section = Get-PropertyValue -Object $document.components -Name $parts[0]
|
|
$jsonComponent = $section.PSObject.Properties[$parts[1]]
|
|
$yamlBlock = Get-YamlComponentBlock -Section $parts[0] -Name $parts[1]
|
|
Assert-Contract -Condition ($null -ne $yamlBlock) -Message "YAML component missing from legacy v1 closure: $key"
|
|
Assert-ExactSet -Actual @(Get-ComponentRefSet -Value $yamlBlock) -Expected @(Get-ComponentRefSet -Value $jsonComponent.Value) -Label "YAML legacy component refs $key"
|
|
}
|
|
|
|
Write-Output 'LINEAGE-OPENAPI-CONTRACT PASS'
|