1378 lines
60 KiB
PowerShell
1378 lines
60 KiB
PowerShell
param(
|
|
[switch]$SkipProtectedParity,
|
|
[switch]$ReturnIssues,
|
|
[string]$DocumentPath = ''
|
|
)
|
|
|
|
$ErrorActionPreference = 'Stop'
|
|
|
|
$root = Split-Path -Parent $PSScriptRoot
|
|
if ([string]::IsNullOrWhiteSpace($DocumentPath)) {
|
|
$DocumentPath = Join-Path $root 'APP.openapi.json'
|
|
}
|
|
$document = Get-Content -Raw -Encoding UTF8 -LiteralPath $DocumentPath | ConvertFrom-Json
|
|
$issues = New-Object System.Collections.Generic.List[string]
|
|
|
|
$feedListPath = '/genealogy/app/genealogies/{genealogyId}/feeds'
|
|
$feedDetailPath = '/genealogy/app/genealogies/{genealogyId}/feeds/{feedId}'
|
|
$rootCommentPath = '/genealogy/app/genealogies/{genealogyId}/feeds/{feedId}/comments'
|
|
$legacyFeedPagePath = '/genealogy/app/genealogies/{genealogyId}/feeds/page'
|
|
$legacyCommentPagePath = '/genealogy/app/genealogies/{genealogyId}/feeds/{feedId}/comments/page'
|
|
$identifierPattern = '^[A-Za-z0-9][A-Za-z0-9._~-]{0,127}$'
|
|
$cursorPattern = '^[A-Za-z0-9_-]{1,512}$'
|
|
|
|
$operations = @(
|
|
[pscustomobject]@{
|
|
Path = $feedListPath
|
|
Method = 'get'
|
|
Id = 'appListFamilyFeeds'
|
|
Parameters = @('header:clientid', 'path:genealogyId', 'query:cursor', 'query:limit')
|
|
SuccessRef = '#/components/schemas/RAppFamilyFeedCursorPage'
|
|
CursorRef = '#/components/schemas/FamilyFeedCursor'
|
|
CursorScope = @('tenant', 'account', 'authSession', 'client', 'genealogyId', 'projection', 'order', 'limit', 'windowUpperBound', 'lastTuple')
|
|
CursorOrder = @('publishedAt:DESC', 'feedId:DESC_ORDINAL')
|
|
},
|
|
[pscustomobject]@{
|
|
Path = $feedDetailPath
|
|
Method = 'get'
|
|
Id = 'appGetFamilyFeed'
|
|
Parameters = @('header:clientid', 'path:feedId', 'path:genealogyId')
|
|
SuccessRef = '#/components/schemas/RAppFamilyFeedReadItem'
|
|
CursorRef = ''
|
|
CursorScope = @()
|
|
CursorOrder = @()
|
|
},
|
|
[pscustomobject]@{
|
|
Path = $rootCommentPath
|
|
Method = 'get'
|
|
Id = 'appListFamilyFeedRootComments'
|
|
Parameters = @('header:clientid', 'path:feedId', 'path:genealogyId', 'query:cursor', 'query:limit')
|
|
SuccessRef = '#/components/schemas/RAppFamilyFeedRootCommentCursorPage'
|
|
CursorRef = '#/components/schemas/FamilyFeedRootCommentCursor'
|
|
CursorScope = @('tenant', 'account', 'authSession', 'client', 'genealogyId', 'feedId', 'projection', 'order', 'limit', 'windowUpperBound', 'lastTuple')
|
|
CursorOrder = @('publishedAt:ASC', 'commentId:ASC_ORDINAL')
|
|
}
|
|
)
|
|
|
|
$responseRefs = [ordered]@{
|
|
'400' = '#/components/schemas/RFamilyFeedReadBadRequest'
|
|
'401' = '#/components/schemas/RFamilyFeedReadUnauthorized'
|
|
'404' = '#/components/schemas/RFamilyFeedReadNotFound'
|
|
'429' = '#/components/schemas/RFamilyFeedReadRateLimited'
|
|
'500' = '#/components/schemas/RFamilyFeedReadUnavailable'
|
|
}
|
|
|
|
function Add-Issue([string]$Message) {
|
|
$script:issues.Add($Message)
|
|
}
|
|
|
|
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]$Object, [string]$Name) {
|
|
return $null -ne (Get-ExactProperty $Object $Name)
|
|
}
|
|
|
|
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 -eq $Expected
|
|
}
|
|
|
|
function Test-ContainsExactString([object[]]$Values, [string]$Candidate) {
|
|
foreach ($value in @($Values)) {
|
|
if ([string]$value -ceq $Candidate) { return $true }
|
|
}
|
|
return $false
|
|
}
|
|
|
|
function Assert-CanonicalKeyCasing(
|
|
[object]$Object,
|
|
[string[]]$CanonicalNames,
|
|
[string]$Label,
|
|
[bool]$AllowExtensions = $true
|
|
) {
|
|
if (-not $Object) { return }
|
|
foreach ($property in @($Object.PSObject.Properties)) {
|
|
if ($AllowExtensions -and $property.Name.StartsWith('x-', [System.StringComparison]::Ordinal)) { continue }
|
|
if (Test-ContainsExactString $CanonicalNames $property.Name) { continue }
|
|
if (@($CanonicalNames | Where-Object { $_ -ieq $property.Name }).Count -gt 0) {
|
|
Add-Issue "$Label keyword casing is invalid: $($property.Name)"
|
|
} else {
|
|
Add-Issue "$Label contains an unowned keyword: $($property.Name)"
|
|
}
|
|
}
|
|
}
|
|
|
|
function Assert-AllowedOperationKeys(
|
|
[object]$Object,
|
|
[string[]]$ExpectedExtensions,
|
|
[string]$Label
|
|
) {
|
|
if (-not $Object) { return }
|
|
$standardNames = @(
|
|
'tags', 'summary', 'description', 'externalDocs', 'operationId',
|
|
'parameters', 'requestBody', 'responses', 'callbacks', 'deprecated',
|
|
'security', 'servers'
|
|
)
|
|
foreach ($property in @($Object.PSObject.Properties)) {
|
|
if ((Test-ContainsExactString $standardNames $property.Name) -or
|
|
(Test-ContainsExactString $ExpectedExtensions $property.Name)) {
|
|
continue
|
|
}
|
|
$allNames = @($standardNames) + @($ExpectedExtensions)
|
|
if (@($allNames | Where-Object { $_ -ieq $property.Name }).Count -gt 0) {
|
|
Add-Issue "$Label operation keyword casing is invalid: $($property.Name)"
|
|
} else {
|
|
Add-Issue "$Label operation contains an unowned keyword: $($property.Name)"
|
|
}
|
|
}
|
|
}
|
|
|
|
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-ExactArray([object]$Value, [string[]]$Expected, [string]$Label) {
|
|
if (-not (Test-IsJsonArray $Value)) {
|
|
Add-Issue "$Label must be the exact JSON array [$($Expected -join ',')]"
|
|
return
|
|
}
|
|
$actual = @($Value)
|
|
if ($actual.Count -ne $Expected.Count) {
|
|
Add-Issue "$Label must be the exact JSON array [$($Expected -join ',')]"
|
|
return
|
|
}
|
|
for ($index = 0; $index -lt $Expected.Count; $index++) {
|
|
if ($actual[$index] -isnot [string] -or [string]$actual[$index] -cne $Expected[$index]) {
|
|
Add-Issue "$Label must be the exact JSON array [$($Expected -join ',')]"
|
|
return
|
|
}
|
|
}
|
|
}
|
|
|
|
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 Assert-NoConflictingSchemaKeywords([object]$Schema, [string]$Label, [string[]]$Allowed = @()) {
|
|
if (-not $Schema) { return }
|
|
foreach ($keyword in @('not', 'allOf', 'anyOf', 'oneOf', 'const', 'enum')) {
|
|
if (-not (Test-ContainsExactString $Allowed $keyword) -and (Test-ContainsExact $Schema $keyword)) {
|
|
Add-Issue "$Label must not define conflicting schema keyword: $keyword"
|
|
}
|
|
}
|
|
}
|
|
|
|
function Assert-AllowedSchemaKeywords(
|
|
[object]$Schema,
|
|
[string]$Label,
|
|
[string[]]$Allowed,
|
|
[string[]]$AllowedExtensions = @()
|
|
) {
|
|
if (-not $Schema) { return }
|
|
$annotations = @('title', 'description', 'example', 'examples', 'deprecated', 'externalDocs', 'xml')
|
|
foreach ($property in @($Schema.PSObject.Properties)) {
|
|
if ((Test-ContainsExactString $annotations $property.Name) -or
|
|
(Test-ContainsExactString $Allowed $property.Name) -or
|
|
(Test-ContainsExactString $AllowedExtensions $property.Name)) { continue }
|
|
Add-Issue "$Label contains an unowned schema keyword: $($property.Name)"
|
|
}
|
|
}
|
|
|
|
function Test-IsPureComponentRef(
|
|
[object]$Value,
|
|
[string]$ExpectedRef,
|
|
[string]$Section,
|
|
[string]$Label
|
|
) {
|
|
if (-not $Value) { return $false }
|
|
$names = @($Value.PSObject.Properties.Name)
|
|
$actualRef = [string]$Value.'$ref'
|
|
if ($names.Count -ne 1 -or $names[0] -cne '$ref' -or $actualRef -cne $ExpectedRef) {
|
|
Add-Issue "$Label must be the sole exact local ref $ExpectedRef; actual: $actualRef"
|
|
return $false
|
|
}
|
|
[void](Get-LocalComponentName $actualRef $Section $Label)
|
|
return $true
|
|
}
|
|
|
|
function Test-IsPureSchemaRef([object]$Schema, [string]$ExpectedRef, [string]$Label) {
|
|
return Test-IsPureComponentRef $Schema $ExpectedRef 'schemas' $Label
|
|
}
|
|
|
|
function Get-Schema([string]$Name) {
|
|
$schemas = Get-ExactProperty $document.components 'schemas'
|
|
$property = if ($schemas) { Get-ExactProperty $schemas.Value $Name } else { $null }
|
|
if (-not $property) {
|
|
Add-Issue "missing schema owner: $Name"
|
|
return $null
|
|
}
|
|
return $property.Value
|
|
}
|
|
|
|
function Assert-CanonicalPathItemKeys([string]$Path) {
|
|
$pathProperty = Get-ExactProperty $document.paths $Path
|
|
if (-not $pathProperty) { return }
|
|
$canonical = @('$ref', 'summary', 'description', 'get', 'put', 'post', 'delete', 'options', 'head', 'patch', 'trace', 'servers', 'parameters')
|
|
foreach ($property in @($pathProperty.Value.PSObject.Properties)) {
|
|
if ($property.Name.StartsWith('x-', [System.StringComparison]::Ordinal) -or
|
|
(Test-ContainsExactString $canonical $property.Name)) { continue }
|
|
if (@($canonical | Where-Object { $_ -ieq $property.Name }).Count -gt 0) {
|
|
Add-Issue "$Path Path Item keyword casing is invalid: $($property.Name)"
|
|
} else {
|
|
Add-Issue "$Path Path Item contains an unowned keyword: $($property.Name)"
|
|
}
|
|
}
|
|
}
|
|
|
|
function Get-Operation([string]$Path, [string]$Method) {
|
|
$pathProperty = Get-ExactProperty $document.paths $Path
|
|
if (-not $pathProperty) {
|
|
Add-Issue "missing path: $Path"
|
|
return $null
|
|
}
|
|
Assert-CanonicalPathItemKeys $Path
|
|
$operationProperty = Get-ExactProperty $pathProperty.Value $Method
|
|
if (-not $operationProperty) {
|
|
Add-Issue "missing $($Method.ToUpperInvariant()) $Path"
|
|
return $null
|
|
}
|
|
return $operationProperty.Value
|
|
}
|
|
|
|
function Resolve-Parameter([object]$Parameter, [string]$Label) {
|
|
if (-not $Parameter) { return $null }
|
|
$refProperty = Get-ExactProperty $Parameter '$ref'
|
|
if (-not $refProperty) { return $Parameter }
|
|
$referenceKeys = @($Parameter.PSObject.Properties.Name)
|
|
if ($referenceKeys.Count -ne 1 -or $referenceKeys[0] -cne '$ref') {
|
|
Add-Issue "$Label parameter ref must contain only its exact local `$ref under OpenAPI 3.0.1"
|
|
}
|
|
$name = Get-LocalComponentName ([string]$refProperty.Value) 'parameters' $Label
|
|
if (-not $name) { return $null }
|
|
$parameters = Get-ExactProperty $document.components 'parameters'
|
|
$owner = if ($parameters) { Get-ExactProperty $parameters.Value $name } else { $null }
|
|
if (-not $owner) {
|
|
Add-Issue "$Label references missing parameter owner: $name"
|
|
return $null
|
|
}
|
|
return $owner.Value
|
|
}
|
|
|
|
function Assert-ParameterObject([object]$Parameter, [string]$Label) {
|
|
if (-not $Parameter) { return }
|
|
$allowed = @(
|
|
'name', 'in', 'description', 'required', 'deprecated',
|
|
'schema', 'example', 'examples'
|
|
)
|
|
foreach ($property in @($Parameter.PSObject.Properties)) {
|
|
if (Test-ContainsExactString $allowed $property.Name) { continue }
|
|
if (@($allowed | Where-Object { $_ -ieq $property.Name }).Count -gt 0) {
|
|
Add-Issue "$Label Parameter Object keyword casing is invalid: $($property.Name)"
|
|
} else {
|
|
Add-Issue "$Label Parameter Object contains an unowned keyword: $($property.Name)"
|
|
}
|
|
}
|
|
$schemaProperty = Get-ExactProperty $Parameter 'schema'
|
|
if (-not $schemaProperty) {
|
|
Add-Issue "$Label Parameter Object must define exact schema"
|
|
}
|
|
}
|
|
|
|
function Get-OperationParameters([string]$Path, [object]$Operation, [string]$Label) {
|
|
if (-not $Operation) { return @() }
|
|
$pathItem = (Get-ExactProperty $document.paths $Path).Value
|
|
$merged = [System.Collections.Generic.Dictionary[string, object]]::new(
|
|
[System.StringComparer]::Ordinal
|
|
)
|
|
foreach ($entry in @(
|
|
[pscustomobject]@{ Scope = 'path'; Owner = $pathItem },
|
|
[pscustomobject]@{ Scope = 'operation'; Owner = $Operation }
|
|
)) {
|
|
$parametersProperty = Get-ExactProperty $entry.Owner 'parameters'
|
|
if (-not $parametersProperty) { continue }
|
|
if (-not (Test-IsJsonArray $parametersProperty.Value)) {
|
|
Add-Issue "$Label $($entry.Scope) parameters must be a JSON array"
|
|
continue
|
|
}
|
|
$scopeSeen = [System.Collections.Generic.HashSet[string]]::new(
|
|
[System.StringComparer]::Ordinal
|
|
)
|
|
foreach ($parameter in @($parametersProperty.Value)) {
|
|
$resolved = Resolve-Parameter $parameter "$Label $($entry.Scope)"
|
|
if (-not $resolved) { continue }
|
|
$nameProperty = Get-ExactProperty $resolved 'name'
|
|
$inProperty = Get-ExactProperty $resolved 'in'
|
|
if (-not $nameProperty -or -not $inProperty) {
|
|
Add-Issue "$Label $($entry.Scope) parameter must define exact name and in"
|
|
continue
|
|
}
|
|
Assert-ParameterObject $resolved "$Label $($entry.Scope) $([string]$inProperty.Value):$([string]$nameProperty.Value)"
|
|
$parameterIn = [string]$inProperty.Value
|
|
$parameterName = [string]$nameProperty.Value
|
|
if (-not (Test-ContainsExactString @('query', 'header', 'path', 'cookie') $parameterIn) -or
|
|
[string]::IsNullOrWhiteSpace($parameterName)) {
|
|
Add-Issue "$Label $($entry.Scope) parameter identity is invalid: ${parameterIn}:${parameterName}"
|
|
continue
|
|
}
|
|
$identityName = if ($parameterIn -ceq 'header') {
|
|
$parameterName.ToLowerInvariant()
|
|
} else {
|
|
$parameterName
|
|
}
|
|
$identity = "${parameterIn}:${identityName}"
|
|
if (-not $scopeSeen.Add($identity)) {
|
|
Add-Issue "$Label contains a duplicate parameter in one scope: ${parameterIn}:${parameterName}"
|
|
continue
|
|
}
|
|
$merged[$identity] = $resolved
|
|
}
|
|
}
|
|
return @($merged.Values)
|
|
}
|
|
|
|
function Get-Parameter([object[]]$Parameters, [string]$In, [string]$Name, [string]$Label) {
|
|
$matches = @($Parameters | Where-Object {
|
|
[string]$_.in -ceq $In -and [string]$_.name -ceq $Name
|
|
})
|
|
if ($matches.Count -ne 1) {
|
|
Add-Issue "$Label must define exactly one ${In}:${Name}"
|
|
return $null
|
|
}
|
|
return $matches[0]
|
|
}
|
|
|
|
function Assert-ExactParameters([object[]]$Parameters, [string[]]$Expected, [string]$Label) {
|
|
$actual = @($Parameters | ForEach-Object { "$([string]$_.in):$([string]$_.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 Assert-ParameterRequired([object]$Parameter, [bool]$Expected, [string]$Label) {
|
|
if (-not $Parameter) { return }
|
|
$required = Get-ExactProperty $Parameter 'required'
|
|
if (-not $required -or -not (Test-IsJsonBoolean $required.Value $Expected)) {
|
|
Add-Issue "$Label required must be the JSON boolean $Expected"
|
|
}
|
|
}
|
|
|
|
function Resolve-Response([object]$Response, [string]$Label) {
|
|
if (-not $Response) { return $null }
|
|
$refProperty = Get-ExactProperty $Response '$ref'
|
|
if (-not $refProperty) { return $Response }
|
|
$referenceKeys = @($Response.PSObject.Properties.Name)
|
|
if ($referenceKeys.Count -ne 1 -or $referenceKeys[0] -cne '$ref') {
|
|
Add-Issue "$Label response ref must contain only its exact local `$ref under OpenAPI 3.0.1"
|
|
}
|
|
$name = Get-LocalComponentName ([string]$refProperty.Value) 'responses' $Label
|
|
if (-not $name) { return $null }
|
|
$responses = Get-ExactProperty $document.components 'responses'
|
|
$owner = if ($responses) { Get-ExactProperty $responses.Value $name } else { $null }
|
|
if (-not $owner) {
|
|
Add-Issue "$Label references missing response owner: $name"
|
|
return $null
|
|
}
|
|
return $owner.Value
|
|
}
|
|
|
|
function Resolve-Header([object]$Header, [string]$Label) {
|
|
if (-not $Header) { return $null }
|
|
$refProperty = Get-ExactProperty $Header '$ref'
|
|
if (-not $refProperty) { return $Header }
|
|
$referenceKeys = @($Header.PSObject.Properties.Name)
|
|
if ($referenceKeys.Count -ne 1 -or $referenceKeys[0] -cne '$ref') {
|
|
Add-Issue "$Label header ref must contain only its exact local `$ref under OpenAPI 3.0.1"
|
|
}
|
|
$name = Get-LocalComponentName ([string]$refProperty.Value) 'headers' $Label
|
|
if (-not $name) { return $null }
|
|
$headers = Get-ExactProperty $document.components 'headers'
|
|
$owner = if ($headers) { Get-ExactProperty $headers.Value $name } else { $null }
|
|
if (-not $owner) {
|
|
Add-Issue "$Label references missing header owner: $name"
|
|
return $null
|
|
}
|
|
return $owner.Value
|
|
}
|
|
|
|
function Get-Response([object]$Operation, [string]$Status, [string]$Label) {
|
|
if (-not $Operation) { return $null }
|
|
$responsesProperty = Get-ExactProperty $Operation 'responses'
|
|
$property = if ($responsesProperty) { Get-ExactProperty $responsesProperty.Value $Status } else { $null }
|
|
if (-not $property) {
|
|
Add-Issue "$Label missing response $Status"
|
|
return $null
|
|
}
|
|
return Resolve-Response $property.Value "$Label $Status"
|
|
}
|
|
|
|
function Assert-ExactResponseSet([object]$Operation, [string]$Label) {
|
|
if (-not $Operation) { return }
|
|
$responsesProperty = Get-ExactProperty $Operation 'responses'
|
|
if (-not $responsesProperty) {
|
|
Add-Issue "$Label must define responses"
|
|
return
|
|
}
|
|
$actual = @($responsesProperty.Value.PSObject.Properties.Name | Sort-Object)
|
|
$wanted = @('200', '400', '401', '404', '429', '500')
|
|
if (($actual -join ',') -cne ($wanted -join ',')) {
|
|
Add-Issue "$Label responses must be exactly [$($wanted -join ',')]; actual: [$($actual -join ',')]"
|
|
}
|
|
}
|
|
|
|
function Get-JsonResponseSchemaRef([object]$Response, [string]$Label) {
|
|
if (-not $Response) { return '' }
|
|
$contentProperty = Get-ExactProperty $Response 'content'
|
|
$media = if ($contentProperty) { @($contentProperty.Value.PSObject.Properties) } else { @() }
|
|
if ($media.Count -ne 1 -or $media[0].Name -cne 'application/json') {
|
|
Add-Issue "$Label must expose only application/json"
|
|
return ''
|
|
}
|
|
$schemaProperty = Get-ExactProperty $media[0].Value 'schema'
|
|
if (-not $schemaProperty) {
|
|
Add-Issue "$Label must define a response schema"
|
|
return ''
|
|
}
|
|
$schema = $schemaProperty.Value
|
|
$ref = [string]$schema.'$ref'
|
|
if (-not $ref) {
|
|
Add-Issue "$Label response schema must use an exact local component ref"
|
|
return ''
|
|
}
|
|
[void](Test-IsPureSchemaRef $schema $ref "$Label response schema")
|
|
return $ref
|
|
}
|
|
|
|
function Assert-PrivateNoStore([object]$Response, [string]$Label) {
|
|
if (-not $Response) { return }
|
|
$headersProperty = Get-ExactProperty $Response 'headers'
|
|
$cacheProperty = if ($headersProperty) { Get-ExactProperty $headersProperty.Value 'Cache-Control' } else { $null }
|
|
if (-not $cacheProperty) {
|
|
Add-Issue "$Label must define Cache-Control: private, no-store"
|
|
return
|
|
}
|
|
$header = Resolve-Header $cacheProperty.Value "$Label Cache-Control"
|
|
if (-not $header) { return }
|
|
foreach ($property in @($header.PSObject.Properties)) {
|
|
if (-not (Test-ContainsExactString @('description', 'deprecated', 'schema') $property.Name)) {
|
|
Add-Issue "$Label Cache-Control contains an unowned Header Object keyword: $($property.Name)"
|
|
}
|
|
}
|
|
$schemaProperty = Get-ExactProperty $header 'schema'
|
|
$schema = if ($schemaProperty) { $schemaProperty.Value } else { $null }
|
|
if (-not $schema -or [string]$schema.type -cne 'string' -or
|
|
-not (Test-IsNonNullable $schema) -or
|
|
-not (Test-IsJsonArray $schema.enum) -or
|
|
@($schema.enum).Count -ne 1 -or
|
|
[string]$schema.enum[0] -cne 'private, no-store') {
|
|
Add-Issue "$Label Cache-Control must be the single non-null value private, no-store"
|
|
}
|
|
if ($schema) {
|
|
Assert-NoConflictingSchemaKeywords $schema "$Label Cache-Control" @('enum')
|
|
Assert-AllowedSchemaKeywords $schema "$Label Cache-Control" @('type', 'enum', 'nullable')
|
|
}
|
|
}
|
|
|
|
function Assert-RetryAfter([object]$Response, [string]$Label) {
|
|
if (-not $Response) { return }
|
|
$headersProperty = Get-ExactProperty $Response 'headers'
|
|
$retryProperty = if ($headersProperty) { Get-ExactProperty $headersProperty.Value 'Retry-After' } else { $null }
|
|
if (-not $retryProperty) {
|
|
Add-Issue "$Label must define Retry-After"
|
|
return
|
|
}
|
|
$header = Resolve-Header $retryProperty.Value "$Label Retry-After"
|
|
if (-not $header) { return }
|
|
foreach ($property in @($header.PSObject.Properties)) {
|
|
if (-not (Test-ContainsExactString @('description', 'deprecated', 'schema') $property.Name)) {
|
|
Add-Issue "$Label Retry-After contains an unowned Header Object keyword: $($property.Name)"
|
|
}
|
|
}
|
|
$schemaProperty = Get-ExactProperty $header 'schema'
|
|
$schema = if ($schemaProperty) { $schemaProperty.Value } else { $null }
|
|
if (-not $schema -or [string]$schema.type -cne 'integer' -or
|
|
-not (Test-IsNonNullable $schema) -or
|
|
[int]$schema.minimum -ne 1 -or
|
|
[int]$schema.maximum -ne 300) {
|
|
Add-Issue "$Label Retry-After must be a non-null integer in 1..300 seconds"
|
|
}
|
|
if ($schema) {
|
|
Assert-NoConflictingSchemaKeywords $schema "$Label Retry-After"
|
|
Assert-AllowedSchemaKeywords $schema "$Label Retry-After" @('type', 'minimum', 'maximum', 'nullable')
|
|
}
|
|
}
|
|
|
|
function Assert-ResponseHeaders([object]$Response, [string]$Status, [string]$Label) {
|
|
if (-not $Response) { return }
|
|
foreach ($property in @($Response.PSObject.Properties)) {
|
|
if (-not (Test-ContainsExactString @('description', 'headers', 'content') $property.Name)) {
|
|
Add-Issue "$Label contains an unowned Response Object keyword: $($property.Name)"
|
|
}
|
|
}
|
|
$headersProperty = Get-ExactProperty $Response 'headers'
|
|
$headerNames = if ($headersProperty) { @($headersProperty.Value.PSObject.Properties.Name) } else { @() }
|
|
$allowed = @('Cache-Control', 'traceparent', 'tracestate', 'x-request-id', 'x-correlation-id')
|
|
if ($Status -ceq '429') { $allowed += 'Retry-After' }
|
|
$unexpected = @($headerNames | Where-Object { -not (Test-ContainsExactString $allowed $_) })
|
|
if ($unexpected.Count -gt 0) {
|
|
Add-Issue "$Label contains unowned response headers: $($unexpected -join ',')"
|
|
}
|
|
Assert-PrivateNoStore $Response $Label
|
|
if ($Status -ceq '429') { Assert-RetryAfter $Response $Label }
|
|
}
|
|
|
|
function Assert-SaToken([object]$Operation, [string]$Label) {
|
|
if (-not $Operation) { return }
|
|
$securityProperty = Get-ExactProperty $Operation 'security'
|
|
if (-not $securityProperty -or -not (Test-IsJsonArray $securityProperty.Value)) {
|
|
Add-Issue "$Label security must be a JSON array"
|
|
return
|
|
}
|
|
$requirements = @($securityProperty.Value)
|
|
if ($requirements.Count -ne 1) {
|
|
Add-Issue "$Label must have exactly one security requirement"
|
|
return
|
|
}
|
|
$names = @($requirements[0].PSObject.Properties.Name)
|
|
if ($names.Count -ne 1 -or $names[0] -cne 'SaToken') {
|
|
Add-Issue "$Label must require only exact SaToken"
|
|
return
|
|
}
|
|
$scopes = $requirements[0].SaToken
|
|
if (-not (Test-IsJsonArray $scopes) -or @($scopes).Count -ne 0) {
|
|
Add-Issue "$Label SaToken scopes must be an empty JSON array"
|
|
}
|
|
}
|
|
|
|
function Assert-SaTokenScheme {
|
|
$componentsProperty = Get-ExactProperty $document 'components'
|
|
$schemesProperty = if ($componentsProperty) {
|
|
Get-ExactProperty $componentsProperty.Value 'securitySchemes'
|
|
} else {
|
|
$null
|
|
}
|
|
$schemeProperty = if ($schemesProperty) {
|
|
Get-ExactProperty $schemesProperty.Value 'SaToken'
|
|
} else {
|
|
$null
|
|
}
|
|
if (-not $schemeProperty) {
|
|
Add-Issue 'components.securitySchemes.SaToken must be the exact security owner'
|
|
return
|
|
}
|
|
$scheme = $schemeProperty.Value
|
|
$allowed = @('type', 'description', 'name', 'in')
|
|
foreach ($property in @($scheme.PSObject.Properties)) {
|
|
if (Test-ContainsExactString $allowed $property.Name) { continue }
|
|
if (@($allowed | Where-Object { $_ -ieq $property.Name }).Count -gt 0) {
|
|
Add-Issue "SaToken Security Scheme keyword casing is invalid: $($property.Name)"
|
|
} else {
|
|
Add-Issue "SaToken Security Scheme contains an unowned keyword: $($property.Name)"
|
|
}
|
|
}
|
|
$typeProperty = Get-ExactProperty $scheme 'type'
|
|
$inProperty = Get-ExactProperty $scheme 'in'
|
|
$nameProperty = Get-ExactProperty $scheme 'name'
|
|
if (-not $typeProperty -or [string]$typeProperty.Value -cne 'apiKey' -or
|
|
-not $inProperty -or [string]$inProperty.Value -cne 'header' -or
|
|
-not $nameProperty -or [string]$nameProperty.Value -cne 'Authorization') {
|
|
Add-Issue 'SaToken must be the exact apiKey/header/Authorization security owner'
|
|
}
|
|
}
|
|
|
|
function Assert-ClosedObject(
|
|
[object]$Schema,
|
|
[string]$Name,
|
|
[string[]]$Fields,
|
|
[string[]]$Required,
|
|
[string[]]$AllowedExtensions = @()
|
|
) {
|
|
if (-not $Schema) { return }
|
|
$propertiesProperty = Get-ExactProperty $Schema 'properties'
|
|
$requiredProperty = Get-ExactProperty $Schema 'required'
|
|
$additionalProperty = Get-ExactProperty $Schema 'additionalProperties'
|
|
$actualFields = if ($propertiesProperty) { @($propertiesProperty.Value.PSObject.Properties.Name | Sort-Object) } else { @() }
|
|
$actualRequired = if ($requiredProperty -and (Test-IsJsonArray $requiredProperty.Value)) { @($requiredProperty.Value | Sort-Object) } else { @() }
|
|
$expectedFields = @($Fields | Sort-Object)
|
|
$expectedRequired = @($Required | Sort-Object)
|
|
if ([string]$Schema.type -cne 'object' -or
|
|
-not (Test-IsNonNullable $Schema) -or
|
|
-not $additionalProperty -or
|
|
-not (Test-IsJsonBoolean $additionalProperty.Value $false) -or
|
|
($actualFields -join ',') -cne ($expectedFields -join ',') -or
|
|
($actualRequired -join ',') -cne ($expectedRequired -join ',')) {
|
|
Add-Issue "$Name must be closed with fields [$($expectedFields -join ',')] and required [$($expectedRequired -join ',')]"
|
|
}
|
|
Assert-NoConflictingSchemaKeywords $Schema $Name
|
|
Assert-AllowedSchemaKeywords $Schema $Name @('type', 'properties', 'required', 'additionalProperties', 'nullable') $AllowedExtensions
|
|
}
|
|
|
|
function Assert-PropertyRef([object]$Schema, [string]$SchemaName, [string]$Field, [string]$ExpectedRef) {
|
|
if (-not $Schema) { return }
|
|
$properties = Get-ExactProperty $Schema 'properties'
|
|
$property = if ($properties) { Get-ExactProperty $properties.Value $Field } else { $null }
|
|
if (-not $property) {
|
|
Add-Issue "$SchemaName.$Field must use $ExpectedRef"
|
|
return
|
|
}
|
|
[void](Test-IsPureSchemaRef $property.Value $ExpectedRef "$SchemaName.$Field")
|
|
}
|
|
|
|
function Assert-StringOwner(
|
|
[string]$Name,
|
|
[int]$MinLength,
|
|
[int]$MaxLength,
|
|
[string]$Pattern = '',
|
|
[string[]]$AllowedExtensions = @()
|
|
) {
|
|
$schema = Get-Schema $Name
|
|
if (-not $schema) { return }
|
|
if ([string]$schema.type -cne 'string' -or
|
|
-not (Test-IsNonNullable $schema) -or
|
|
[int]$schema.minLength -ne $MinLength -or
|
|
[int]$schema.maxLength -ne $MaxLength -or
|
|
($Pattern -and [string]$schema.pattern -cne $Pattern)) {
|
|
Add-Issue "$Name must be a non-null string length $MinLength..$MaxLength$(if ($Pattern) { ' with its exact pattern' } else { '' })"
|
|
}
|
|
Assert-NoConflictingSchemaKeywords $schema $Name
|
|
$keywords = @('type', 'minLength', 'maxLength', 'nullable')
|
|
if ($Pattern) { $keywords += 'pattern' }
|
|
Assert-AllowedSchemaKeywords $schema $Name $keywords $AllowedExtensions
|
|
return $schema
|
|
}
|
|
|
|
function Assert-DateTimeOwner([string]$Name) {
|
|
$schema = Get-Schema $Name
|
|
if (-not $schema) { return }
|
|
if ([string]$schema.type -cne 'string' -or
|
|
[string]$schema.format -cne 'date-time' -or
|
|
-not (Test-IsNonNullable $schema) -or
|
|
-not (Test-IsJsonBoolean $schema.'x-server-generated' $true) -or
|
|
-not (Test-IsJsonBoolean $schema.'x-immutable' $true)) {
|
|
Add-Issue "$Name must be a non-null immutable server-generated RFC3339 date-time"
|
|
}
|
|
Assert-NoConflictingSchemaKeywords $schema $Name
|
|
Assert-AllowedSchemaKeywords $schema $Name @('type', 'format', 'nullable') @('x-server-generated', 'x-immutable')
|
|
}
|
|
|
|
function Assert-SuccessEnvelope([string]$Name, [string]$DataRef) {
|
|
$schema = Get-Schema $Name
|
|
Assert-ClosedObject $schema $Name @('code', 'data') @('code', 'data')
|
|
if (-not $schema) { return }
|
|
$code = $schema.properties.code
|
|
if ([string]$code.type -cne 'integer' -or
|
|
-not (Test-IsNonNullable $code) -or
|
|
-not (Test-IsJsonArray $code.enum) -or
|
|
@($code.enum).Count -ne 1 -or
|
|
[int]$code.enum[0] -ne 200) {
|
|
Add-Issue "$Name.code must be the single non-null integer value 200"
|
|
}
|
|
Assert-NoConflictingSchemaKeywords $code "$Name.code" @('enum')
|
|
Assert-AllowedSchemaKeywords $code "$Name.code" @('type', 'enum', 'nullable')
|
|
Assert-PropertyRef $schema $Name 'data' $DataRef
|
|
}
|
|
|
|
function Assert-FixedError([string]$Name, [int]$Status, [string[]]$BusinessCodes) {
|
|
$schema = Get-Schema $Name
|
|
Assert-ClosedObject $schema $Name @('businessCode', 'code', 'message') @('businessCode', 'code', 'message')
|
|
if (-not $schema) { return }
|
|
$code = $schema.properties.code
|
|
$businessCode = $schema.properties.businessCode
|
|
$message = $schema.properties.message
|
|
if ([string]$code.type -cne 'integer' -or
|
|
-not (Test-IsNonNullable $code) -or
|
|
-not (Test-IsJsonArray $code.enum) -or
|
|
@($code.enum).Count -ne 1 -or
|
|
[int]$code.enum[0] -ne $Status) {
|
|
Add-Issue "$Name.code must be the single non-null integer value $Status"
|
|
}
|
|
$actualCodes = @($businessCode.enum | Sort-Object)
|
|
$expectedCodes = @($BusinessCodes | Sort-Object)
|
|
if ([string]$businessCode.type -cne 'string' -or
|
|
-not (Test-IsNonNullable $businessCode) -or
|
|
-not (Test-IsJsonArray $businessCode.enum) -or
|
|
($actualCodes -join ',') -cne ($expectedCodes -join ',')) {
|
|
Add-Issue "$Name.businessCode must be exactly [$($expectedCodes -join ',')]"
|
|
}
|
|
if ([string]$message.type -cne 'string' -or
|
|
-not (Test-IsNonNullable $message) -or
|
|
[int]$message.minLength -ne 1 -or
|
|
[int]$message.maxLength -ne 200) {
|
|
Add-Issue "$Name.message must be a non-null string length 1..200"
|
|
}
|
|
Assert-NoConflictingSchemaKeywords $code "$Name.code" @('enum')
|
|
Assert-NoConflictingSchemaKeywords $businessCode "$Name.businessCode" @('enum')
|
|
Assert-NoConflictingSchemaKeywords $message "$Name.message"
|
|
Assert-AllowedSchemaKeywords $code "$Name.code" @('type', 'enum', 'nullable')
|
|
Assert-AllowedSchemaKeywords $businessCode "$Name.businessCode" @('type', 'enum', 'nullable')
|
|
Assert-AllowedSchemaKeywords $message "$Name.message" @('type', 'minLength', 'maxLength', 'nullable')
|
|
}
|
|
|
|
function Assert-CursorPage(
|
|
[string]$Name,
|
|
[string]$ItemRef,
|
|
[string]$CursorRef,
|
|
[string[]]$Order
|
|
) {
|
|
$schema = Get-Schema $Name
|
|
Assert-ClosedObject $schema $Name @('items', 'nextCursor') @('items') @(
|
|
'x-no-total',
|
|
'x-next-cursor-absent-at-end',
|
|
'x-order',
|
|
'x-read-window',
|
|
'x-new-items-after-window',
|
|
'x-deletion-or-visibility-change',
|
|
'x-edit-policy'
|
|
)
|
|
if (-not $schema) { return }
|
|
$items = $schema.properties.items
|
|
if ([string]$items.type -cne 'array' -or
|
|
-not (Test-IsNonNullable $items) -or
|
|
[int]$items.minItems -ne 0 -or
|
|
[int]$items.maxItems -ne 50) {
|
|
Add-Issue "$Name.items must be a non-null array with 0..50 rows"
|
|
}
|
|
if ($items) {
|
|
[void](Test-IsPureSchemaRef $items.items $ItemRef "$Name.items.items")
|
|
Assert-NoConflictingSchemaKeywords $items "$Name.items"
|
|
Assert-AllowedSchemaKeywords $items "$Name.items" @('type', 'items', 'minItems', 'maxItems', 'nullable')
|
|
}
|
|
Assert-PropertyRef $schema $Name 'nextCursor' $CursorRef
|
|
$noTotal = Get-ExactProperty $schema 'x-no-total'
|
|
$absentAtEnd = Get-ExactProperty $schema 'x-next-cursor-absent-at-end'
|
|
$orderProperty = Get-ExactProperty $schema 'x-order'
|
|
$readWindow = Get-ExactProperty $schema 'x-read-window'
|
|
$newItems = Get-ExactProperty $schema 'x-new-items-after-window'
|
|
$deletion = Get-ExactProperty $schema 'x-deletion-or-visibility-change'
|
|
$editPolicy = Get-ExactProperty $schema 'x-edit-policy'
|
|
if (-not $noTotal -or -not (Test-IsJsonBoolean $noTotal.Value $true) -or
|
|
-not $absentAtEnd -or -not (Test-IsJsonBoolean $absentAtEnd.Value $true) -or
|
|
-not $readWindow -or [string]$readWindow.Value -cne 'UPPER_BOUND_KEYSET_LATEST_VISIBLE' -or
|
|
-not $newItems -or [string]$newItems.Value -cne 'EXCLUDED_UNTIL_REFRESH' -or
|
|
-not $deletion -or [string]$deletion.Value -cne 'OMIT_ON_LATER_PAGE' -or
|
|
-not $editPolicy -or [string]$editPolicy.Value -cne 'LATEST_VISIBLE_AT_PAGE_READ') {
|
|
Add-Issue "$Name cursor window/order/no-total semantics drifted"
|
|
}
|
|
Assert-ExactArray $(if ($orderProperty) { $orderProperty.Value } else { $null }) $Order "$Name x-order"
|
|
}
|
|
|
|
function Assert-NoForbiddenSuccessFields([string[]]$RootRefs) {
|
|
$queue = New-Object System.Collections.Generic.Queue[string]
|
|
$visited = New-Object System.Collections.Generic.HashSet[string] ([System.StringComparer]::Ordinal)
|
|
foreach ($rootRef in $RootRefs) {
|
|
$name = Get-LocalComponentName $rootRef 'schemas' 'success graph root'
|
|
if ($name) { $queue.Enqueue($name) }
|
|
}
|
|
$forbidden = '(?i)(phone|mobile|tenant|userId|appUser|publisherUser|audit|moderation|reviewer|operator|remark|createBy|updateBy|deleteFlag|userDeleted|parentComment|commentLevel|status|mediaOssId)'
|
|
while ($queue.Count -gt 0) {
|
|
$name = $queue.Dequeue()
|
|
if (-not $visited.Add($name)) { continue }
|
|
$schema = Get-Schema $name
|
|
if (-not $schema) { continue }
|
|
$properties = Get-ExactProperty $schema 'properties'
|
|
if ($properties) {
|
|
foreach ($property in @($properties.Value.PSObject.Properties)) {
|
|
if ($property.Name -match $forbidden) {
|
|
Add-Issue "success graph leaks forbidden/internal field: $name.$($property.Name)"
|
|
}
|
|
$propertyRef = Get-ExactProperty $property.Value '$ref'
|
|
if ($propertyRef) {
|
|
$child = Get-LocalComponentName ([string]$propertyRef.Value) 'schemas' "$name.$($property.Name)"
|
|
if ($child) { $queue.Enqueue($child) }
|
|
}
|
|
$itemsProperty = Get-ExactProperty $property.Value 'items'
|
|
$itemsRef = if ($itemsProperty) { Get-ExactProperty $itemsProperty.Value '$ref' } else { $null }
|
|
if ($itemsRef) {
|
|
$child = Get-LocalComponentName ([string]$itemsRef.Value) 'schemas' "$name.$($property.Name).items"
|
|
if ($child) { $queue.Enqueue($child) }
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
function Test-SchemaNodeExposesFamilyFeedRead(
|
|
[object]$Schema,
|
|
[System.Collections.Generic.HashSet[string]]$VisitedSchemas,
|
|
[string]$Label
|
|
) {
|
|
if (-not $Schema) { return $false }
|
|
$ownedNames = @(
|
|
'RAppFamilyFeedCursorPage',
|
|
'RAppFamilyFeedReadItem',
|
|
'RAppFamilyFeedRootCommentCursorPage',
|
|
'AppFamilyFeedCursorPage',
|
|
'AppFamilyFeedReadItem',
|
|
'AppFamilyFeedRootCommentCursorPage',
|
|
'AppFamilyFeedRootCommentReadItem'
|
|
)
|
|
|
|
$refProperty = Get-ExactProperty $Schema '$ref'
|
|
$refCaseVariants = @($Schema.PSObject.Properties | Where-Object { $_.Name -ieq '$ref' })
|
|
if (-not $refProperty -and $refCaseVariants.Count -gt 0) {
|
|
Add-Issue "$Label schema ref keyword casing is invalid"
|
|
return $false
|
|
}
|
|
if ($refProperty) {
|
|
$match = [regex]::Match(
|
|
[string]$refProperty.Value,
|
|
'^#/components/schemas/(?<name>[^/]+)$'
|
|
)
|
|
if (-not $match.Success) {
|
|
Add-Issue "$Label schema ref is not an inspectable exact local component ref"
|
|
return $false
|
|
}
|
|
$name = $match.Groups['name'].Value
|
|
if (Test-ContainsExactString $ownedNames $name) { return $true }
|
|
if (-not $VisitedSchemas.Add($name)) { return $false }
|
|
$components = Get-ExactProperty $document 'components'
|
|
$schemas = if ($components) { Get-ExactProperty $components.Value 'schemas' } else { $null }
|
|
$owner = if ($schemas) { Get-ExactProperty $schemas.Value $name } else { $null }
|
|
if (-not $owner) {
|
|
Add-Issue "$Label references a missing schema owner: $name"
|
|
return $false
|
|
}
|
|
return Test-SchemaNodeExposesFamilyFeedRead $owner.Value $VisitedSchemas "$Label -> $name"
|
|
}
|
|
|
|
$properties = Get-ExactProperty $Schema 'properties'
|
|
if ($properties) {
|
|
$fieldNames = @($properties.Value.PSObject.Properties.Name | Sort-Object)
|
|
$feedProjectionFields = @(
|
|
@('authorDisplayName', 'feedContent', 'feedId', 'hasMedia', 'publishedAt') |
|
|
Sort-Object
|
|
)
|
|
$commentProjectionFields = @(
|
|
@('authorDisplayName', 'commentContent', 'commentId', 'publishedAt') |
|
|
Sort-Object
|
|
)
|
|
$isFeedProjection =
|
|
($fieldNames -join ',') -ceq ($feedProjectionFields -join ',')
|
|
$isCommentProjection =
|
|
($fieldNames -join ',') -ceq ($commentProjectionFields -join ',')
|
|
if ($isFeedProjection -or $isCommentProjection) { return $true }
|
|
foreach ($property in @($properties.Value.PSObject.Properties)) {
|
|
if (Test-SchemaNodeExposesFamilyFeedRead $property.Value $VisitedSchemas "$Label.$($property.Name)") {
|
|
return $true
|
|
}
|
|
}
|
|
}
|
|
$items = Get-ExactProperty $Schema 'items'
|
|
if ($items -and
|
|
(Test-SchemaNodeExposesFamilyFeedRead $items.Value $VisitedSchemas "$Label.items")) {
|
|
return $true
|
|
}
|
|
$additionalProperties = Get-ExactProperty $Schema 'additionalProperties'
|
|
if ($additionalProperties -and $additionalProperties.Value -isnot [System.Boolean] -and
|
|
(Test-SchemaNodeExposesFamilyFeedRead $additionalProperties.Value $VisitedSchemas "$Label.additionalProperties")) {
|
|
return $true
|
|
}
|
|
foreach ($compositionName in @('allOf', 'oneOf', 'anyOf')) {
|
|
$composition = Get-ExactProperty $Schema $compositionName
|
|
if (-not $composition -or -not (Test-IsJsonArray $composition.Value)) { continue }
|
|
foreach ($branch in @($composition.Value)) {
|
|
if (Test-SchemaNodeExposesFamilyFeedRead $branch $VisitedSchemas "$Label.$compositionName") {
|
|
return $true
|
|
}
|
|
}
|
|
}
|
|
return $false
|
|
}
|
|
|
|
function Test-ResponseNodeExposesFamilyFeedRead(
|
|
[object]$Response,
|
|
[System.Collections.Generic.HashSet[string]]$VisitedResponses,
|
|
[System.Collections.Generic.HashSet[string]]$VisitedSchemas,
|
|
[string]$Label
|
|
) {
|
|
if (-not $Response) { return $false }
|
|
$refProperty = Get-ExactProperty $Response '$ref'
|
|
$refCaseVariants = @($Response.PSObject.Properties | Where-Object { $_.Name -ieq '$ref' })
|
|
if (-not $refProperty -and $refCaseVariants.Count -gt 0) {
|
|
Add-Issue "$Label response ref keyword casing is invalid"
|
|
return $false
|
|
}
|
|
if ($refProperty) {
|
|
$match = [regex]::Match(
|
|
[string]$refProperty.Value,
|
|
'^#/components/responses/(?<name>[^/]+)$'
|
|
)
|
|
if (-not $match.Success) {
|
|
Add-Issue "$Label response ref is not an inspectable exact local component ref"
|
|
return $false
|
|
}
|
|
$name = $match.Groups['name'].Value
|
|
if (-not $VisitedResponses.Add($name)) { return $false }
|
|
$components = Get-ExactProperty $document 'components'
|
|
$responses = if ($components) { Get-ExactProperty $components.Value 'responses' } else { $null }
|
|
$owner = if ($responses) { Get-ExactProperty $responses.Value $name } else { $null }
|
|
if (-not $owner) {
|
|
Add-Issue "$Label references a missing response owner: $name"
|
|
return $false
|
|
}
|
|
return Test-ResponseNodeExposesFamilyFeedRead $owner.Value $VisitedResponses $VisitedSchemas "$Label -> $name"
|
|
}
|
|
|
|
$content = Get-ExactProperty $Response 'content'
|
|
$contentCaseVariants = @($Response.PSObject.Properties | Where-Object { $_.Name -ieq 'content' })
|
|
if (-not $content -and $contentCaseVariants.Count -gt 0) {
|
|
Add-Issue "$Label Response Object content keyword casing is invalid"
|
|
return $false
|
|
}
|
|
if (-not $content) { return $false }
|
|
foreach ($media in @($content.Value.PSObject.Properties)) {
|
|
$schema = Get-ExactProperty $media.Value 'schema'
|
|
$schemaCaseVariants = @($media.Value.PSObject.Properties | Where-Object { $_.Name -ieq 'schema' })
|
|
if (-not $schema -and $schemaCaseVariants.Count -gt 0) {
|
|
Add-Issue "$Label media schema keyword casing is invalid"
|
|
continue
|
|
}
|
|
if ($schema -and
|
|
(Test-SchemaNodeExposesFamilyFeedRead $schema.Value $VisitedSchemas "$Label $($media.Name)")) {
|
|
return $true
|
|
}
|
|
}
|
|
return $false
|
|
}
|
|
|
|
function Scan-AppCallbackObject(
|
|
[object]$Callback,
|
|
[string]$Context,
|
|
[System.Collections.Generic.HashSet[string]]$VisitedCallbacks
|
|
) {
|
|
if (-not $Callback) { return }
|
|
$refProperty = Get-ExactProperty $Callback '$ref'
|
|
if ($refProperty) {
|
|
$keys = @($Callback.PSObject.Properties.Name)
|
|
if ($keys.Count -ne 1 -or $keys[0] -cne '$ref') {
|
|
Add-Issue "$Context callback ref must contain only its exact local `$ref under OpenAPI 3.0.1"
|
|
}
|
|
$match = [regex]::Match(
|
|
[string]$refProperty.Value,
|
|
'^#/components/callbacks/(?<name>[^/]+)$'
|
|
)
|
|
if (-not $match.Success) {
|
|
Add-Issue "$Context callback ref is not an inspectable exact local component ref"
|
|
return
|
|
}
|
|
$name = $match.Groups['name'].Value
|
|
if (-not $VisitedCallbacks.Add($name)) { return }
|
|
$components = Get-ExactProperty $document 'components'
|
|
$callbacks = if ($components) { Get-ExactProperty $components.Value 'callbacks' } else { $null }
|
|
$owner = if ($callbacks) { Get-ExactProperty $callbacks.Value $name } else { $null }
|
|
if (-not $owner) {
|
|
Add-Issue "$Context references a missing callback owner: $name"
|
|
return
|
|
}
|
|
Scan-AppCallbackObject $owner.Value "$Context -> $name" $VisitedCallbacks
|
|
return
|
|
}
|
|
foreach ($expression in @($Callback.PSObject.Properties)) {
|
|
Scan-AppPathItem $expression.Value "$Context callback $($expression.Name)" '' $false $VisitedCallbacks
|
|
}
|
|
}
|
|
|
|
function Scan-AppPathItem(
|
|
[object]$PathItem,
|
|
[string]$Context,
|
|
[string]$TopLevelPath,
|
|
[bool]$IsTopLevel,
|
|
[System.Collections.Generic.HashSet[string]]$VisitedCallbacks
|
|
) {
|
|
if (-not $PathItem) { return }
|
|
foreach ($method in @('get', 'put', 'post', 'delete', 'options', 'head', 'patch', 'trace')) {
|
|
$operationProperty = Get-ExactProperty $PathItem $method
|
|
if (-not $operationProperty) { continue }
|
|
$operation = $operationProperty.Value
|
|
$responses = Get-ExactProperty $operation 'responses'
|
|
if ($responses) {
|
|
foreach ($responseProperty in @($responses.Value.PSObject.Properties)) {
|
|
if ($responseProperty.Name -notmatch '^(2[0-9][0-9]|default)$') { continue }
|
|
$visitedResponses = [System.Collections.Generic.HashSet[string]]::new(
|
|
[System.StringComparer]::Ordinal
|
|
)
|
|
$visitedSchemas = [System.Collections.Generic.HashSet[string]]::new(
|
|
[System.StringComparer]::Ordinal
|
|
)
|
|
$exposes = Test-ResponseNodeExposesFamilyFeedRead `
|
|
$responseProperty.Value `
|
|
$visitedResponses `
|
|
$visitedSchemas `
|
|
"$Context $($method.ToUpperInvariant()) $($responseProperty.Name)"
|
|
if (-not $exposes) { continue }
|
|
$isCanonicalOwner =
|
|
$IsTopLevel -and
|
|
$method -ceq 'get' -and
|
|
$responseProperty.Name -ceq '200' -and
|
|
(Test-ContainsExactString @($feedListPath, $feedDetailPath, $rootCommentPath) $TopLevelPath)
|
|
if (-not $isCanonicalOwner) {
|
|
Add-Issue "alternate APP operation exposes the family-feed read projection: $Context $($method.ToUpperInvariant()) $($responseProperty.Name)"
|
|
}
|
|
}
|
|
}
|
|
$callbacks = Get-ExactProperty $operation 'callbacks'
|
|
if ($callbacks) {
|
|
foreach ($callbackProperty in @($callbacks.Value.PSObject.Properties)) {
|
|
Scan-AppCallbackObject $callbackProperty.Value "$Context $($method.ToUpperInvariant()) callback $($callbackProperty.Name)" $VisitedCallbacks
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
if (-not $SkipProtectedParity) {
|
|
$parityScript = Join-Path $PSScriptRoot 'openapi-yaml-json-parity-runtime-smoke.js'
|
|
$parityOutput = @(& node $parityScript 2>&1)
|
|
if ($LASTEXITCODE -ne 0 -or -not (Test-ContainsExactString $parityOutput 'OPENAPI-YAML-JSON-PARITY PASS')) {
|
|
Add-Issue "protected JSON/YAML semantic parity failed: $($parityOutput -join ' | ')"
|
|
}
|
|
}
|
|
|
|
Assert-CanonicalKeyCasing $document @(
|
|
'openapi', 'info', 'servers', 'paths',
|
|
'components', 'security', 'tags', 'externalDocs'
|
|
) 'OpenAPI root'
|
|
$openapiProperty = Get-ExactProperty $document 'openapi'
|
|
if (-not $openapiProperty -or [string]$openapiProperty.Value -cne '3.0.1') {
|
|
Add-Issue 'OpenAPI version must be exact 3.0.1'
|
|
}
|
|
$pathsProperty = Get-ExactProperty $document 'paths'
|
|
if (-not $pathsProperty) {
|
|
Add-Issue 'OpenAPI root must define exact paths'
|
|
}
|
|
$componentsProperty = Get-ExactProperty $document 'components'
|
|
if (-not $componentsProperty) {
|
|
Add-Issue 'OpenAPI root must define exact components'
|
|
}
|
|
Assert-CanonicalKeyCasing $(if ($componentsProperty) { $componentsProperty.Value } else { $null }) @(
|
|
'schemas', 'responses', 'parameters', 'examples', 'requestBodies', 'headers',
|
|
'securitySchemes', 'links', 'callbacks'
|
|
) 'OpenAPI components'
|
|
Assert-SaTokenScheme
|
|
|
|
foreach ($legacyPath in @($legacyFeedPagePath, $legacyCommentPagePath)) {
|
|
$pathProperty = Get-ExactProperty $document.paths $legacyPath
|
|
if (-not $pathProperty) { continue }
|
|
$legacyGets = @($pathProperty.Value.PSObject.Properties | Where-Object { $_.Name -ieq 'get' })
|
|
if ($legacyGets.Count -gt 0) {
|
|
Add-Issue "legacy duplicate GET owner must be removed: $legacyPath"
|
|
}
|
|
}
|
|
|
|
$resolvedOperations = @{}
|
|
foreach ($spec in $operations) {
|
|
$operation = Get-Operation $spec.Path $spec.Method
|
|
if (-not $operation) { continue }
|
|
$resolvedOperations[$spec.Id] = $operation
|
|
|
|
$operationIdProperty = Get-ExactProperty $operation 'operationId'
|
|
if (-not $operationIdProperty -or [string]$operationIdProperty.Value -cne $spec.Id) {
|
|
Add-Issue "$($spec.Method.ToUpperInvariant()) $($spec.Path) operationId must be $($spec.Id)"
|
|
}
|
|
$extensionNames = @(
|
|
'x-read-only',
|
|
'x-non-disclosing-not-found',
|
|
'x-authorize-every-request',
|
|
'x-cors-policy-owner',
|
|
'x-authorization-scope'
|
|
)
|
|
if ($spec.CursorRef) {
|
|
$extensionNames += @(
|
|
'x-cursor-scope',
|
|
'x-cursor-order',
|
|
'x-read-window',
|
|
'x-cursor-no-total',
|
|
'x-refresh-discards-cursor',
|
|
'x-authorize-every-page',
|
|
'x-invalid-or-expired-cursor',
|
|
'x-cross-scope-cursor'
|
|
)
|
|
}
|
|
Assert-AllowedOperationKeys $operation $extensionNames $spec.Id
|
|
$requestBodyProperty = Get-ExactProperty $operation 'requestBody'
|
|
if ($requestBodyProperty) {
|
|
Add-Issue "$($spec.Id) is read-only and must not define requestBody"
|
|
}
|
|
if (Get-ExactProperty $operation 'callbacks') {
|
|
Add-Issue "$($spec.Id) is read-only and must not define callbacks"
|
|
}
|
|
$pathItem = (Get-ExactProperty $document.paths $spec.Path).Value
|
|
foreach ($forbiddenMethod in @('head', 'options', 'trace')) {
|
|
if (Get-ExactProperty $pathItem $forbiddenMethod) {
|
|
Add-Issue "$($spec.Path) must not expose an explicit $($forbiddenMethod.ToUpperInvariant()) read bypass"
|
|
}
|
|
}
|
|
Assert-SaToken $operation $spec.Id
|
|
Assert-ExactResponseSet $operation $spec.Id
|
|
|
|
$parameters = @(Get-OperationParameters $spec.Path $operation $spec.Id)
|
|
Assert-ExactParameters $parameters $spec.Parameters $spec.Id
|
|
|
|
$clientid = Get-Parameter $parameters 'header' 'clientid' $spec.Id
|
|
Assert-ParameterRequired $clientid $true "$($spec.Id) clientid"
|
|
if ($clientid) {
|
|
$clientSchemaProperty = Get-ExactProperty $clientid 'schema'
|
|
$clientSchema = if ($clientSchemaProperty) { $clientSchemaProperty.Value } else { $null }
|
|
if ([string]$clientSchema.type -cne 'string' -or
|
|
-not (Test-IsNonNullable $clientSchema) -or
|
|
[int]$clientSchema.minLength -ne 1 -or
|
|
[int]$clientSchema.maxLength -ne 128) {
|
|
Add-Issue "$($spec.Id) clientid must be a required non-null string length 1..128"
|
|
}
|
|
Assert-NoConflictingSchemaKeywords $clientSchema "$($spec.Id) clientid"
|
|
Assert-AllowedSchemaKeywords $clientSchema "$($spec.Id) clientid" @('type', 'minLength', 'maxLength', 'nullable')
|
|
}
|
|
|
|
$genealogyId = Get-Parameter $parameters 'path' 'genealogyId' $spec.Id
|
|
Assert-ParameterRequired $genealogyId $true "$($spec.Id) genealogyId"
|
|
if ($genealogyId) {
|
|
$schemaProperty = Get-ExactProperty $genealogyId 'schema'
|
|
[void](Test-IsPureSchemaRef $(if ($schemaProperty) { $schemaProperty.Value } else { $null }) '#/components/schemas/GenealogyId' "$($spec.Id) genealogyId")
|
|
}
|
|
|
|
if ($spec.Path -match '\{feedId\}') {
|
|
$feedId = Get-Parameter $parameters 'path' 'feedId' $spec.Id
|
|
Assert-ParameterRequired $feedId $true "$($spec.Id) feedId"
|
|
if ($feedId) {
|
|
$schemaProperty = Get-ExactProperty $feedId 'schema'
|
|
[void](Test-IsPureSchemaRef $(if ($schemaProperty) { $schemaProperty.Value } else { $null }) '#/components/schemas/FamilyFeedId' "$($spec.Id) feedId")
|
|
}
|
|
}
|
|
|
|
if ($spec.CursorRef) {
|
|
$cursor = Get-Parameter $parameters 'query' 'cursor' $spec.Id
|
|
$limit = Get-Parameter $parameters 'query' 'limit' $spec.Id
|
|
Assert-ParameterRequired $cursor $false "$($spec.Id) cursor"
|
|
Assert-ParameterRequired $limit $false "$($spec.Id) limit"
|
|
if ($cursor) {
|
|
$schemaProperty = Get-ExactProperty $cursor 'schema'
|
|
[void](Test-IsPureSchemaRef $(if ($schemaProperty) { $schemaProperty.Value } else { $null }) $spec.CursorRef "$($spec.Id) cursor")
|
|
}
|
|
if ($limit) {
|
|
$limitSchemaProperty = Get-ExactProperty $limit 'schema'
|
|
$limitSchema = if ($limitSchemaProperty) { $limitSchemaProperty.Value } else { $null }
|
|
if ([string]$limitSchema.type -cne 'integer' -or
|
|
-not (Test-IsNonNullable $limitSchema) -or
|
|
[int]$limitSchema.minimum -ne 1 -or
|
|
[int]$limitSchema.maximum -ne 50 -or
|
|
[int]$limitSchema.default -ne 20) {
|
|
Add-Issue "$($spec.Id) limit must be an optional non-null integer default 20 in 1..50"
|
|
}
|
|
Assert-NoConflictingSchemaKeywords $limitSchema "$($spec.Id) limit"
|
|
Assert-AllowedSchemaKeywords $limitSchema "$($spec.Id) limit" @('type', 'minimum', 'maximum', 'default', 'nullable')
|
|
}
|
|
$cursorScope = Get-ExactProperty $operation 'x-cursor-scope'
|
|
$cursorOrder = Get-ExactProperty $operation 'x-cursor-order'
|
|
$readWindow = Get-ExactProperty $operation 'x-read-window'
|
|
$cursorNoTotal = Get-ExactProperty $operation 'x-cursor-no-total'
|
|
$refreshDiscards = Get-ExactProperty $operation 'x-refresh-discards-cursor'
|
|
$authorizeEveryPage = Get-ExactProperty $operation 'x-authorize-every-page'
|
|
$invalidCursor = Get-ExactProperty $operation 'x-invalid-or-expired-cursor'
|
|
$crossScopeCursor = Get-ExactProperty $operation 'x-cross-scope-cursor'
|
|
Assert-ExactArray $(if ($cursorScope) { $cursorScope.Value } else { $null }) $spec.CursorScope "$($spec.Id) x-cursor-scope"
|
|
Assert-ExactArray $(if ($cursorOrder) { $cursorOrder.Value } else { $null }) $spec.CursorOrder "$($spec.Id) x-cursor-order"
|
|
if (-not $readWindow -or [string]$readWindow.Value -cne 'UPPER_BOUND_KEYSET_LATEST_VISIBLE' -or
|
|
-not $cursorNoTotal -or -not (Test-IsJsonBoolean $cursorNoTotal.Value $true) -or
|
|
-not $refreshDiscards -or -not (Test-IsJsonBoolean $refreshDiscards.Value $true) -or
|
|
-not $authorizeEveryPage -or -not (Test-IsJsonBoolean $authorizeEveryPage.Value $true) -or
|
|
-not $invalidCursor -or [string]$invalidCursor.Value -cne '400_FAMILY_FEED_CURSOR_INVALID' -or
|
|
-not $crossScopeCursor -or [string]$crossScopeCursor.Value -cne '404_FAMILY_FEED_NOT_AVAILABLE') {
|
|
Add-Issue "$($spec.Id) cursor/refresh/per-page authorization semantics drifted"
|
|
}
|
|
}
|
|
|
|
$readOnly = Get-ExactProperty $operation 'x-read-only'
|
|
$nonDisclosing = Get-ExactProperty $operation 'x-non-disclosing-not-found'
|
|
$authorizeEveryRequest = Get-ExactProperty $operation 'x-authorize-every-request'
|
|
$corsOwner = Get-ExactProperty $operation 'x-cors-policy-owner'
|
|
if (-not $readOnly -or -not (Test-IsJsonBoolean $readOnly.Value $true) -or
|
|
-not $nonDisclosing -or -not (Test-IsJsonBoolean $nonDisclosing.Value $true) -or
|
|
-not $authorizeEveryRequest -or -not (Test-IsJsonBoolean $authorizeEveryRequest.Value $true) -or
|
|
-not $corsOwner -or [string]$corsOwner.Value -cne 'APP_GATEWAY_PREFLIGHT') {
|
|
Add-Issue "$($spec.Id) read-only/non-disclosing/auth/CORS extensions drifted"
|
|
}
|
|
$expectedAuthorization = if ($spec.Id -ceq 'appListFamilyFeeds') {
|
|
@('tenant', 'genealogy', 'membership')
|
|
} else {
|
|
@('tenant', 'genealogy', 'membership', 'feedBelongsToGenealogy', 'feedVisibility')
|
|
}
|
|
$authorizationScope = Get-ExactProperty $operation 'x-authorization-scope'
|
|
Assert-ExactArray $(if ($authorizationScope) { $authorizationScope.Value } else { $null }) $expectedAuthorization "$($spec.Id) x-authorization-scope"
|
|
|
|
foreach ($status in @('200', '400', '401', '404', '429', '500')) {
|
|
$response = Get-Response $operation $status $spec.Id
|
|
if (-not $response) { continue }
|
|
$actualRef = Get-JsonResponseSchemaRef $response "$($spec.Id) $status"
|
|
$expectedRef = if ($status -ceq '200') { $spec.SuccessRef } else { $responseRefs[$status] }
|
|
if ($actualRef -cne $expectedRef) {
|
|
Add-Issue "$($spec.Id) $status schema ref must be $expectedRef; actual: $actualRef"
|
|
}
|
|
Assert-ResponseHeaders $response $status "$($spec.Id) $status"
|
|
}
|
|
}
|
|
|
|
$visitedCallbacks = [System.Collections.Generic.HashSet[string]]::new(
|
|
[System.StringComparer]::Ordinal
|
|
)
|
|
if ($pathsProperty) {
|
|
foreach ($pathProperty in @($pathsProperty.Value.PSObject.Properties)) {
|
|
if (-not $pathProperty.Name.StartsWith('/genealogy/app/', [System.StringComparison]::Ordinal)) {
|
|
continue
|
|
}
|
|
Scan-AppPathItem $pathProperty.Value $pathProperty.Name $pathProperty.Name $true $visitedCallbacks
|
|
}
|
|
}
|
|
|
|
$allOperationIds = @{}
|
|
foreach ($pathProperty in @($document.paths.PSObject.Properties)) {
|
|
foreach ($method in @('get', 'put', 'post', 'delete', 'options', 'head', 'patch', 'trace')) {
|
|
$operationProperty = Get-ExactProperty $pathProperty.Value $method
|
|
if (-not $operationProperty) { continue }
|
|
$operationIdProperty = Get-ExactProperty $operationProperty.Value 'operationId'
|
|
if (-not $operationIdProperty) { continue }
|
|
$id = [string]$operationIdProperty.Value
|
|
if (-not $allOperationIds.ContainsKey($id)) { $allOperationIds[$id] = @() }
|
|
$allOperationIds[$id] += "$($method.ToUpperInvariant()) $($pathProperty.Name)"
|
|
}
|
|
}
|
|
foreach ($spec in $operations) {
|
|
$owners = @($allOperationIds[$spec.Id])
|
|
if ($owners.Count -ne 1 -or $owners[0] -cne "$($spec.Method.ToUpperInvariant()) $($spec.Path)") {
|
|
Add-Issue "$($spec.Id) must have exactly one global operation owner; actual: $($owners -join ' | ')"
|
|
}
|
|
}
|
|
|
|
Assert-StringOwner 'GenealogyId' 1 128 $identifierPattern
|
|
$feedIdOwner = Assert-StringOwner 'FamilyFeedId' 1 128 $identifierPattern @('x-opaque', 'x-client-semantics')
|
|
if ($feedIdOwner -and (-not (Test-IsJsonBoolean $feedIdOwner.'x-opaque' $true) -or [string]$feedIdOwner.'x-client-semantics' -cne 'COMPARE_ONLY')) {
|
|
Add-Issue 'FamilyFeedId must be opaque and used by clients only for exact comparison'
|
|
}
|
|
$commentIdOwner = Assert-StringOwner 'FamilyFeedCommentId' 1 128 $identifierPattern @('x-opaque', 'x-client-semantics')
|
|
if ($commentIdOwner -and (-not (Test-IsJsonBoolean $commentIdOwner.'x-opaque' $true) -or [string]$commentIdOwner.'x-client-semantics' -cne 'COMPARE_ONLY')) {
|
|
Add-Issue 'FamilyFeedCommentId must be opaque and used by clients only for exact comparison'
|
|
}
|
|
$feedCursorOwner = Assert-StringOwner 'FamilyFeedCursor' 1 512 $cursorPattern @('x-opaque', 'x-purpose')
|
|
if ($feedCursorOwner -and (-not (Test-IsJsonBoolean $feedCursorOwner.'x-opaque' $true) -or [string]$feedCursorOwner.'x-purpose' -cne 'FAMILY_FEED_PAGE')) {
|
|
Add-Issue 'FamilyFeedCursor purpose/opacity drifted'
|
|
}
|
|
$commentCursorOwner = Assert-StringOwner 'FamilyFeedRootCommentCursor' 1 512 $cursorPattern @('x-opaque', 'x-purpose')
|
|
if ($commentCursorOwner -and (-not (Test-IsJsonBoolean $commentCursorOwner.'x-opaque' $true) -or [string]$commentCursorOwner.'x-purpose' -cne 'FAMILY_FEED_ROOT_COMMENT_PAGE')) {
|
|
Add-Issue 'FamilyFeedRootCommentCursor purpose/opacity drifted'
|
|
}
|
|
$feedContentOwner = Assert-StringOwner 'FamilyFeedContent' 1 300 '' @('x-text-normalizer', 'x-length-unit')
|
|
if ($feedContentOwner -and ([string]$feedContentOwner.'x-text-normalizer' -cne 'FAMILY_FEED_TEXT_V1' -or [string]$feedContentOwner.'x-length-unit' -cne 'UNICODE_CODE_POINT')) {
|
|
Add-Issue 'FamilyFeedContent normalization/length unit drifted'
|
|
}
|
|
$commentContentOwner = Assert-StringOwner 'FamilyFeedCommentContent' 1 1000 '' @('x-text-normalizer', 'x-length-unit')
|
|
if ($commentContentOwner -and ([string]$commentContentOwner.'x-text-normalizer' -cne 'FAMILY_FEED_COMMENT_TEXT_V1' -or [string]$commentContentOwner.'x-length-unit' -cne 'UNICODE_CODE_POINT')) {
|
|
Add-Issue 'FamilyFeedCommentContent normalization/length unit drifted'
|
|
}
|
|
$displayNameOwner = Assert-StringOwner 'FamilyFeedAuthorDisplayName' 1 100 '' @('x-projection', 'x-missing-author-policy')
|
|
if ($displayNameOwner -and ([string]$displayNameOwner.'x-projection' -cne 'AUTHORIZED_DISPLAY_NAME_ONLY' -or [string]$displayNameOwner.'x-missing-author-policy' -cne 'NON_EMPTY_SERVER_FALLBACK')) {
|
|
Add-Issue 'FamilyFeedAuthorDisplayName projection/fallback drifted'
|
|
}
|
|
Assert-DateTimeOwner 'FamilyFeedPublishedAt'
|
|
|
|
$feedItem = Get-Schema 'AppFamilyFeedReadItem'
|
|
Assert-ClosedObject $feedItem 'AppFamilyFeedReadItem' @(
|
|
'authorDisplayName',
|
|
'feedContent',
|
|
'feedId',
|
|
'hasMedia',
|
|
'publishedAt'
|
|
) @(
|
|
'authorDisplayName',
|
|
'feedContent',
|
|
'feedId',
|
|
'hasMedia',
|
|
'publishedAt'
|
|
) @('x-projection', 'x-media-policy')
|
|
Assert-PropertyRef $feedItem 'AppFamilyFeedReadItem' 'feedId' '#/components/schemas/FamilyFeedId'
|
|
Assert-PropertyRef $feedItem 'AppFamilyFeedReadItem' 'feedContent' '#/components/schemas/FamilyFeedContent'
|
|
Assert-PropertyRef $feedItem 'AppFamilyFeedReadItem' 'authorDisplayName' '#/components/schemas/FamilyFeedAuthorDisplayName'
|
|
Assert-PropertyRef $feedItem 'AppFamilyFeedReadItem' 'publishedAt' '#/components/schemas/FamilyFeedPublishedAt'
|
|
if ($feedItem) {
|
|
$hasMedia = $feedItem.properties.hasMedia
|
|
if ([string]$hasMedia.type -cne 'boolean' -or -not (Test-IsNonNullable $hasMedia)) {
|
|
Add-Issue 'AppFamilyFeedReadItem.hasMedia must be a required non-null boolean'
|
|
}
|
|
Assert-NoConflictingSchemaKeywords $hasMedia 'AppFamilyFeedReadItem.hasMedia'
|
|
Assert-AllowedSchemaKeywords $hasMedia 'AppFamilyFeedReadItem.hasMedia' @('type', 'nullable')
|
|
if ([string]$feedItem.'x-projection' -cne 'VISIBLE_FEED_PRESENTATION_ONLY' -or
|
|
[string]$feedItem.'x-media-policy' -cne 'HAS_MEDIA_REQUIRES_HONEST_CLIENT_PLACEHOLDER_UNTIL_MEDIA_READ_CONTRACT') {
|
|
Add-Issue 'AppFamilyFeedReadItem projection/media policy drifted'
|
|
}
|
|
}
|
|
|
|
$commentItem = Get-Schema 'AppFamilyFeedRootCommentReadItem'
|
|
Assert-ClosedObject $commentItem 'AppFamilyFeedRootCommentReadItem' @(
|
|
'authorDisplayName',
|
|
'commentContent',
|
|
'commentId',
|
|
'publishedAt'
|
|
) @(
|
|
'authorDisplayName',
|
|
'commentContent',
|
|
'commentId',
|
|
'publishedAt'
|
|
) @('x-projection', 'x-comment-level', 'x-deleted-placeholder-policy')
|
|
Assert-PropertyRef $commentItem 'AppFamilyFeedRootCommentReadItem' 'commentId' '#/components/schemas/FamilyFeedCommentId'
|
|
Assert-PropertyRef $commentItem 'AppFamilyFeedRootCommentReadItem' 'commentContent' '#/components/schemas/FamilyFeedCommentContent'
|
|
Assert-PropertyRef $commentItem 'AppFamilyFeedRootCommentReadItem' 'authorDisplayName' '#/components/schemas/FamilyFeedAuthorDisplayName'
|
|
Assert-PropertyRef $commentItem 'AppFamilyFeedRootCommentReadItem' 'publishedAt' '#/components/schemas/FamilyFeedPublishedAt'
|
|
if ($commentItem -and (
|
|
[string]$commentItem.'x-projection' -cne 'VISIBLE_COMMENT_PRESENTATION_ONLY' -or
|
|
[string]$commentItem.'x-comment-level' -cne 'ROOT_ONLY' -or
|
|
[string]$commentItem.'x-deleted-placeholder-policy' -cne 'EXCLUDE')) {
|
|
Add-Issue 'AppFamilyFeedRootCommentReadItem visibility/root/deleted-placeholder policy drifted'
|
|
}
|
|
|
|
Assert-CursorPage 'AppFamilyFeedCursorPage' '#/components/schemas/AppFamilyFeedReadItem' '#/components/schemas/FamilyFeedCursor' @('publishedAt:DESC', 'feedId:DESC_ORDINAL')
|
|
Assert-CursorPage 'AppFamilyFeedRootCommentCursorPage' '#/components/schemas/AppFamilyFeedRootCommentReadItem' '#/components/schemas/FamilyFeedRootCommentCursor' @('publishedAt:ASC', 'commentId:ASC_ORDINAL')
|
|
|
|
Assert-SuccessEnvelope 'RAppFamilyFeedCursorPage' '#/components/schemas/AppFamilyFeedCursorPage'
|
|
Assert-SuccessEnvelope 'RAppFamilyFeedReadItem' '#/components/schemas/AppFamilyFeedReadItem'
|
|
Assert-SuccessEnvelope 'RAppFamilyFeedRootCommentCursorPage' '#/components/schemas/AppFamilyFeedRootCommentCursorPage'
|
|
|
|
Assert-FixedError 'RFamilyFeedReadBadRequest' 400 @('FAMILY_FEED_CURSOR_INVALID', 'FAMILY_FEED_QUERY_INVALID')
|
|
Assert-FixedError 'RFamilyFeedReadUnauthorized' 401 @('AUTH_REQUIRED')
|
|
Assert-FixedError 'RFamilyFeedReadNotFound' 404 @('FAMILY_FEED_NOT_AVAILABLE')
|
|
Assert-FixedError 'RFamilyFeedReadRateLimited' 429 @('RATE_LIMITED')
|
|
Assert-FixedError 'RFamilyFeedReadUnavailable' 500 @('FAMILY_FEED_READ_UNAVAILABLE')
|
|
|
|
Assert-NoForbiddenSuccessFields @(
|
|
'#/components/schemas/RAppFamilyFeedCursorPage',
|
|
'#/components/schemas/RAppFamilyFeedReadItem',
|
|
'#/components/schemas/RAppFamilyFeedRootCommentCursorPage'
|
|
)
|
|
|
|
if ($ReturnIssues) {
|
|
return [string[]]@($issues)
|
|
}
|
|
|
|
if ($issues.Count -gt 0) {
|
|
Write-Output 'FAMILY-FEED-READ-OPENAPI-CONTRACT BLOCKED'
|
|
Write-Output "Issues: $($issues.Count)"
|
|
$issues | ForEach-Object { Write-Output "- $_" }
|
|
Write-Output '- Keep F01/F03 on the explicit genealogy-scoped fixture preview; do not connect the dormant broad getFeeds branch.'
|
|
Write-Output '- After one backend JSON/YAML/live export passes, start with executable production read normalizer/coordinator tests and migrate F01/F03 atomically.'
|
|
exit 1
|
|
}
|
|
|
|
Write-Output 'FAMILY-FEED-READ-OPENAPI-CONTRACT PASS'
|