1400 lines
70 KiB
PowerShell
1400 lines
70 KiB
PowerShell
param(
|
|
[object]$InputDocument,
|
|
[switch]$SkipParity,
|
|
[switch]$ReturnIssues
|
|
)
|
|
|
|
$ErrorActionPreference = 'Stop'
|
|
|
|
$root = Split-Path -Parent $PSScriptRoot
|
|
$jsonPath = Join-Path $root 'APP.openapi.json'
|
|
$document = if ($PSBoundParameters.ContainsKey('InputDocument')) {
|
|
$InputDocument
|
|
} else {
|
|
Get-Content -Raw -Encoding UTF8 -LiteralPath $jsonPath | ConvertFrom-Json
|
|
}
|
|
$issues = New-Object System.Collections.Generic.List[string]
|
|
|
|
$poemPath = '/genealogy/app/genealogies/{genealogyId}/generation-poems'
|
|
$httpMethods = @('get', 'post', 'put', 'patch', 'delete', 'options', 'head', 'trace')
|
|
$identifierPattern = '^[A-Za-z0-9][A-Za-z0-9._~-]{0,127}$'
|
|
$generationTextPattern = '^(?!.*[\u0000-\u001F\u007F-\u009F\u061C\u200B-\u200F\u2028-\u202E\u2060\u2066-\u2069\uFEFF])\S(?:[\s\S]*\S)?$'
|
|
$generationPoemLexicalPattern = '(?i)(generation[-_ ]?poem|poem[-_ ]?set|poemSetVersion|poemId|generationText|\u5B57\u8F88)'
|
|
|
|
function Add-Issue([string]$Message) {
|
|
$script:issues.Add($Message)
|
|
}
|
|
|
|
function Test-IsJsonArray([object]$Value) {
|
|
return $null -ne $Value -and $Value.GetType().IsArray
|
|
}
|
|
|
|
function Test-IsJsonBoolean([object]$Value, [bool]$Expected) {
|
|
return $Value -is [System.Boolean] -and $Value -ceq $Expected
|
|
}
|
|
|
|
function Get-ExactProperty([object]$Object, [string]$Name) {
|
|
if ($null -eq $Object) { return $null }
|
|
$matches = @($Object.PSObject.Properties | Where-Object { $_.Name -ceq $Name })
|
|
if ($matches.Count -eq 1) { return $matches[0] }
|
|
return $null
|
|
}
|
|
|
|
function Test-ContainsExact([object[]]$Values, [object]$Value) {
|
|
return @($Values | Where-Object { $_ -ceq $Value }).Count -gt 0
|
|
}
|
|
|
|
function Assert-CanonicalKeyCasing([object]$Object, [string]$Label, [string[]]$CanonicalNames) {
|
|
if (-not $Object) { return }
|
|
foreach ($property in @($Object.PSObject.Properties)) {
|
|
$canonical = @($CanonicalNames | Where-Object { $_ -ieq $property.Name })
|
|
if ($canonical.Count -eq 1 -and $property.Name -cne $canonical[0]) {
|
|
Add-Issue "$Label contains non-canonical object keyword casing: $($property.Name); expected: $($canonical[0])"
|
|
}
|
|
}
|
|
}
|
|
|
|
function Get-OrdinalMapKey([string]$Value) {
|
|
return [Convert]::ToBase64String([Text.Encoding]::UTF8.GetBytes($Value))
|
|
}
|
|
|
|
function Test-IsNonNullable([object]$Schema) {
|
|
if (-not $Schema) { return $false }
|
|
$nullable = Get-ExactProperty $Schema 'nullable'
|
|
return -not $nullable -or (Test-IsJsonBoolean $nullable.Value $false)
|
|
}
|
|
|
|
function Assert-AllowedObjectKeys {
|
|
param(
|
|
[object]$Object,
|
|
[string]$Label,
|
|
[string[]]$Allowed,
|
|
[string[]]$Required = @(),
|
|
[string[]]$AllowedExtensions = @()
|
|
)
|
|
if (-not $Object) { return }
|
|
foreach ($property in @($Object.PSObject.Properties)) {
|
|
if ($property.Name -clike 'x-*') {
|
|
if (-not (Test-ContainsExact $AllowedExtensions $property.Name)) {
|
|
Add-Issue "$Label contains an unowned semantic extension: $($property.Name)"
|
|
}
|
|
continue
|
|
}
|
|
if (-not (Test-ContainsExact $Allowed $property.Name)) {
|
|
Add-Issue "$Label contains an unowned object keyword: $($property.Name)"
|
|
}
|
|
}
|
|
foreach ($name in $Required) {
|
|
if (-not (Get-ExactProperty $Object $name)) {
|
|
Add-Issue "$Label missing required object keyword: $name"
|
|
}
|
|
}
|
|
}
|
|
|
|
function Assert-NoConflictingSchemaKeywords([object]$Schema, [string]$Label, [string[]]$Allowed = @()) {
|
|
if (-not $Schema) { return }
|
|
foreach ($keyword in @('not', 'allOf', 'anyOf', 'oneOf', 'const', 'enum')) {
|
|
if (-not (Test-ContainsExact $Allowed $keyword) -and (Get-ExactProperty $Schema $keyword)) {
|
|
Add-Issue "$Label must not define conflicting schema keyword: $keyword"
|
|
}
|
|
}
|
|
}
|
|
|
|
function Assert-AllowedSchemaKeywords([object]$Schema, [string]$Label, [string[]]$Allowed) {
|
|
if (-not $Schema) { return }
|
|
$annotations = @('title', 'description', 'example', 'deprecated')
|
|
foreach ($property in @($Schema.PSObject.Properties)) {
|
|
if ((Test-ContainsExact $annotations $property.Name) -or (Test-ContainsExact $Allowed $property.Name)) { continue }
|
|
Add-Issue "$Label contains an unowned schema keyword: $($property.Name)"
|
|
}
|
|
}
|
|
|
|
function Assert-AllowedSchemaKeywordsWithExtensions {
|
|
param(
|
|
[object]$Schema,
|
|
[string]$Label,
|
|
[string[]]$Allowed,
|
|
[string[]]$AllowedExtensions
|
|
)
|
|
if (-not $Schema) { return }
|
|
$annotations = @('title', 'description', 'example', 'deprecated')
|
|
foreach ($property in @($Schema.PSObject.Properties)) {
|
|
if ($property.Name -clike 'x-*') {
|
|
if (-not (Test-ContainsExact $AllowedExtensions $property.Name)) {
|
|
Add-Issue "$Label contains an unowned semantic extension: $($property.Name)"
|
|
}
|
|
continue
|
|
}
|
|
if ((Test-ContainsExact $annotations $property.Name) -or (Test-ContainsExact $Allowed $property.Name)) { continue }
|
|
Add-Issue "$Label contains an unowned schema keyword: $($property.Name)"
|
|
}
|
|
}
|
|
|
|
function Get-LocalComponentName([string]$Ref, [string]$Section, [string]$Label) {
|
|
$pattern = '^#/components/' + [regex]::Escape($Section) + '/(?<name>[^/]+)$'
|
|
$match = [regex]::Match($Ref, $pattern)
|
|
if (-not $match.Success) {
|
|
Add-Issue "$Label must use an exact local #/components/$Section/... ref; actual: $Ref"
|
|
return ''
|
|
}
|
|
return $match.Groups['name'].Value
|
|
}
|
|
|
|
function Test-IsPureSchemaRef([object]$Schema, [string]$ExpectedRef, [string]$Label) {
|
|
if (-not $Schema) { return $false }
|
|
$properties = @($Schema.PSObject.Properties.Name)
|
|
$actualRef = [string]$Schema.'$ref'
|
|
if ($properties.Count -ne 1 -or $properties[0] -cne '$ref' -or $actualRef -cne $ExpectedRef) {
|
|
Add-Issue "$Label must be the sole exact local schema ref $ExpectedRef; actual: $actualRef"
|
|
return $false
|
|
}
|
|
[void](Get-LocalComponentName $actualRef 'schemas' $Label)
|
|
return $true
|
|
}
|
|
|
|
function Get-ComponentSection([string]$Section) {
|
|
$componentsProperty = Get-ExactProperty $document 'components'
|
|
$components = if ($componentsProperty) { $componentsProperty.Value } else { $null }
|
|
$sectionProperty = Get-ExactProperty $components $Section
|
|
return $(if ($sectionProperty) { $sectionProperty.Value } else { $null })
|
|
}
|
|
|
|
function Resolve-PureLocalReference {
|
|
param(
|
|
[object]$Object,
|
|
[string]$Section,
|
|
[string]$Label,
|
|
[hashtable]$Seen = @{}
|
|
)
|
|
if (-not $Object) { return $null }
|
|
$refProperty = Get-ExactProperty $Object '$ref'
|
|
if (-not $refProperty) { return $Object }
|
|
$keys = @($Object.PSObject.Properties.Name)
|
|
if ($keys.Count -ne 1 -or $keys[0] -cne '$ref') {
|
|
Add-Issue "$Label OpenAPI 3.0.1 Reference Object must contain only `$ref"
|
|
}
|
|
$ref = [string]$refProperty.Value
|
|
$name = Get-LocalComponentName $ref $Section $Label
|
|
if (-not $name) { return $null }
|
|
$key = "$Section/$name"
|
|
$seenKey = Get-OrdinalMapKey $key
|
|
if ($Seen.ContainsKey($seenKey)) {
|
|
Add-Issue "$Label contains a cyclic local reference: $ref"
|
|
return $null
|
|
}
|
|
$Seen[$seenKey] = $true
|
|
$sectionOwner = Get-ComponentSection $Section
|
|
$ownerProperty = Get-ExactProperty $sectionOwner $name
|
|
if (-not $ownerProperty) {
|
|
Add-Issue "$Label references missing $Section owner: $name"
|
|
return $null
|
|
}
|
|
return Resolve-PureLocalReference $ownerProperty.Value $Section "$Label->$name" $Seen
|
|
}
|
|
|
|
function Assert-ParameterObject([object]$Parameter, [string]$Label) {
|
|
if (-not $Parameter) { return }
|
|
Assert-AllowedObjectKeys $Parameter $Label @('name', 'in', 'required', 'schema', 'description', 'example', 'examples') @('name', 'in', 'required', 'schema')
|
|
if (Get-ExactProperty $Parameter 'content') {
|
|
Add-Issue "$Label Parameter Object must use schema only, never content"
|
|
}
|
|
if ((Get-ExactProperty $Parameter 'example') -and (Get-ExactProperty $Parameter 'examples')) {
|
|
Add-Issue "$Label Parameter Object must not define both example and examples"
|
|
}
|
|
}
|
|
|
|
function Assert-HeaderObject([object]$Header, [string]$Label) {
|
|
if (-not $Header) { return }
|
|
Assert-AllowedObjectKeys $Header $Label @('schema', 'description', 'example', 'examples') @('schema')
|
|
if (Get-ExactProperty $Header 'content') {
|
|
Add-Issue "$Label Header Object must use schema only, never content"
|
|
}
|
|
if ((Get-ExactProperty $Header 'example') -and (Get-ExactProperty $Header 'examples')) {
|
|
Add-Issue "$Label Header Object must not define both example and examples"
|
|
}
|
|
}
|
|
|
|
function Assert-MediaTypeObject([object]$MediaType, [string]$Label) {
|
|
if (-not $MediaType) { return }
|
|
Assert-AllowedObjectKeys $MediaType $Label @('schema', 'example', 'examples') @('schema')
|
|
if ((Get-ExactProperty $MediaType 'example') -and (Get-ExactProperty $MediaType 'examples')) {
|
|
Add-Issue "$Label MediaType Object must not define both example and examples"
|
|
}
|
|
}
|
|
|
|
function Assert-RequestBodyObject([object]$RequestBody, [string]$Label) {
|
|
if (-not $RequestBody) { return }
|
|
Assert-AllowedObjectKeys $RequestBody $Label @('required', 'content', 'description') @('required', 'content')
|
|
}
|
|
|
|
function Assert-ResponseObject([object]$Response, [string]$Label) {
|
|
if (-not $Response) { return }
|
|
Assert-AllowedObjectKeys $Response $Label @('description', 'headers', 'content') @('description', 'headers', 'content')
|
|
}
|
|
|
|
function Resolve-Parameter([object]$Parameter, [string]$Label) {
|
|
$resolved = Resolve-PureLocalReference $Parameter 'parameters' $Label @{}
|
|
Assert-ParameterObject $resolved $Label
|
|
return $resolved
|
|
}
|
|
|
|
function Resolve-Response([object]$Response, [string]$Label) {
|
|
$resolved = Resolve-PureLocalReference $Response 'responses' $Label @{}
|
|
Assert-ResponseObject $resolved $Label
|
|
return $resolved
|
|
}
|
|
|
|
function Resolve-Header([object]$Header, [string]$Label) {
|
|
$resolved = Resolve-PureLocalReference $Header 'headers' $Label @{}
|
|
Assert-HeaderObject $resolved $Label
|
|
return $resolved
|
|
}
|
|
|
|
function Get-Schema([string]$Name) {
|
|
$componentsProperty = Get-ExactProperty $document 'components'
|
|
$schemasProperty = if ($componentsProperty) { Get-ExactProperty $componentsProperty.Value 'schemas' } else { $null }
|
|
$property = if ($schemasProperty) { Get-ExactProperty $schemasProperty.Value $Name } else { $null }
|
|
if (-not $property) {
|
|
Add-Issue "missing schema owner: $Name"
|
|
return $null
|
|
}
|
|
return $property.Value
|
|
}
|
|
|
|
function Resolve-RequestBody([object]$RequestBody, [string]$Label) {
|
|
$resolved = Resolve-PureLocalReference $RequestBody 'requestBodies' $Label @{}
|
|
Assert-RequestBodyObject $resolved $Label
|
|
return $resolved
|
|
}
|
|
|
|
function Test-ObjectGraphLooksLikeGenerationPoem {
|
|
param(
|
|
[object]$Value,
|
|
[hashtable]$Seen,
|
|
[string]$Label,
|
|
[string]$ExpectedRefSection = '',
|
|
[ValidateSet('Any', 'Mutation', 'CanonicalRead')]
|
|
[string]$OwnerKind = 'Any'
|
|
)
|
|
if ($null -eq $Value) { return $false }
|
|
if ($Value -is [string]) { return $false }
|
|
if ($Value -is [ValueType]) { return $false }
|
|
if (Test-IsJsonArray $Value) {
|
|
$arrayMatched = $false
|
|
foreach ($item in @($Value)) {
|
|
if (Test-ObjectGraphLooksLikeGenerationPoem $item $Seen "$Label[]" $ExpectedRefSection $OwnerKind) { $arrayMatched = $true }
|
|
}
|
|
return $arrayMatched
|
|
}
|
|
|
|
$refProperty = Get-ExactProperty $Value '$ref'
|
|
if ($refProperty) {
|
|
$keys = @($Value.PSObject.Properties.Name)
|
|
$hasReferenceSiblings = $keys.Count -ne 1 -or $keys[0] -cne '$ref'
|
|
$ref = [string]$refProperty.Value
|
|
$match = [regex]::Match($ref, '^#/components/(?<section>schemas|parameters|headers|requestBodies|responses|callbacks|securitySchemes)/(?<name>[^/]+)$')
|
|
if (-not $match.Success) {
|
|
Add-Issue "$Label contains an uninspectable external or wrong-section ref: $ref"
|
|
return $ref -cmatch $generationPoemLexicalPattern
|
|
}
|
|
$section = $match.Groups['section'].Value
|
|
$name = $match.Groups['name'].Value
|
|
if ($ExpectedRefSection -and $section -cne $ExpectedRefSection) {
|
|
Add-Issue "$Label uses wrong-section ref $ref; expected components/$ExpectedRefSection"
|
|
return $ref -cmatch $generationPoemLexicalPattern
|
|
}
|
|
$key = "$section/$name"
|
|
$seenKey = Get-OrdinalMapKey $key
|
|
if ($Seen.ContainsKey($seenKey)) { return $false }
|
|
$Seen[$seenKey] = $true
|
|
$sectionOwner = Get-ComponentSection $section
|
|
$ownerProperty = Get-ExactProperty $sectionOwner $name
|
|
if (-not $ownerProperty) {
|
|
Add-Issue "$Label references missing $section owner: $name"
|
|
return $name -cmatch $generationPoemLexicalPattern
|
|
}
|
|
$named = switch ($OwnerKind) {
|
|
'Mutation' { $name -cmatch '^(AppGenerationPoemSetUpdateBody|AppGenerationPoemSetItem|GenerationPoemText|GenerationPoemBody|GenerationPoemBatchBody)$' }
|
|
'CanonicalRead' { $name -cmatch '^(RGenerationPoemSetSnapshot|GenerationPoemSetSnapshot|RGenerationPoemList|GenerationPoemListResult)$' }
|
|
default { $name -cmatch $generationPoemLexicalPattern }
|
|
}
|
|
$nested = Test-ObjectGraphLooksLikeGenerationPoem $ownerProperty.Value $Seen "$Label->$name" $section $OwnerKind
|
|
if (($named -or $nested) -and $hasReferenceSiblings) {
|
|
Add-Issue "$Label G12-related OpenAPI 3.0.1 Reference Object must contain only `$ref"
|
|
}
|
|
return $named -or $nested
|
|
}
|
|
|
|
$propertyNames = @($Value.PSObject.Properties.Name)
|
|
$mutationSignature = (Test-ContainsExact $propertyNames 'poemText') -or
|
|
(Test-ContainsExact $propertyNames 'disableMissing') -or
|
|
((Test-ContainsExact $propertyNames 'generationText') -and (Test-ContainsExact $propertyNames 'generationNo'))
|
|
$readSignature = (Test-ContainsExact $propertyNames 'poemSetVersion') -and (Test-ContainsExact $propertyNames 'items')
|
|
$signature = switch ($OwnerKind) {
|
|
'Mutation' { $mutationSignature }
|
|
'CanonicalRead' { $readSignature }
|
|
default { $mutationSignature -or $readSignature }
|
|
}
|
|
$matched = [bool]$signature
|
|
if (Get-ExactProperty $Value 'responses') {
|
|
$operationMetadata = @(
|
|
[string]$Value.operationId,
|
|
[string]$Value.summary,
|
|
[string]$Value.description,
|
|
(@($Value.tags) -join ' ')
|
|
) -join ' '
|
|
if ($operationMetadata -cmatch $generationPoemLexicalPattern) { $matched = $true }
|
|
}
|
|
|
|
foreach ($property in @($Value.PSObject.Properties)) {
|
|
$name = $property.Name
|
|
$child = $property.Value
|
|
if (($name -cmatch '^/|^\{') -and $name -cmatch $generationPoemLexicalPattern) {
|
|
$matched = $true
|
|
}
|
|
if ($name -ceq 'properties') {
|
|
$schemaFieldNames = @($child.PSObject.Properties.Name)
|
|
$schemaMutationSignature = (Test-ContainsExact $schemaFieldNames 'poemText') -or
|
|
(Test-ContainsExact $schemaFieldNames 'disableMissing') -or
|
|
((Test-ContainsExact $schemaFieldNames 'generationText') -and (Test-ContainsExact $schemaFieldNames 'generationNo'))
|
|
$schemaReadSignature = (Test-ContainsExact $schemaFieldNames 'poemSetVersion') -and (Test-ContainsExact $schemaFieldNames 'items')
|
|
$schemaSignature = switch ($OwnerKind) {
|
|
'Mutation' { $schemaMutationSignature }
|
|
'CanonicalRead' { $schemaReadSignature }
|
|
default { $schemaMutationSignature -or $schemaReadSignature }
|
|
}
|
|
if ($schemaSignature) {
|
|
$matched = $true
|
|
}
|
|
foreach ($schemaProperty in @($child.PSObject.Properties)) {
|
|
if (Test-ObjectGraphLooksLikeGenerationPoem $schemaProperty.Value $Seen "$Label.properties.$($schemaProperty.Name)" 'schemas' $OwnerKind) { $matched = $true }
|
|
}
|
|
continue
|
|
}
|
|
if (Test-ContainsExact @('schema', 'items', 'additionalProperties', 'not') $name) {
|
|
if ($child -isnot [System.Boolean] -and
|
|
(Test-ObjectGraphLooksLikeGenerationPoem $child $Seen "$Label.$name" 'schemas' $OwnerKind)) { $matched = $true }
|
|
continue
|
|
}
|
|
if (Test-ContainsExact @('allOf', 'oneOf', 'anyOf') $name) {
|
|
foreach ($branch in @($child)) {
|
|
if (Test-ObjectGraphLooksLikeGenerationPoem $branch $Seen "$Label.$name" 'schemas' $OwnerKind) { $matched = $true }
|
|
}
|
|
continue
|
|
}
|
|
if ($name -ceq 'parameters') {
|
|
foreach ($parameter in @($child)) {
|
|
if (Test-ObjectGraphLooksLikeGenerationPoem $parameter $Seen "$Label.parameters" 'parameters' $OwnerKind) { $matched = $true }
|
|
}
|
|
continue
|
|
}
|
|
if ($name -ceq 'requestBody') {
|
|
if (Test-ObjectGraphLooksLikeGenerationPoem $child $Seen "$Label.requestBody" 'requestBodies' $OwnerKind) { $matched = $true }
|
|
continue
|
|
}
|
|
if ($name -ceq 'responses') {
|
|
foreach ($response in @($child.PSObject.Properties)) {
|
|
if (Test-ObjectGraphLooksLikeGenerationPoem $response.Value $Seen "$Label.responses.$($response.Name)" 'responses' $OwnerKind) { $matched = $true }
|
|
}
|
|
continue
|
|
}
|
|
if ($name -ceq 'headers') {
|
|
foreach ($header in @($child.PSObject.Properties)) {
|
|
if (Test-ObjectGraphLooksLikeGenerationPoem $header.Value $Seen "$Label.headers.$($header.Name)" 'headers' $OwnerKind) { $matched = $true }
|
|
}
|
|
continue
|
|
}
|
|
if ($name -ceq 'callbacks') {
|
|
foreach ($callback in @($child.PSObject.Properties)) {
|
|
if (Test-ObjectGraphLooksLikeGenerationPoem $callback.Value $Seen "$Label.callbacks.$($callback.Name)" 'callbacks' $OwnerKind) { $matched = $true }
|
|
}
|
|
continue
|
|
}
|
|
if ($name -ceq 'mapping') {
|
|
foreach ($mapping in @($child.PSObject.Properties)) {
|
|
$mappingRef = [pscustomobject]@{ '$ref' = [string]$mapping.Value }
|
|
if (Test-ObjectGraphLooksLikeGenerationPoem $mappingRef $Seen "$Label.mapping.$($mapping.Name)" 'schemas' $OwnerKind) { $matched = $true }
|
|
}
|
|
continue
|
|
}
|
|
if ($name -clike 'x-*' -or (Test-ContainsExact @('title', 'summary', 'description', 'example', 'examples', 'default', 'externalDocs', 'xml') $name)) {
|
|
continue
|
|
}
|
|
if (Test-ObjectGraphLooksLikeGenerationPoem $child $Seen "$Label.$name" '' $OwnerKind) { $matched = $true }
|
|
}
|
|
return $matched
|
|
}
|
|
|
|
function Test-OperationHasGenerationPoemIdentity([string]$Path, [object]$Operation) {
|
|
$identity = @(
|
|
$Path,
|
|
[string]$Operation.operationId,
|
|
(@($Operation.tags) -join ' ')
|
|
) -join ' '
|
|
return $identity -cmatch $generationPoemLexicalPattern
|
|
}
|
|
|
|
function Get-GlobalGenerationPoemOwners([ValidateSet('Mutation', 'CanonicalRead')][string]$OwnerKind) {
|
|
$owners = New-Object System.Collections.Generic.List[string]
|
|
$pathsProperty = Get-ExactProperty $document 'paths'
|
|
$paths = if ($pathsProperty) { $pathsProperty.Value } else { $null }
|
|
foreach ($pathProperty in @($paths.PSObject.Properties | Where-Object { $_.Name -cmatch '^/genealogy/app(?:/|$)' })) {
|
|
$pathItem = $pathProperty.Value
|
|
if (Get-ExactProperty $pathItem '$ref') {
|
|
Add-Issue "Path Item $($pathProperty.Name) must not use `$ref in the OpenAPI 3.0.1 APP owner graph"
|
|
}
|
|
$methods = if ($OwnerKind -ceq 'Mutation') { @('post', 'put', 'patch', 'delete') } else { @('get', 'head') }
|
|
foreach ($methodProperty in @($pathItem.PSObject.Properties | Where-Object { Test-ContainsExact $methods $_.Name })) {
|
|
$label = "$($methodProperty.Name.ToUpperInvariant()) $($pathProperty.Name)"
|
|
$operation = $methodProperty.Value
|
|
$matched = Test-OperationHasGenerationPoemIdentity $pathProperty.Name $operation
|
|
if ($OwnerKind -ceq 'Mutation') {
|
|
if ((Get-ExactProperty $operation 'requestBody') -and
|
|
(Test-ObjectGraphLooksLikeGenerationPoem $operation.requestBody @{} "$label requestBody" 'requestBodies' 'Mutation')) {
|
|
$matched = $true
|
|
}
|
|
foreach ($parameter in @(@($pathItem.parameters) + @($operation.parameters))) {
|
|
if (Test-ObjectGraphLooksLikeGenerationPoem $parameter @{} "$label parameter" 'parameters' 'Mutation') { $matched = $true }
|
|
}
|
|
} else {
|
|
if ((Get-ExactProperty $operation 'responses') -and
|
|
(Test-ObjectGraphLooksLikeGenerationPoem $operation.responses @{} "$label responses" '' 'CanonicalRead')) {
|
|
$matched = $true
|
|
}
|
|
}
|
|
if ((Get-ExactProperty $operation 'callbacks') -and
|
|
(Test-ObjectGraphLooksLikeGenerationPoem $operation.callbacks @{} "$label callbacks" '' 'Any')) {
|
|
$matched = $true
|
|
}
|
|
if ($matched) { $owners.Add($label) }
|
|
}
|
|
foreach ($methodProperty in @($pathItem.PSObject.Properties | Where-Object { Test-ContainsExact @('options', 'trace') $_.Name })) {
|
|
$label = "$($methodProperty.Name.ToUpperInvariant()) $($pathProperty.Name)"
|
|
$operation = $methodProperty.Value
|
|
$matched = Test-OperationHasGenerationPoemIdentity $pathProperty.Name $operation
|
|
if (Test-ObjectGraphLooksLikeGenerationPoem $operation @{} $label '' 'Any') { $matched = $true }
|
|
if ($matched) { Add-Issue "$label is a forbidden G12 contract surface" }
|
|
}
|
|
}
|
|
return @($owners | Sort-Object -Unique)
|
|
}
|
|
|
|
function Get-GlobalGenerationPoemMutationOwners {
|
|
return @(Get-GlobalGenerationPoemOwners 'Mutation')
|
|
}
|
|
|
|
function Get-GlobalGenerationPoemReadOwners {
|
|
return @(Get-GlobalGenerationPoemOwners 'CanonicalRead')
|
|
}
|
|
|
|
function Get-Operation([string]$Path, [string]$Method) {
|
|
$pathsProperty = Get-ExactProperty $document 'paths'
|
|
$pathProperty = if ($pathsProperty) { Get-ExactProperty $pathsProperty.Value $Path } else { $null }
|
|
if (-not $pathProperty) {
|
|
Add-Issue "missing path: $Path"
|
|
return $null
|
|
}
|
|
$operation = Get-ExactProperty $pathProperty.Value $Method
|
|
if (-not $operation) {
|
|
Add-Issue "missing $($Method.ToUpperInvariant()) $Path"
|
|
return $null
|
|
}
|
|
return $operation.Value
|
|
}
|
|
|
|
function Assert-OnlyMethods([string]$Path, [string[]]$Expected) {
|
|
$pathsProperty = Get-ExactProperty $document 'paths'
|
|
$pathProperty = if ($pathsProperty) { Get-ExactProperty $pathsProperty.Value $Path } else { $null }
|
|
if (-not $pathProperty) { return }
|
|
Assert-CanonicalKeyCasing $pathProperty.Value "$Path Path Item" @('$ref', 'summary', 'description', 'get', 'put', 'post', 'delete', 'options', 'head', 'patch', 'trace', 'servers', 'parameters')
|
|
$actual = @($pathProperty.Value.PSObject.Properties.Name | Where-Object { Test-ContainsExact $httpMethods $_ } | Sort-Object)
|
|
$wanted = @($Expected | Sort-Object)
|
|
if (($actual -join ',') -cne ($wanted -join ',')) {
|
|
Add-Issue "$Path must expose exactly $($wanted -join ','); actual: $($actual -join ',')"
|
|
}
|
|
}
|
|
|
|
function Assert-GlobalOperationId([string]$OperationId, [string]$ExpectedOwner) {
|
|
$owners = New-Object System.Collections.Generic.List[string]
|
|
foreach ($pathProperty in @($document.paths.PSObject.Properties)) {
|
|
foreach ($methodProperty in @($pathProperty.Value.PSObject.Properties | Where-Object { Test-ContainsExact $httpMethods $_.Name })) {
|
|
if ([string]$methodProperty.Value.operationId -ceq $OperationId) {
|
|
$owners.Add("$($methodProperty.Name.ToUpperInvariant()) $($pathProperty.Name)")
|
|
}
|
|
}
|
|
}
|
|
if ($owners.Count -ne 1 -or $owners[0] -cne $ExpectedOwner) {
|
|
Add-Issue "operationId $OperationId must have one global owner $ExpectedOwner; actual: $($owners -join ',')"
|
|
}
|
|
}
|
|
|
|
function Get-OperationParameters([string]$Path, [object]$Operation, [string]$Label) {
|
|
if (-not $Operation) { return @() }
|
|
$resolved = @()
|
|
$pathsProperty = Get-ExactProperty $document 'paths'
|
|
$pathProperty = if ($pathsProperty) { Get-ExactProperty $pathsProperty.Value $Path } else { $null }
|
|
$pathParametersProperty = if ($pathProperty) { Get-ExactProperty $pathProperty.Value 'parameters' } else { $null }
|
|
$operationParametersProperty = Get-ExactProperty $Operation 'parameters'
|
|
$pathParameters = if ($pathParametersProperty) { @($pathParametersProperty.Value) } else { @() }
|
|
$operationParameters = if ($operationParametersProperty) { @($operationParametersProperty.Value) } else { @() }
|
|
foreach ($scope in @($pathParameters, $operationParameters)) {
|
|
foreach ($parameter in $scope) {
|
|
$value = Resolve-Parameter $parameter "$Label parameter"
|
|
if ($value) { $resolved += $value }
|
|
}
|
|
}
|
|
return $resolved
|
|
}
|
|
|
|
function Assert-ExactParameters([object[]]$Parameters, [string]$Label, [string[]]$Expected) {
|
|
$actual = @($Parameters | ForEach-Object { "$($_.in):$($_.name)" } | Sort-Object)
|
|
$wanted = @($Expected | Sort-Object)
|
|
if (($actual -join ',') -cne ($wanted -join ',')) {
|
|
Add-Issue "$Label parameters must be exactly $($wanted -join ','); actual: $($actual -join ',')"
|
|
}
|
|
}
|
|
|
|
function Get-Parameter([object[]]$Parameters, [string]$Name, [string]$In, [string]$Label) {
|
|
$matches = @($Parameters | Where-Object { [string]$_.name -ceq $Name -and [string]$_.in -ceq $In })
|
|
if ($matches.Count -ne 1) {
|
|
Add-Issue "$Label must declare exactly one $In parameter: $Name"
|
|
return $null
|
|
}
|
|
return $matches[0]
|
|
}
|
|
|
|
function Assert-SaToken([object]$Operation, [string]$Label) {
|
|
if (-not $Operation) { return }
|
|
if (-not (Test-IsJsonArray $Operation.security)) {
|
|
Add-Issue "$Label must require exactly one SaToken security alternative"
|
|
return
|
|
}
|
|
$security = @($Operation.security)
|
|
$saTokenProperty = if ($security.Count -eq 1) { Get-ExactProperty $security[0] 'SaToken' } else { $null }
|
|
if ($security.Count -ne 1 -or $security[0].PSObject.Properties.Count -ne 1 -or
|
|
-not $saTokenProperty -or
|
|
-not (Test-IsJsonArray $saTokenProperty.Value) -or @($saTokenProperty.Value).Count -ne 0) {
|
|
Add-Issue "$Label must require exactly one SaToken security alternative"
|
|
}
|
|
}
|
|
|
|
function Assert-SaTokenOwner {
|
|
$securitySchemes = Get-ComponentSection 'securitySchemes'
|
|
$ownerProperty = Get-ExactProperty $securitySchemes 'SaToken'
|
|
if (-not $ownerProperty) {
|
|
Add-Issue 'missing security scheme owner: SaToken'
|
|
return
|
|
}
|
|
$owner = Resolve-PureLocalReference $ownerProperty.Value 'securitySchemes' 'SaToken security scheme owner' @{}
|
|
if (-not $owner) { return }
|
|
Assert-AllowedObjectKeys $owner 'SaToken security scheme owner' @('type', 'in', 'name', 'description') @('type', 'in', 'name')
|
|
if ([string]$owner.type -cne 'apiKey' -or [string]$owner.in -cne 'header' -or
|
|
[string]$owner.name -cne 'Authorization') {
|
|
Add-Issue 'SaToken must be the header apiKey security scheme named Authorization'
|
|
}
|
|
}
|
|
|
|
function Assert-AppGatewayCorsPolicyOwner {
|
|
$policiesProperty = Get-ExactProperty $document 'x-app-gateway-policies'
|
|
$policies = if ($policiesProperty) { $policiesProperty.Value } else { $null }
|
|
$policyProperty = Get-ExactProperty $policies 'APP_GATEWAY_PREFLIGHT'
|
|
if (-not $policyProperty) {
|
|
Add-Issue 'missing top-level gateway CORS policy owner: APP_GATEWAY_PREFLIGHT'
|
|
return
|
|
}
|
|
$policy = $policyProperty.Value
|
|
Assert-AllowedObjectKeys $policy 'APP_GATEWAY_PREFLIGHT gateway policy' @('allowedRequestHeaders', 'allowedMethods', 'originPolicy', 'maxAgeSeconds') @('allowedRequestHeaders', 'allowedMethods', 'originPolicy', 'maxAgeSeconds')
|
|
if (-not (Test-IsJsonArray $policy.allowedRequestHeaders) -or
|
|
(@($policy.allowedRequestHeaders) -join ',') -cne 'Authorization,Content-Type,If-Match,clientid' -or
|
|
-not (Test-IsJsonArray $policy.allowedMethods) -or
|
|
(@($policy.allowedMethods) -join ',') -cne 'GET,PUT,OPTIONS' -or
|
|
[string]$policy.originPolicy -cne 'EXPLICIT_DEPLOYMENT_ALLOWLIST' -or
|
|
($policy.maxAgeSeconds -isnot [int] -and $policy.maxAgeSeconds -isnot [long]) -or
|
|
[int64]$policy.maxAgeSeconds -ne 600) {
|
|
Add-Issue 'APP_GATEWAY_PREFLIGHT must own the exact G12 browser preflight policy'
|
|
}
|
|
}
|
|
|
|
function Assert-OperationObject([object]$Operation, [string]$Label, [string[]]$AllowedExtensions, [bool]$RequiresRequestBody) {
|
|
if (-not $Operation) { return }
|
|
$allowed = @('tags', 'summary', 'description', 'operationId', 'parameters', 'responses', 'security', 'deprecated')
|
|
$required = @('description', 'operationId', 'responses', 'security')
|
|
if ($RequiresRequestBody) {
|
|
$allowed += 'requestBody'
|
|
$required += 'requestBody'
|
|
}
|
|
Assert-AllowedObjectKeys $Operation $Label $allowed $required $AllowedExtensions
|
|
if ((Get-ExactProperty $Operation 'deprecated') -and -not (Test-IsJsonBoolean $Operation.deprecated $false)) {
|
|
Add-Issue "$Label must not publish the canonical owner as deprecated"
|
|
}
|
|
}
|
|
|
|
function Assert-ExactResponseSet([object]$Operation, [string]$Label, [string[]]$Expected) {
|
|
if (-not $Operation) { return }
|
|
$actual = @($Operation.responses.PSObject.Properties.Name | Sort-Object)
|
|
$wanted = @($Expected | Sort-Object)
|
|
if (($actual -join ',') -cne ($wanted -join ',')) {
|
|
Add-Issue "$Label responses must be exactly $($wanted -join ','); actual: $($actual -join ',')"
|
|
}
|
|
foreach ($status in $actual) {
|
|
if ($status -ceq 'default' -or $status -cmatch '^3' -or $status -ceq '412') {
|
|
Add-Issue "$Label must not use default, 3xx, or 412 responses"
|
|
}
|
|
}
|
|
}
|
|
|
|
function Get-Response([object]$Operation, [string]$Status, [string]$Label) {
|
|
if (-not $Operation) { return $null }
|
|
$property = Get-ExactProperty $Operation.responses $Status
|
|
if (-not $property) {
|
|
Add-Issue "$Label missing response: $Status"
|
|
return $null
|
|
}
|
|
return Resolve-Response $property.Value "$Label $Status"
|
|
}
|
|
|
|
function Get-JsonResponseSchemaRef([object]$Response, [string]$Label) {
|
|
if (-not $Response) { return '' }
|
|
$media = if ($Response.content) { @($Response.content.PSObject.Properties) } else { @() }
|
|
if ($media.Count -ne 1 -or $media[0].Name -cne 'application/json') {
|
|
Add-Issue "$Label must expose only application/json"
|
|
return ''
|
|
}
|
|
Assert-MediaTypeObject $media[0].Value "$Label application/json"
|
|
$schema = $media[0].Value.schema
|
|
$ref = [string]$schema.'$ref'
|
|
if (-not $ref) {
|
|
Add-Issue "$Label must use a component schema ref"
|
|
return ''
|
|
}
|
|
[void](Get-LocalComponentName $ref 'schemas' "$Label schema")
|
|
$keywords = @($schema.PSObject.Properties.Name)
|
|
if ($keywords.Count -ne 1 -or $keywords[0] -cne '$ref') {
|
|
Add-Issue "$Label response schema must contain only its exact local schema ref"
|
|
}
|
|
return $ref
|
|
}
|
|
|
|
function Assert-ResponseHeaders([object]$Response, [string]$Status, [string]$Label) {
|
|
if (-not $Response) { return }
|
|
$headers = if ($Response.headers) { @($Response.headers.PSObject.Properties.Name) } else { @() }
|
|
$cacheProperty = Get-ExactProperty $Response.headers 'Cache-Control'
|
|
if (-not $cacheProperty) {
|
|
Add-Issue "$Label must use the shared PrivateNoStore Cache-Control header"
|
|
} else {
|
|
$cacheRef = [string]$cacheProperty.Value.'$ref'
|
|
$refKeys = @($cacheProperty.Value.PSObject.Properties.Name)
|
|
if ($cacheRef -cne '#/components/headers/PrivateNoStore' -or $refKeys.Count -ne 1 -or $refKeys[0] -cne '$ref') {
|
|
Add-Issue "$Label Cache-Control must be the sole exact local PrivateNoStore Reference Object"
|
|
}
|
|
[void](Get-LocalComponentName $cacheRef 'headers' "$Label Cache-Control")
|
|
}
|
|
if ($Status -ceq '429') {
|
|
$retryProperty = Get-ExactProperty $Response.headers 'Retry-After'
|
|
if (-not $retryProperty) {
|
|
Add-Issue "$Label must use the shared RetryAfter header"
|
|
} else {
|
|
$retryRef = [string]$retryProperty.Value.'$ref'
|
|
$refKeys = @($retryProperty.Value.PSObject.Properties.Name)
|
|
if ($retryRef -cne '#/components/headers/RetryAfter' -or $refKeys.Count -ne 1 -or $refKeys[0] -cne '$ref') {
|
|
Add-Issue "$Label Retry-After must be the sole exact local RetryAfter Reference Object"
|
|
}
|
|
[void](Get-LocalComponentName $retryRef 'headers' "$Label Retry-After")
|
|
}
|
|
}
|
|
if (@($headers | Where-Object { $_ -ceq 'ETag' }).Count -gt 0) {
|
|
Add-Issue "$Label must not publish ETag; poemSetVersion is the sole concurrency owner"
|
|
}
|
|
$allowed = @('Cache-Control', 'traceparent', 'tracestate', 'x-request-id', 'x-correlation-id')
|
|
if ($Status -ceq '429') { $allowed += 'Retry-After' }
|
|
$unexpected = @($headers | Where-Object { -not (Test-ContainsExact $allowed $_) })
|
|
if ($unexpected.Count -gt 0) {
|
|
Add-Issue "$Label has forbidden semantic response headers: $($unexpected -join ',')"
|
|
}
|
|
foreach ($traceName in @('traceparent', 'tracestate', 'x-request-id', 'x-correlation-id')) {
|
|
$property = Get-ExactProperty $Response.headers $traceName
|
|
if (-not $property) { continue }
|
|
$header = Resolve-Header $property.Value "$Label $traceName"
|
|
if (-not $header) { continue }
|
|
if (-not $header.schema -or [string]$header.schema.type -cne 'string' -or -not (Test-IsNonNullable $header.schema)) {
|
|
Add-Issue "$Label $traceName must resolve to a Header Object with a non-null string schema"
|
|
continue
|
|
}
|
|
Assert-NoConflictingSchemaKeywords $header.schema "$Label $traceName schema"
|
|
Assert-AllowedSchemaKeywords $header.schema "$Label $traceName schema" @('type', 'nullable')
|
|
}
|
|
}
|
|
|
|
function Assert-ExactObject([object]$Schema, [string]$Name, [string[]]$Properties, [string[]]$Required, [string[]]$AllowedExtensions = @()) {
|
|
if (-not $Schema) { return }
|
|
$actualProperties = if ($Schema.properties) { @($Schema.properties.PSObject.Properties.Name | Sort-Object) } else { @() }
|
|
$actualRequired = @($Schema.required | Sort-Object)
|
|
$expectedProperties = @($Properties | Sort-Object)
|
|
$expectedRequired = @($Required | Sort-Object)
|
|
if ([string]$Schema.type -cne 'object' -or -not (Test-IsNonNullable $Schema) -or
|
|
-not (Test-IsJsonBoolean $Schema.additionalProperties $false) -or
|
|
-not (Test-IsJsonArray $Schema.required) -or
|
|
($actualProperties -join ',') -cne ($expectedProperties -join ',') -or
|
|
($actualRequired -join ',') -cne ($expectedRequired -join ',')) {
|
|
Add-Issue "$Name must be a non-null closed object with properties [$($expectedProperties -join ',')] and required [$($expectedRequired -join ',')]"
|
|
}
|
|
Assert-NoConflictingSchemaKeywords $Schema $Name
|
|
Assert-AllowedSchemaKeywordsWithExtensions $Schema $Name @('type', 'properties', 'required', 'additionalProperties', 'nullable') $AllowedExtensions
|
|
}
|
|
|
|
function Assert-PropertyRef([object]$Schema, [string]$SchemaName, [string]$Field, [string]$ExpectedRef) {
|
|
if (-not $Schema) { return }
|
|
$property = Get-ExactProperty $Schema.properties $Field
|
|
if (-not $property) {
|
|
Add-Issue "$SchemaName missing property: $Field"
|
|
return
|
|
}
|
|
[void](Test-IsPureSchemaRef $property.Value $ExpectedRef "$SchemaName.$Field")
|
|
}
|
|
|
|
function Assert-SingleEnum([object]$Schema, [string]$Label, [object]$Value) {
|
|
if (-not $Schema) { return }
|
|
$values = @($Schema.enum)
|
|
if (-not (Test-IsJsonArray $Schema.enum) -or $values.Count -ne 1 -or $values[0] -cne $Value) {
|
|
Add-Issue "$Label must use the single enum value: $Value"
|
|
}
|
|
}
|
|
|
|
function Assert-IntegerEnumScalar([object]$Schema, [string]$Label, [int]$Value) {
|
|
if (-not $Schema) { return }
|
|
$formatProperty = Get-ExactProperty $Schema 'format'
|
|
if ([string]$Schema.type -cne 'integer' -or -not (Test-IsNonNullable $Schema) -or
|
|
($formatProperty -and [string]$formatProperty.Value -cne 'int32')) {
|
|
Add-Issue "$Label must be a non-null integer scalar"
|
|
}
|
|
Assert-SingleEnum $Schema $Label $Value
|
|
Assert-NoConflictingSchemaKeywords $Schema $Label @('enum')
|
|
Assert-AllowedSchemaKeywords $Schema $Label @('type', 'format', 'enum', 'nullable')
|
|
}
|
|
|
|
function Assert-StringEnumScalar([object]$Schema, [string]$Label, [string[]]$Values) {
|
|
if (-not $Schema) { return }
|
|
$actual = @($Schema.enum | Sort-Object)
|
|
$expected = @($Values | Sort-Object)
|
|
if ([string]$Schema.type -cne 'string' -or -not (Test-IsNonNullable $Schema) -or
|
|
-not (Test-IsJsonArray $Schema.enum) -or
|
|
($actual -join ',') -cne ($expected -join ',')) {
|
|
Add-Issue "$Label must be a non-null string enum exactly [$($expected -join ',')]"
|
|
}
|
|
Assert-NoConflictingSchemaKeywords $Schema $Label @('enum')
|
|
Assert-AllowedSchemaKeywords $Schema $Label @('type', 'enum', 'nullable')
|
|
}
|
|
|
|
function Assert-BoundedStringScalar([object]$Schema, [string]$Label, [int]$Maximum, [string]$Pattern = '') {
|
|
if (-not $Schema) { return }
|
|
if ([string]$Schema.type -cne 'string' -or -not (Test-IsNonNullable $Schema) -or
|
|
-not (Get-ExactProperty $Schema 'minLength') -or [int]$Schema.minLength -ne 1 -or
|
|
-not (Get-ExactProperty $Schema 'maxLength') -or [int]$Schema.maxLength -ne $Maximum -or
|
|
($Pattern -and [string]$Schema.pattern -cne $Pattern)) {
|
|
Add-Issue "$Label must be a bounded non-null string scalar"
|
|
}
|
|
Assert-NoConflictingSchemaKeywords $Schema $Label
|
|
$allowed = @('type', 'minLength', 'maxLength', 'nullable')
|
|
if ($Pattern) { $allowed += 'pattern' }
|
|
Assert-AllowedSchemaKeywords $Schema $Label $allowed
|
|
}
|
|
|
|
function Assert-SuccessEnvelope([object]$Schema) {
|
|
Assert-ExactObject $Schema 'RGenerationPoemSetSnapshot' @('code', 'data') @('code', 'data')
|
|
if (-not $Schema) { return }
|
|
Assert-IntegerEnumScalar $Schema.properties.code 'RGenerationPoemSetSnapshot.code' 200
|
|
Assert-PropertyRef $Schema 'RGenerationPoemSetSnapshot' 'data' '#/components/schemas/GenerationPoemSetSnapshot'
|
|
}
|
|
|
|
function Assert-FixedError([string]$Name, [int]$Status, [string[]]$BusinessCodes) {
|
|
$schema = Get-Schema $Name
|
|
Assert-ExactObject $schema $Name @('code', 'businessCode', 'message') @('code', 'businessCode', 'message')
|
|
if (-not $schema) { return }
|
|
$code = $schema.properties.code
|
|
$businessCode = $schema.properties.businessCode
|
|
$message = $schema.properties.message
|
|
Assert-IntegerEnumScalar $code "$Name.code" $Status
|
|
Assert-StringEnumScalar $businessCode "$Name.businessCode" $BusinessCodes
|
|
Assert-BoundedStringScalar $message "$Name.message" 200
|
|
}
|
|
|
|
function Assert-Union([object]$Schema, [string]$Name, [hashtable]$Branches, [string[]]$AllowedExtensions = @()) {
|
|
if (-not $Schema) { return }
|
|
Assert-AllowedObjectKeys $Schema $Name @('discriminator', 'oneOf') @('discriminator', 'oneOf') $AllowedExtensions
|
|
$discriminatorKeys = if ($Schema.discriminator) { @($Schema.discriminator.PSObject.Properties.Name | Sort-Object) } else { @() }
|
|
if (($discriminatorKeys -join ',') -cne 'mapping,propertyName' -or
|
|
-not (Test-IsJsonArray $Schema.oneOf) -or
|
|
@($Schema.oneOf).Count -ne $Branches.Count -or
|
|
[string]$Schema.discriminator.propertyName -cne 'businessCode') {
|
|
Add-Issue "$Name must be a pure businessCode discriminator/oneOf union"
|
|
}
|
|
$actualMappingKeys = if ($Schema.discriminator.mapping) { @($Schema.discriminator.mapping.PSObject.Properties.Name | Sort-Object) } else { @() }
|
|
$expectedMappingKeys = @($Branches.Keys | Sort-Object)
|
|
if (($actualMappingKeys -join ',') -cne ($expectedMappingKeys -join ',')) {
|
|
Add-Issue "$Name discriminator mapping keys must be exactly $($expectedMappingKeys -join ',')"
|
|
}
|
|
foreach ($entry in $Branches.GetEnumerator()) {
|
|
$mappingProperty = Get-ExactProperty $Schema.discriminator.mapping ([string]$entry.Key)
|
|
if (-not $mappingProperty -or [string]$mappingProperty.Value -cne $entry.Value) {
|
|
Add-Issue "$Name discriminator mapping drifted for $($entry.Key)"
|
|
}
|
|
[void](Get-LocalComponentName ([string]$mappingProperty.Value) 'schemas' "$Name discriminator mapping $($entry.Key)")
|
|
}
|
|
$actualRefs = @($Schema.oneOf | ForEach-Object { [string]$_.'$ref' } | Sort-Object)
|
|
$expectedRefs = @($Branches.Values | Sort-Object)
|
|
if (($actualRefs -join ',') -cne ($expectedRefs -join ',')) {
|
|
Add-Issue "$Name oneOf refs must be exactly $($expectedRefs -join ',')"
|
|
}
|
|
foreach ($branch in @($Schema.oneOf)) {
|
|
if ($branch) { [void](Test-IsPureSchemaRef $branch ([string]$branch.'$ref') "$Name branch") }
|
|
}
|
|
}
|
|
|
|
function Assert-SharedHeaderOwners {
|
|
$componentsProperty = Get-ExactProperty $document 'components'
|
|
$headersProperty = if ($componentsProperty) { Get-ExactProperty $componentsProperty.Value 'headers' } else { $null }
|
|
$headers = if ($headersProperty) { $headersProperty.Value } else { $null }
|
|
$privateProperty = Get-ExactProperty $headers 'PrivateNoStore'
|
|
if (-not $privateProperty) {
|
|
Add-Issue 'missing shared header owner: PrivateNoStore'
|
|
} else {
|
|
$header = Resolve-Header $privateProperty.Value 'PrivateNoStore header owner'
|
|
if ($header) {
|
|
$values = @($header.schema.enum)
|
|
if ([string]$header.schema.type -cne 'string' -or -not (Test-IsNonNullable $header.schema) -or
|
|
-not (Test-IsJsonArray $header.schema.enum) -or $values.Count -ne 1 -or
|
|
[string]$values[0] -cne 'private, no-store') {
|
|
Add-Issue 'PrivateNoStore must be a fixed non-null string enum [private, no-store]'
|
|
}
|
|
Assert-NoConflictingSchemaKeywords $header.schema 'PrivateNoStore schema' @('enum')
|
|
Assert-AllowedSchemaKeywords $header.schema 'PrivateNoStore schema' @('type', 'enum', 'nullable')
|
|
}
|
|
}
|
|
$retryProperty = Get-ExactProperty $headers 'RetryAfter'
|
|
if (-not $retryProperty) {
|
|
Add-Issue 'missing shared header owner: RetryAfter'
|
|
} else {
|
|
$header = Resolve-Header $retryProperty.Value 'RetryAfter header owner'
|
|
if ($header) {
|
|
if ([string]$header.schema.type -cne 'integer' -or -not (Test-IsNonNullable $header.schema) -or
|
|
[int]$header.schema.minimum -ne 1 -or [int]$header.schema.maximum -lt 1 -or
|
|
[int]$header.schema.maximum -gt 120) {
|
|
Add-Issue 'RetryAfter must be a non-null integer bounded to 1..120 seconds'
|
|
}
|
|
Assert-NoConflictingSchemaKeywords $header.schema 'RetryAfter schema'
|
|
Assert-AllowedSchemaKeywords $header.schema 'RetryAfter schema' @('type', 'minimum', 'maximum', 'nullable')
|
|
}
|
|
}
|
|
}
|
|
|
|
$openapiProperty = Get-ExactProperty $document 'openapi'
|
|
if (-not $openapiProperty -or [string]$openapiProperty.Value -cne '3.0.1') {
|
|
Add-Issue "G12 gate requires the exact OpenAPI 3.0.1 dialect; actual: $($document.openapi)"
|
|
}
|
|
if (Get-ExactProperty $document 'webhooks') {
|
|
Add-Issue 'OpenAPI 3.0.1 must not define top-level webhooks; migrate dialect and owner graph together'
|
|
}
|
|
if (-not $SkipParity) {
|
|
$parityScript = Join-Path $PSScriptRoot 'openapi-yaml-json-parity-runtime-smoke.js'
|
|
$parityOutput = @(& node $parityScript 2>&1)
|
|
if ($LASTEXITCODE -ne 0 -or 'OPENAPI-YAML-JSON-PARITY PASS' -notin $parityOutput) {
|
|
Add-Issue "protected JSON/YAML semantic parity failed: $($parityOutput -join ' | ')"
|
|
}
|
|
}
|
|
Assert-SaTokenOwner
|
|
Assert-AppGatewayCorsPolicyOwner
|
|
Assert-SharedHeaderOwners
|
|
|
|
$poemPaths = @($document.paths.PSObject.Properties.Name | Where-Object {
|
|
$_ -match '^/genealogy/app/genealogies/\{genealogyId\}/generation-poems(?:/|$)'
|
|
} | Sort-Object)
|
|
if ($poemPaths.Count -ne 1 -or $poemPaths[0] -cne $poemPath) {
|
|
Add-Issue "generation-poem APP API must have the sole aggregate path $poemPath; actual: $($poemPaths -join ',')"
|
|
}
|
|
foreach ($path in $poemPaths) {
|
|
if ($path -cne $poemPath) { Add-Issue "legacy generation-poem path must be removed: $path" }
|
|
}
|
|
Assert-OnlyMethods $poemPath @('get', 'put')
|
|
|
|
$globalMutationOwners = @(Get-GlobalGenerationPoemMutationOwners)
|
|
if ($globalMutationOwners.Count -ne 1 -or $globalMutationOwners[0] -cne "PUT $poemPath") {
|
|
Add-Issue "generation-poem APP mutation must have one global owner PUT $poemPath; actual: $($globalMutationOwners -join ',')"
|
|
}
|
|
$globalReadOwners = @(Get-GlobalGenerationPoemReadOwners)
|
|
if ($globalReadOwners.Count -ne 1 -or $globalReadOwners[0] -cne "GET $poemPath") {
|
|
Add-Issue "generation-poem APP canonical read must have one global owner GET $poemPath; actual: $($globalReadOwners -join ',')"
|
|
}
|
|
$callbackOwners = Get-ComponentSection 'callbacks'
|
|
foreach ($callbackProperty in @($callbackOwners.PSObject.Properties)) {
|
|
if (Test-ObjectGraphLooksLikeGenerationPoem $callbackProperty.Value @{} "components.callbacks.$($callbackProperty.Name)" 'callbacks') {
|
|
Add-Issue "unowned component callback exposes the G12 contract: $($callbackProperty.Name)"
|
|
}
|
|
}
|
|
|
|
$legacySchemas = @(
|
|
'GenerationPoemBatchBody',
|
|
'GenerationPoemBody',
|
|
'RGenerationPoem',
|
|
'GenerationPoemView',
|
|
'RGenerationPoemList',
|
|
'RGenerationPoemBatchPreview',
|
|
'GenerationPoemBatchPreviewView',
|
|
'GenerationPoemBatchItemView'
|
|
)
|
|
foreach ($name in $legacySchemas) {
|
|
$schemasSection = Get-ComponentSection 'schemas'
|
|
if (Get-ExactProperty $schemasSection $name) {
|
|
Add-Issue "legacy generation-poem schema owner must be removed: $name"
|
|
}
|
|
}
|
|
foreach ($name in @('GenerationPoemResult', 'GenerationPoemListResult', 'GenerationPoemBatchPreviewResult')) {
|
|
$responsesSection = Get-ComponentSection 'responses'
|
|
if (Get-ExactProperty $responsesSection $name) {
|
|
Add-Issue "legacy generation-poem response owner must be removed: $name"
|
|
}
|
|
}
|
|
|
|
$getOperation = Get-Operation $poemPath 'get'
|
|
$putOperation = Get-Operation $poemPath 'put'
|
|
$getOperationExtensions = @(
|
|
'x-projection',
|
|
'x-sort-order',
|
|
'x-cors-required-request-headers',
|
|
'x-cors-policy-owner'
|
|
)
|
|
$putOperationExtensions = @(
|
|
'x-cas-owner',
|
|
'x-revalidates',
|
|
'x-candidate-steps',
|
|
'x-generation-slot-collision-policy',
|
|
'x-swap-declaration-policy',
|
|
'x-swap-write-policy',
|
|
'x-transaction-failure-policy',
|
|
'x-transaction-effects',
|
|
'x-semantic-noop',
|
|
'x-version-reuse',
|
|
'x-outcome-unknown-reconciliation',
|
|
'x-unknown-precedence',
|
|
'x-unknown-overlap-policy',
|
|
'x-unknown-existing-match-fields',
|
|
'x-unknown-new-match-fields',
|
|
'x-unknown-new-id-policy',
|
|
'x-unknown-set-equality',
|
|
'x-unknown-version-comparison',
|
|
'x-unknown-version-policy',
|
|
'x-unknown-target-construction-fields',
|
|
'x-unknown-semantic-fields',
|
|
'x-cors-required-request-headers',
|
|
'x-cors-policy-owner'
|
|
)
|
|
Assert-OperationObject $getOperation "GET $poemPath" $getOperationExtensions $false
|
|
Assert-OperationObject $putOperation "PUT $poemPath" $putOperationExtensions $true
|
|
$operationContracts = @(
|
|
[pscustomobject]@{
|
|
Method = 'GET'
|
|
Operation = $getOperation
|
|
OperationId = 'appGetGenerationPoemSet'
|
|
Parameters = @('header:clientid', 'path:genealogyId')
|
|
Responses = [ordered]@{
|
|
'200' = '#/components/schemas/RGenerationPoemSetSnapshot'
|
|
'400' = '#/components/schemas/RGenerationPoemBadRequest'
|
|
'401' = '#/components/schemas/RGenerationPoemUnauthorized'
|
|
'404' = '#/components/schemas/RGenerationPoemNotFound'
|
|
'429' = '#/components/schemas/RGenerationPoemRateLimited'
|
|
'500' = '#/components/schemas/RGenerationPoemReadUnavailable'
|
|
}
|
|
},
|
|
[pscustomobject]@{
|
|
Method = 'PUT'
|
|
Operation = $putOperation
|
|
OperationId = 'appUpdateGenerationPoemSet'
|
|
Parameters = @('header:If-Match', 'header:clientid', 'path:genealogyId')
|
|
Responses = [ordered]@{
|
|
'200' = '#/components/schemas/RGenerationPoemSetSnapshot'
|
|
'400' = '#/components/schemas/RGenerationPoemBadRequest'
|
|
'401' = '#/components/schemas/RGenerationPoemUnauthorized'
|
|
'403' = '#/components/schemas/RGenerationPoemForbidden'
|
|
'404' = '#/components/schemas/RGenerationPoemNotFound'
|
|
'409' = '#/components/schemas/RGenerationPoemSetConflict'
|
|
'422' = '#/components/schemas/RGenerationPoemUnprocessable'
|
|
'429' = '#/components/schemas/RGenerationPoemRateLimited'
|
|
'500' = '#/components/schemas/RGenerationPoemOutcomeUnknown'
|
|
}
|
|
}
|
|
)
|
|
|
|
foreach ($contract in $operationContracts) {
|
|
$label = "$($contract.Method) $poemPath"
|
|
$operation = $contract.Operation
|
|
if (-not $operation) { continue }
|
|
if ([string]$operation.operationId -cne $contract.OperationId) {
|
|
Add-Issue "$label operationId must be $($contract.OperationId)"
|
|
}
|
|
Assert-GlobalOperationId $contract.OperationId $label
|
|
Assert-SaToken $operation $label
|
|
$parameters = @(Get-OperationParameters $poemPath $operation $label)
|
|
Assert-ExactParameters $parameters $label $contract.Parameters
|
|
$clientid = Get-Parameter $parameters 'clientid' 'header' $label
|
|
if ($clientid -and (-not (Test-IsJsonBoolean $clientid.required $true) -or
|
|
[string]$clientid.schema.type -cne 'string' -or -not (Test-IsNonNullable $clientid.schema) -or
|
|
[int]$clientid.schema.minLength -ne 1 -or [int]$clientid.schema.maxLength -ne 128)) {
|
|
Add-Issue "$label clientid must be a required non-null string bounded to 1..128"
|
|
}
|
|
if ($clientid) {
|
|
Assert-NoConflictingSchemaKeywords $clientid.schema "$label clientid"
|
|
Assert-AllowedSchemaKeywords $clientid.schema "$label clientid" @('type', 'minLength', 'maxLength', 'nullable')
|
|
}
|
|
$genealogyId = Get-Parameter $parameters 'genealogyId' 'path' $label
|
|
if ($genealogyId -and -not (Test-IsJsonBoolean $genealogyId.required $true)) {
|
|
Add-Issue "$label genealogyId must be required"
|
|
}
|
|
if ($genealogyId) {
|
|
[void](Test-IsPureSchemaRef $genealogyId.schema '#/components/schemas/GenealogyId' "$label genealogyId")
|
|
}
|
|
Assert-ExactResponseSet $operation $label @($contract.Responses.Keys)
|
|
foreach ($status in @($contract.Responses.Keys)) {
|
|
$response = Get-Response $operation $status $label
|
|
$actualRef = Get-JsonResponseSchemaRef $response "$label $status"
|
|
if ($actualRef -cne $contract.Responses[$status]) {
|
|
Add-Issue "$label $status must return $($contract.Responses[$status]); actual: $actualRef"
|
|
}
|
|
Assert-ResponseHeaders $response $status "$label $status"
|
|
}
|
|
}
|
|
|
|
if ($getOperation) {
|
|
if (Get-ExactProperty $getOperation 'requestBody') { Add-Issue "GET $poemPath must not define a request body" }
|
|
foreach ($pattern in @('canView', 'ACTIVE[- ]only', 'generationNo.*ascending', 'non-disclosing.*404')) {
|
|
if ([string]$getOperation.description -notmatch "(?i)$pattern") {
|
|
Add-Issue "GET $poemPath description misses: $pattern"
|
|
}
|
|
}
|
|
if ([string]$getOperation.'x-projection' -cne 'ACTIVE_ONLY' -or
|
|
-not (Test-IsJsonArray $getOperation.'x-sort-order') -or
|
|
(@($getOperation.'x-sort-order') -join ',') -cne 'generationNo:asc' -or
|
|
-not (Test-IsJsonArray $getOperation.'x-cors-required-request-headers') -or
|
|
(@($getOperation.'x-cors-required-request-headers') -join ',') -cne 'Authorization,clientid' -or
|
|
[string]$getOperation.'x-cors-policy-owner' -cne 'APP_GATEWAY_PREFLIGHT') {
|
|
Add-Issue "GET $poemPath must machine-bind ACTIVE_ONLY, generationNo ascending, and gateway CORS headers"
|
|
}
|
|
}
|
|
|
|
if ($putOperation) {
|
|
$putParameters = @(Get-OperationParameters $poemPath $putOperation "PUT $poemPath")
|
|
$ifMatch = Get-Parameter $putParameters 'If-Match' 'header' "PUT $poemPath"
|
|
if ($ifMatch -and -not (Test-IsJsonBoolean $ifMatch.required $true)) {
|
|
Add-Issue 'PUT generation-poem If-Match must be required'
|
|
}
|
|
if ($ifMatch) {
|
|
[void](Test-IsPureSchemaRef $ifMatch.schema '#/components/schemas/GenerationPoemSetVersion' 'PUT generation-poem If-Match')
|
|
}
|
|
$requestBody = Resolve-RequestBody $putOperation.requestBody 'PUT generation-poem request body'
|
|
if (-not $requestBody -or -not (Test-IsJsonBoolean $requestBody.required $true)) {
|
|
Add-Issue 'PUT generation-poem request body must be required'
|
|
}
|
|
$requestMedia = if ($requestBody -and $requestBody.content) { @($requestBody.content.PSObject.Properties) } else { @() }
|
|
if ($requestMedia.Count -ne 1 -or $requestMedia[0].Name -cne 'application/json') {
|
|
Add-Issue 'PUT generation-poem request body must expose only application/json'
|
|
} else {
|
|
Assert-MediaTypeObject $requestMedia[0].Value 'PUT generation-poem request body application/json'
|
|
[void](Test-IsPureSchemaRef $requestMedia[0].Value.schema '#/components/schemas/AppGenerationPoemSetUpdateBody' 'PUT generation-poem request body')
|
|
}
|
|
foreach ($pattern in @(
|
|
'canEditContent.*READY.*transaction',
|
|
'If-Match.*poemSetVersion',
|
|
'full candidate.*before.*write',
|
|
'disableMissing=false.*preserve.*unrepresented.*ACTIVE',
|
|
'disableMissing=true.*soft-disable.*unrepresented.*ACTIVE',
|
|
'never physical.*delete',
|
|
'swap.*no observable intermediate',
|
|
'semantic no-op.*keep.*version',
|
|
'unknown.*fresh GET.*target.*old.*current',
|
|
'current.*target.*not.*prove.*request',
|
|
'no automatic PUT'
|
|
)) {
|
|
if ([string]$putOperation.description -notmatch "(?i)$pattern") {
|
|
Add-Issue "PUT $poemPath description misses: $pattern"
|
|
}
|
|
}
|
|
$revalidates = @($putOperation.'x-revalidates')
|
|
$candidateSteps = @($putOperation.'x-candidate-steps')
|
|
$effects = @($putOperation.'x-transaction-effects')
|
|
$unknownTargetFields = @($putOperation.'x-unknown-target-construction-fields')
|
|
$unknownFields = @($putOperation.'x-unknown-semantic-fields')
|
|
if ([string]$putOperation.'x-cas-owner' -cne 'GenerationPoemSetVersion' -or
|
|
-not (Test-IsJsonArray $putOperation.'x-revalidates') -or
|
|
($revalidates -join ',') -cne 'tenantScope,canEditContent,poemSetVersion,genealogyState' -or
|
|
-not (Test-IsJsonArray $putOperation.'x-candidate-steps') -or
|
|
($candidateSteps -join ',') -cne 'RESOLVE_BASELINE_ACTIVE_IDS,VALIDATE_DECLARED_STRICT_ORDER,BUILD_DECLARED_TARGET,APPLY_DISABLE_MISSING_POLICY,ORDER_MERGED_CANDIDATE_BY_GENERATION_NO,VALIDATE_ID_AND_GENERATION_UNIQUENESS,VALIDATE_GENERATION_SLOT_COLLISIONS,VALIDATE_CONTIGUITY,VALIDATE_FINAL_ACTIVE_CAPACITY,ALLOCATE_UNIQUE_NEW_IDS,VALIDATE_ALLOCATED_IDS_NONEMPTY_UNIQUE_NOT_IN_BASELINE,WRITE_ATOMICALLY' -or
|
|
[string]$putOperation.'x-generation-slot-collision-policy' -cne 'REJECT_422_ZERO_DOMAIN_WRITE' -or
|
|
[string]$putOperation.'x-swap-declaration-policy' -cne 'ALL_AFFECTED_ROWS_REQUIRED' -or
|
|
-not (Test-IsJsonArray $putOperation.'x-transaction-effects') -or
|
|
($effects -join ',') -cne 'APPLY_VALIDATED_CANDIDATE_ATOMICALLY,APPLY_DISABLE_MISSING_POLICY,UPDATE_VERSION_ON_SEMANTIC_CHANGE' -or
|
|
[string]$putOperation.'x-swap-write-policy' -cne 'ATOMIC_NO_OBSERVABLE_INTERMEDIATE_STATE' -or
|
|
[string]$putOperation.'x-transaction-failure-policy' -cne 'ZERO_DOMAIN_WRITE' -or
|
|
[string]$putOperation.'x-semantic-noop' -cne 'RETURN_200_KEEP_VERSION' -or
|
|
[string]$putOperation.'x-version-reuse' -cne 'FORBIDDEN' -or
|
|
[string]$putOperation.'x-outcome-unknown-reconciliation' -cne 'FRESH_GET_THREE_WAY_NO_AUTO_PUT' -or
|
|
[string]$putOperation.'x-unknown-precedence' -cne 'CURRENT_EQUALS_TARGET_THEN_CURRENT_EQUALS_OLD_THEN_DIVERGED' -or
|
|
[string]$putOperation.'x-unknown-overlap-policy' -cne 'CURRENT_EQUALS_TARGET_FIRST_NO_ATTRIBUTION' -or
|
|
-not (Test-IsJsonArray $putOperation.'x-unknown-existing-match-fields') -or
|
|
(@($putOperation.'x-unknown-existing-match-fields') -join ',') -cne 'poemId,generationNo,generationText' -or
|
|
-not (Test-IsJsonArray $putOperation.'x-unknown-new-match-fields') -or
|
|
(@($putOperation.'x-unknown-new-match-fields') -join ',') -cne 'generationNo,generationText' -or
|
|
[string]$putOperation.'x-unknown-new-id-policy' -cne 'NON_EMPTY_UNIQUE_NOT_IN_BASELINE' -or
|
|
[string]$putOperation.'x-unknown-set-equality' -cne 'SAME_CARDINALITY_NO_EXTRA_ACTIVE_ROWS' -or
|
|
[string]$putOperation.'x-unknown-version-comparison' -cne 'IGNORE_FOR_SEMANTIC_EQUALITY' -or
|
|
[string]$putOperation.'x-unknown-version-policy' -cne 'CHANGED_TARGET_REQUIRES_FRESH_NONREUSED_VERSION' -or
|
|
-not (Test-IsJsonArray $putOperation.'x-unknown-target-construction-fields') -or
|
|
($unknownTargetFields -join ',') -cne 'genealogyId,baselinePoemSetVersion,baselineOrderedItems,declaredItems,disableMissing' -or
|
|
-not (Test-IsJsonArray $putOperation.'x-unknown-semantic-fields') -or
|
|
($unknownFields -join ',') -cne 'genealogyId,effectiveTargetOrderedItems' -or
|
|
-not (Test-IsJsonArray $putOperation.'x-cors-required-request-headers') -or
|
|
(@($putOperation.'x-cors-required-request-headers') -join ',') -cne 'Authorization,Content-Type,If-Match,clientid' -or
|
|
[string]$putOperation.'x-cors-policy-owner' -cne 'APP_GATEWAY_PREFLIGHT') {
|
|
Add-Issue 'PUT generation-poem CAS/transaction/swap/unknown/CORS extensions drifted'
|
|
}
|
|
}
|
|
|
|
$genealogyIdOwner = Get-Schema 'GenealogyId'
|
|
if ($genealogyIdOwner -and ([string]$genealogyIdOwner.type -cne 'string' -or -not (Test-IsNonNullable $genealogyIdOwner) -or
|
|
[int]$genealogyIdOwner.minLength -ne 1 -or [int]$genealogyIdOwner.maxLength -ne 128 -or
|
|
[string]$genealogyIdOwner.pattern -cne $identifierPattern)) {
|
|
Add-Issue 'GenealogyId must be a non-null 1..128 lexical opaque identifier'
|
|
}
|
|
if ($genealogyIdOwner) {
|
|
Assert-NoConflictingSchemaKeywords $genealogyIdOwner 'GenealogyId'
|
|
Assert-AllowedSchemaKeywords $genealogyIdOwner 'GenealogyId' @('type', 'minLength', 'maxLength', 'pattern', 'nullable')
|
|
}
|
|
|
|
$poemIdOwner = Get-Schema 'GenerationPoemId'
|
|
if ($poemIdOwner -and ([string]$poemIdOwner.type -cne 'string' -or -not (Test-IsNonNullable $poemIdOwner) -or
|
|
[int]$poemIdOwner.minLength -ne 1 -or [int]$poemIdOwner.maxLength -ne 128 -or
|
|
[string]$poemIdOwner.pattern -cne $identifierPattern -or
|
|
-not (Test-IsJsonBoolean $poemIdOwner.'x-opaque' $true) -or
|
|
[string]$poemIdOwner.'x-client-semantics' -cne 'COMPARE_ONLY')) {
|
|
Add-Issue 'GenerationPoemId must be a non-null opaque lexical identifier used only for equality'
|
|
}
|
|
if ($poemIdOwner) {
|
|
Assert-NoConflictingSchemaKeywords $poemIdOwner 'GenerationPoemId'
|
|
Assert-AllowedSchemaKeywordsWithExtensions $poemIdOwner 'GenerationPoemId' @('type', 'minLength', 'maxLength', 'pattern', 'nullable') @('x-opaque', 'x-client-semantics')
|
|
}
|
|
|
|
$versionOwner = Get-Schema 'GenerationPoemSetVersion'
|
|
if ($versionOwner -and ([string]$versionOwner.type -cne 'string' -or -not (Test-IsNonNullable $versionOwner) -or
|
|
[int]$versionOwner.minLength -ne 1 -or [int]$versionOwner.maxLength -ne 128 -or
|
|
[string]$versionOwner.pattern -cne $identifierPattern -or
|
|
-not (Test-IsJsonBoolean $versionOwner.'x-opaque' $true) -or
|
|
-not (Test-IsJsonArray $versionOwner.'x-version-scope-fields') -or
|
|
(@($versionOwner.'x-version-scope-fields') -join ',') -cne 'poemId,generationNo,generationText,activeState' -or
|
|
[string]$versionOwner.'x-version-change-policy' -cne 'ACTIVE_SEMANTIC_CHANGE_ONLY' -or
|
|
[string]$versionOwner.'x-version-reuse' -cne 'FORBIDDEN')) {
|
|
Add-Issue 'GenerationPoemSetVersion must be one opaque, non-reused ACTIVE semantic version owner'
|
|
}
|
|
if ($versionOwner) {
|
|
Assert-NoConflictingSchemaKeywords $versionOwner 'GenerationPoemSetVersion'
|
|
Assert-AllowedSchemaKeywordsWithExtensions $versionOwner 'GenerationPoemSetVersion' @('type', 'minLength', 'maxLength', 'pattern', 'nullable') @('x-opaque', 'x-version-scope-fields', 'x-version-change-policy', 'x-version-reuse')
|
|
}
|
|
|
|
$generationNoOwner = Get-Schema 'GenerationPoemGenerationNo'
|
|
if ($generationNoOwner -and ([string]$generationNoOwner.type -cne 'integer' -or -not (Test-IsNonNullable $generationNoOwner) -or
|
|
((Get-ExactProperty $generationNoOwner 'format') -and [string]$generationNoOwner.format -cne 'int32') -or
|
|
-not (Get-ExactProperty $generationNoOwner 'minimum') -or -not (Get-ExactProperty $generationNoOwner 'maximum') -or
|
|
[int64]$generationNoOwner.minimum -ne 1 -or
|
|
[int64]$generationNoOwner.maximum -ne 2147483647)) {
|
|
Add-Issue 'GenerationPoemGenerationNo must be a non-null safe int32-range JSON integer 1..2147483647 with absent or int32 format'
|
|
}
|
|
if ($generationNoOwner) {
|
|
Assert-NoConflictingSchemaKeywords $generationNoOwner 'GenerationPoemGenerationNo'
|
|
Assert-AllowedSchemaKeywords $generationNoOwner 'GenerationPoemGenerationNo' @('type', 'format', 'minimum', 'maximum', 'nullable')
|
|
}
|
|
|
|
$textOwner = Get-Schema 'GenerationPoemText'
|
|
$forbiddenTextClasses = 'C0,C1,CR,LF,TAB,ARABIC_LETTER_MARK,LTR_RTL_MARK,BIDI_OVERRIDE_OR_ISOLATE,WORD_JOINER,ZERO_WIDTH_OR_BOM,LINE_PARAGRAPH_SEPARATOR,UNPAIRED_SURROGATE'
|
|
if ($textOwner -and ([string]$textOwner.type -cne 'string' -or -not (Test-IsNonNullable $textOwner) -or
|
|
[int]$textOwner.minLength -ne 1 -or [int]$textOwner.maxLength -ne 50 -or
|
|
[string]$textOwner.pattern -cne $generationTextPattern -or
|
|
[string]$textOwner.'x-normalization' -cne 'NFC' -or
|
|
[string]$textOwner.'x-length-unit' -cne 'UNICODE_CODE_POINT' -or
|
|
-not (Test-IsJsonBoolean $textOwner.'x-well-formed-unicode' $true) -or
|
|
[string]$textOwner.'x-boundary-whitespace' -cne 'REJECT' -or
|
|
-not (Test-IsJsonArray $textOwner.'x-forbidden-code-point-classes') -or
|
|
(@($textOwner.'x-forbidden-code-point-classes') -join ',') -cne $forbiddenTextClasses)) {
|
|
Add-Issue 'GenerationPoemText must be NFC, 1..50 Unicode code points, boundary-clean, and free of control/bidi/zero-width/malformed Unicode'
|
|
}
|
|
if ($textOwner) {
|
|
Assert-NoConflictingSchemaKeywords $textOwner 'GenerationPoemText'
|
|
Assert-AllowedSchemaKeywordsWithExtensions $textOwner 'GenerationPoemText' @('type', 'minLength', 'maxLength', 'pattern', 'nullable') @('x-normalization', 'x-length-unit', 'x-well-formed-unicode', 'x-boundary-whitespace', 'x-forbidden-code-point-classes')
|
|
}
|
|
|
|
$requestItem = Get-Schema 'AppGenerationPoemSetItem'
|
|
Assert-ExactObject $requestItem 'AppGenerationPoemSetItem' @('poemId', 'generationNo', 'generationText') @('generationNo', 'generationText') @('x-existing-id-policy', 'x-new-id-policy', 'x-id-move-policy', 'x-disabled-id-policy')
|
|
Assert-PropertyRef $requestItem 'AppGenerationPoemSetItem' 'poemId' '#/components/schemas/GenerationPoemId'
|
|
Assert-PropertyRef $requestItem 'AppGenerationPoemSetItem' 'generationNo' '#/components/schemas/GenerationPoemGenerationNo'
|
|
Assert-PropertyRef $requestItem 'AppGenerationPoemSetItem' 'generationText' '#/components/schemas/GenerationPoemText'
|
|
if ($requestItem -and ([string]$requestItem.'x-existing-id-policy' -cne 'CURRENT_BASELINE_ACTIVE_ONLY' -or
|
|
[string]$requestItem.'x-new-id-policy' -cne 'OMIT_POEM_ID' -or
|
|
[string]$requestItem.'x-id-move-policy' -cne 'ALLOW_EXPLICIT_TARGET_GENERATION' -or
|
|
[string]$requestItem.'x-disabled-id-policy' -cne 'ROW_NOT_AVAILABLE')) {
|
|
Add-Issue 'AppGenerationPoemSetItem identity/new/move/disabled policies drifted'
|
|
}
|
|
|
|
$responseItem = Get-Schema 'GenerationPoemSetItem'
|
|
Assert-ExactObject $responseItem 'GenerationPoemSetItem' @('poemId', 'generationNo', 'generationText') @('poemId', 'generationNo', 'generationText')
|
|
Assert-PropertyRef $responseItem 'GenerationPoemSetItem' 'poemId' '#/components/schemas/GenerationPoemId'
|
|
Assert-PropertyRef $responseItem 'GenerationPoemSetItem' 'generationNo' '#/components/schemas/GenerationPoemGenerationNo'
|
|
Assert-PropertyRef $responseItem 'GenerationPoemSetItem' 'generationText' '#/components/schemas/GenerationPoemText'
|
|
|
|
$body = Get-Schema 'AppGenerationPoemSetUpdateBody'
|
|
Assert-ExactObject $body 'AppGenerationPoemSetUpdateBody' @('items', 'disableMissing') @('items', 'disableMissing') @('x-request-order', 'x-false-policy', 'x-true-policy', 'x-empty-false-policy', 'x-empty-true-policy', 'x-final-active-invariant', 'x-candidate-validation', 'x-duplicate-generation-text')
|
|
if ($body) {
|
|
$items = $body.properties.items
|
|
$disableMissing = $body.properties.disableMissing
|
|
if ([string]$items.type -cne 'array' -or -not (Test-IsNonNullable $items) -or
|
|
-not (Get-ExactProperty $items 'minItems') -or [int]$items.minItems -ne 0 -or
|
|
-not (Get-ExactProperty $items 'maxItems') -or [int]$items.maxItems -ne 500) {
|
|
Add-Issue 'AppGenerationPoemSetUpdateBody.items must be a non-null array with 0..500 declared rows'
|
|
}
|
|
if ($items) {
|
|
[void](Test-IsPureSchemaRef $items.items '#/components/schemas/AppGenerationPoemSetItem' 'AppGenerationPoemSetUpdateBody.items.items')
|
|
Assert-NoConflictingSchemaKeywords $items 'AppGenerationPoemSetUpdateBody.items'
|
|
Assert-AllowedSchemaKeywords $items 'AppGenerationPoemSetUpdateBody.items' @('type', 'items', 'minItems', 'maxItems', 'nullable')
|
|
}
|
|
if ([string]$disableMissing.type -cne 'boolean' -or -not (Test-IsNonNullable $disableMissing)) {
|
|
Add-Issue 'AppGenerationPoemSetUpdateBody.disableMissing must be a required non-null boolean'
|
|
}
|
|
if ($disableMissing) {
|
|
Assert-NoConflictingSchemaKeywords $disableMissing 'AppGenerationPoemSetUpdateBody.disableMissing'
|
|
Assert-AllowedSchemaKeywords $disableMissing 'AppGenerationPoemSetUpdateBody.disableMissing' @('type', 'nullable')
|
|
}
|
|
if ([string]$body.'x-request-order' -cne 'STRICT_GENERATION_NO_ASC_NO_SERVER_SORT' -or
|
|
[string]$body.'x-false-policy' -cne 'PRESERVE_ALL_UNREPRESENTED_BASELINE_ACTIVE' -or
|
|
[string]$body.'x-true-policy' -cne 'SOFT_DISABLE_ALL_UNREPRESENTED_BASELINE_ACTIVE' -or
|
|
[string]$body.'x-empty-false-policy' -cne 'SEMANTIC_NOOP' -or
|
|
[string]$body.'x-empty-true-policy' -cne 'SOFT_DISABLE_ALL_ACTIVE' -or
|
|
[string]$body.'x-final-active-invariant' -cne 'EMPTY_OR_CONTIGUOUS_INTERVAL' -or
|
|
[string]$body.'x-candidate-validation' -cne 'BEFORE_ANY_DOMAIN_WRITE' -or
|
|
[string]$body.'x-duplicate-generation-text' -cne 'ALLOWED') {
|
|
Add-Issue 'AppGenerationPoemSetUpdateBody ordering/omission/empty/candidate invariants drifted'
|
|
}
|
|
}
|
|
|
|
$snapshot = Get-Schema 'GenerationPoemSetSnapshot'
|
|
Assert-ExactObject $snapshot 'GenerationPoemSetSnapshot' @('genealogyId', 'poemSetVersion', 'items') @('genealogyId', 'poemSetVersion', 'items') @('x-projection', 'x-order', 'x-active-invariant', 'x-identity-uniqueness')
|
|
Assert-PropertyRef $snapshot 'GenerationPoemSetSnapshot' 'genealogyId' '#/components/schemas/GenealogyId'
|
|
Assert-PropertyRef $snapshot 'GenerationPoemSetSnapshot' 'poemSetVersion' '#/components/schemas/GenerationPoemSetVersion'
|
|
if ($snapshot) {
|
|
$items = $snapshot.properties.items
|
|
if ([string]$items.type -cne 'array' -or -not (Test-IsNonNullable $items) -or
|
|
-not (Get-ExactProperty $items 'minItems') -or [int]$items.minItems -ne 0 -or
|
|
-not (Get-ExactProperty $items 'maxItems') -or [int]$items.maxItems -ne 500) {
|
|
Add-Issue 'GenerationPoemSetSnapshot.items must be the non-null 0..500 canonical ACTIVE array'
|
|
}
|
|
if ($items) {
|
|
[void](Test-IsPureSchemaRef $items.items '#/components/schemas/GenerationPoemSetItem' 'GenerationPoemSetSnapshot.items.items')
|
|
Assert-NoConflictingSchemaKeywords $items 'GenerationPoemSetSnapshot.items'
|
|
Assert-AllowedSchemaKeywords $items 'GenerationPoemSetSnapshot.items' @('type', 'items', 'minItems', 'maxItems', 'nullable')
|
|
}
|
|
if ([string]$snapshot.'x-projection' -cne 'ACTIVE_ONLY' -or
|
|
[string]$snapshot.'x-order' -cne 'STRICT_GENERATION_NO_ASC' -or
|
|
[string]$snapshot.'x-active-invariant' -cne 'EMPTY_OR_CONTIGUOUS_INTERVAL' -or
|
|
[string]$snapshot.'x-identity-uniqueness' -cne 'POEM_ID_AND_GENERATION_NO') {
|
|
Add-Issue 'GenerationPoemSetSnapshot ACTIVE/order/continuity/identity invariants drifted'
|
|
}
|
|
}
|
|
|
|
$successEnvelope = Get-Schema 'RGenerationPoemSetSnapshot'
|
|
Assert-SuccessEnvelope $successEnvelope
|
|
|
|
Assert-FixedError 'RGenerationPoemBadRequest' 400 @('GENERATION_POEM_REQUEST_INVALID')
|
|
Assert-FixedError 'RGenerationPoemUnauthorized' 401 @('AUTH_REQUIRED')
|
|
Assert-FixedError 'RGenerationPoemForbidden' 403 @('GENERATION_POEM_EDIT_FORBIDDEN')
|
|
Assert-FixedError 'RGenerationPoemNotFound' 404 @('GENEALOGY_NOT_AVAILABLE')
|
|
Assert-FixedError 'RGenerationPoemRateLimited' 429 @('RATE_LIMITED')
|
|
Assert-FixedError 'RGenerationPoemReadUnavailable' 500 @('GENERATION_POEM_READ_UNAVAILABLE')
|
|
Assert-FixedError 'RGenerationPoemOutcomeUnknown' 500 @('GENERATION_POEM_OUTCOME_UNKNOWN')
|
|
|
|
$versionConflict = Get-Schema 'GenerationPoemSetVersionConflict'
|
|
Assert-ExactObject $versionConflict 'GenerationPoemSetVersionConflict' @('code', 'businessCode', 'message', 'current') @('code', 'businessCode', 'message', 'current')
|
|
if ($versionConflict) {
|
|
Assert-IntegerEnumScalar $versionConflict.properties.code 'GenerationPoemSetVersionConflict.code' 409
|
|
Assert-StringEnumScalar $versionConflict.properties.businessCode 'GenerationPoemSetVersionConflict.businessCode' @('POEM_SET_VERSION_CONFLICT')
|
|
Assert-BoundedStringScalar $versionConflict.properties.message 'GenerationPoemSetVersionConflict.message' 200
|
|
Assert-PropertyRef $versionConflict 'GenerationPoemSetVersionConflict' 'current' '#/components/schemas/GenerationPoemSetSnapshot'
|
|
}
|
|
$notReadyConflict = Get-Schema 'GenerationPoemGenealogyNotReadyConflict'
|
|
Assert-ExactObject $notReadyConflict 'GenerationPoemGenealogyNotReadyConflict' @('code', 'businessCode', 'message') @('code', 'businessCode', 'message')
|
|
if ($notReadyConflict) {
|
|
Assert-IntegerEnumScalar $notReadyConflict.properties.code 'GenerationPoemGenealogyNotReadyConflict.code' 409
|
|
Assert-StringEnumScalar $notReadyConflict.properties.businessCode 'GenerationPoemGenealogyNotReadyConflict.businessCode' @('GENEALOGY_NOT_READY')
|
|
Assert-BoundedStringScalar $notReadyConflict.properties.message 'GenerationPoemGenealogyNotReadyConflict.message' 200
|
|
}
|
|
$conflict = Get-Schema 'RGenerationPoemSetConflict'
|
|
Assert-Union $conflict 'RGenerationPoemSetConflict' @{
|
|
POEM_SET_VERSION_CONFLICT = '#/components/schemas/GenerationPoemSetVersionConflict'
|
|
GENEALOGY_NOT_READY = '#/components/schemas/GenerationPoemGenealogyNotReadyConflict'
|
|
} @('x-conflict-precedence')
|
|
if ($conflict -and (-not (Test-IsJsonArray $conflict.'x-conflict-precedence') -or
|
|
(@($conflict.'x-conflict-precedence') -join ',') -cne 'VERSION,READY')) {
|
|
Add-Issue 'RGenerationPoemSetConflict precedence must be VERSION then READY after authorization'
|
|
}
|
|
|
|
$fieldError = Get-Schema 'GenerationPoemFieldError'
|
|
Assert-ExactObject $fieldError 'GenerationPoemFieldError' @('path', 'message') @('path', 'message')
|
|
if ($fieldError) {
|
|
$path = $fieldError.properties.path
|
|
$message = $fieldError.properties.message
|
|
$fieldPathPattern = '^(items|items\[(?:[0-9]|[1-9][0-9]|[1-4][0-9]{2})\]\.(poemId|generationNo|generationText)|disableMissing)$'
|
|
if ([string]$path.type -cne 'string' -or -not (Test-IsNonNullable $path) -or [int]$path.minLength -ne 1 -or
|
|
[int]$path.maxLength -ne 128 -or [string]$path.pattern -cne $fieldPathPattern) {
|
|
Add-Issue 'GenerationPoemFieldError.path must identify only the closed request fields'
|
|
}
|
|
Assert-NoConflictingSchemaKeywords $path 'GenerationPoemFieldError.path'
|
|
Assert-AllowedSchemaKeywords $path 'GenerationPoemFieldError.path' @('type', 'minLength', 'maxLength', 'pattern', 'nullable')
|
|
Assert-BoundedStringScalar $message 'GenerationPoemFieldError.message' 200
|
|
}
|
|
|
|
$unprocessable = Get-Schema 'RGenerationPoemUnprocessable'
|
|
Assert-ExactObject $unprocessable 'RGenerationPoemUnprocessable' @('code', 'businessCode', 'message', 'fieldErrors') @('code', 'businessCode', 'message', 'fieldErrors')
|
|
if ($unprocessable) {
|
|
Assert-IntegerEnumScalar $unprocessable.properties.code 'RGenerationPoemUnprocessable.code' 422
|
|
$expectedCodes = @(
|
|
'DUPLICATE_GENERATION_NO',
|
|
'DUPLICATE_POEM_ID',
|
|
'GENERATION_POEM_CAPACITY_EXCEEDED',
|
|
'GENERATION_POEM_REQUEST_ORDER_INVALID',
|
|
'GENERATION_POEM_TEXT_INVALID',
|
|
'GENERATION_SLOT_CONFLICT',
|
|
'NON_CONTIGUOUS_GENERATIONS',
|
|
'POEM_ROW_NOT_AVAILABLE'
|
|
)
|
|
Assert-StringEnumScalar $unprocessable.properties.businessCode 'RGenerationPoemUnprocessable.businessCode' $expectedCodes
|
|
Assert-BoundedStringScalar $unprocessable.properties.message 'RGenerationPoemUnprocessable.message' 200
|
|
$fieldErrors = $unprocessable.properties.fieldErrors
|
|
if ([string]$fieldErrors.type -cne 'array' -or -not (Test-IsNonNullable $fieldErrors) -or
|
|
-not (Get-ExactProperty $fieldErrors 'minItems') -or [int]$fieldErrors.minItems -ne 0 -or
|
|
-not (Get-ExactProperty $fieldErrors 'maxItems') -or [int]$fieldErrors.maxItems -ne 500) {
|
|
Add-Issue 'RGenerationPoemUnprocessable.fieldErrors must be a bounded non-null array'
|
|
}
|
|
if ($fieldErrors) {
|
|
[void](Test-IsPureSchemaRef $fieldErrors.items '#/components/schemas/GenerationPoemFieldError' 'RGenerationPoemUnprocessable.fieldErrors.items')
|
|
Assert-NoConflictingSchemaKeywords $fieldErrors 'RGenerationPoemUnprocessable.fieldErrors'
|
|
Assert-AllowedSchemaKeywords $fieldErrors 'RGenerationPoemUnprocessable.fieldErrors' @('type', 'items', 'minItems', 'maxItems', 'nullable')
|
|
}
|
|
}
|
|
|
|
if ($ReturnIssues) {
|
|
return [string[]]@($issues)
|
|
}
|
|
|
|
if ($issues.Count -gt 0) {
|
|
Write-Output 'G12-GENERATION-POEM-OPENAPI-CONTRACT BLOCKED'
|
|
Write-Output "Issues: $($issues.Count)"
|
|
$issues | ForEach-Object { Write-Output "- $_" }
|
|
Write-Output '- Keep G12 on its explicit local preview until this protected OpenAPI gate passes from one backend JSON/YAML export.'
|
|
Write-Output '- After OpenAPI passes, start with executable production normalizer/coordinator tests; do not add a placeholder client gate.'
|
|
exit 1
|
|
}
|
|
|
|
Write-Output 'G12-GENERATION-POEM-OPENAPI-CONTRACT PASS'
|