Files
jiapuapp/tests/genealogy-workspace-openapi-contract.ps1
T
2026-07-23 17:21:33 +08:00

720 lines
32 KiB
PowerShell

$ErrorActionPreference = 'Stop'
$root = Split-Path -Parent $PSScriptRoot
$jsonPath = Join-Path $root 'APP.openapi.json'
$document = Get-Content -Raw -Encoding UTF8 -LiteralPath $jsonPath | ConvertFrom-Json
$issues = New-Object System.Collections.Generic.List[string]
$minePath = '/genealogy/app/genealogies/mine'
$overviewPath = '/genealogy/app/genealogies/{genealogyId}/overview'
$identifierPattern = '^[A-Za-z0-9][A-Za-z0-9._~-]{0,127}$'
$httpMethods = @('get', 'post', 'put', 'patch', 'delete', 'options', 'head', 'trace')
function Add-Issue {
param([string]$Message)
$script:issues.Add($Message)
}
function Test-IsJsonArray {
param([object]$Value)
return $null -ne $Value -and $Value.GetType().IsArray
}
function Test-IsJsonBoolean {
param([object]$Value, [bool]$Expected)
return $Value -is [System.Boolean] -and $Value -eq $Expected
}
function Test-IsNonNullable {
param([object]$Schema)
if (-not $Schema) { return $false }
$nullable = $Schema.PSObject.Properties['nullable']
return -not $nullable -or (Test-IsJsonBoolean $nullable.Value $false)
}
function Assert-NoConflictingSchemaKeywords {
param(
[object]$Schema,
[string]$Label,
[string[]]$Allowed = @()
)
if (-not $Schema) { return }
foreach ($keyword in @('not', 'allOf', 'anyOf', 'oneOf', 'const', 'enum')) {
if ($keyword -notin $Allowed -and $Schema.PSObject.Properties[$keyword]) {
Add-Issue "JSON $Label must not define conflicting schema keyword: $keyword"
}
}
}
function Assert-AllowedSchemaKeywords {
param([object]$Schema, [string]$Label, [string[]]$Allowed)
if (-not $Schema) { return }
$annotations = @('title', 'description', 'example', 'examples', 'deprecated')
foreach ($property in @($Schema.PSObject.Properties)) {
if ($property.Name -like 'x-*' -or $property.Name -in $annotations -or $property.Name -in $Allowed) { continue }
Add-Issue "JSON $Label contains an unowned schema keyword: $($property.Name)"
}
}
function Test-IsPureSchemaRef {
param([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 "JSON $Label must be the sole exact local schema ref $ExpectedRef; actual: $actualRef"
return $false
}
[void](Get-LocalComponentName $actualRef 'schemas' $Label)
return $true
}
function Get-LocalComponentName {
param([string]$Ref, [string]$Section, [string]$Label)
$pattern = '^#/components/' + [regex]::Escape($Section) + '/(?<name>[^/]+)$'
$match = [regex]::Match($Ref, $pattern)
if (-not $match.Success) {
Add-Issue "JSON $Label must use an exact local #/components/$Section/... ref; actual: $Ref"
return ''
}
return $match.Groups['name'].Value
}
function Get-Schema {
param([string]$Name)
$property = $document.components.schemas.PSObject.Properties[$Name]
if (-not $property) {
Add-Issue "JSON missing schema owner: $Name"
return $null
}
return $property.Value
}
function Get-Operation {
param([string]$Path, [string]$Method)
$pathProperty = $document.paths.PSObject.Properties[$Path]
if (-not $pathProperty) {
Add-Issue "JSON missing path: $Path"
return $null
}
$operationProperty = $pathProperty.Value.PSObject.Properties[$Method]
if (-not $operationProperty) {
Add-Issue "JSON missing operation: $($Method.ToUpperInvariant()) $Path"
return $null
}
return $operationProperty.Value
}
function Assert-OnlyMethod {
param([string]$Path, [string]$Method)
$pathProperty = $document.paths.PSObject.Properties[$Path]
if (-not $pathProperty) { return }
$actual = @($pathProperty.Value.PSObject.Properties.Name | Where-Object { $_ -in $httpMethods } | Sort-Object)
if ($actual.Count -ne 1 -or $actual[0] -ne $Method) {
Add-Issue "JSON $Path must expose only $($Method.ToUpperInvariant()); actual: $($actual -join ',')"
}
}
function Resolve-Parameter {
param([object]$Parameter, [string]$Label)
if (-not $Parameter) { return $null }
if (-not $Parameter.'$ref') { return $Parameter }
$parameterRefSiblings = @($Parameter.PSObject.Properties.Name | Where-Object { $_ -notin @('$ref', 'summary', 'description') })
if ($parameterRefSiblings.Count -gt 0) {
Add-Issue "JSON $Label parameter ref contains semantic sibling keywords: $($parameterRefSiblings -join ',')"
}
$name = Get-LocalComponentName ([string]$Parameter.'$ref') 'parameters' $Label
if (-not $name) { return $null }
$owner = $document.components.parameters.PSObject.Properties[$name]
if (-not $owner) {
Add-Issue "JSON $Label references missing parameter owner: $name"
return $null
}
return $owner.Value
}
function Resolve-Response {
param([object]$Response, [string]$Label, [bool]$StrictRefObject = $true)
if (-not $Response) { return $null }
if (-not $Response.'$ref') { return $Response }
$responseRefSiblings = @($Response.PSObject.Properties.Name | Where-Object { $_ -notin @('$ref', 'summary', 'description') })
if ($StrictRefObject -and $responseRefSiblings.Count -gt 0) {
Add-Issue "JSON $Label response ref contains semantic sibling keywords: $($responseRefSiblings -join ',')"
}
$name = Get-LocalComponentName ([string]$Response.'$ref') 'responses' $Label
if (-not $name) { return $null }
$owner = $document.components.responses.PSObject.Properties[$name]
if (-not $owner) {
Add-Issue "JSON $Label references missing response owner: $name"
return $null
}
return $owner.Value
}
function Resolve-Header {
param([object]$Header, [string]$Label)
if (-not $Header) { return $null }
if (-not $Header.'$ref') { return $Header }
$headerRefSiblings = @($Header.PSObject.Properties.Name | Where-Object { $_ -notin @('$ref', 'summary', 'description') })
if ($headerRefSiblings.Count -gt 0) {
Add-Issue "JSON $Label header ref contains semantic sibling keywords: $($headerRefSiblings -join ',')"
}
$name = Get-LocalComponentName ([string]$Header.'$ref') 'headers' $Label
if (-not $name) { return $null }
$owner = $document.components.headers.PSObject.Properties[$name]
if (-not $owner) {
Add-Issue "JSON $Label references missing header owner: $name"
return $null
}
return $owner.Value
}
function Get-OperationParameters {
param([string]$Path, [object]$Operation, [string]$Label)
$parameters = @()
$pathProperty = $document.paths.PSObject.Properties[$Path]
if ($pathProperty -and $pathProperty.Value.parameters) {
foreach ($parameter in @($pathProperty.Value.parameters)) {
$resolved = Resolve-Parameter $parameter "$Label path parameter"
if ($resolved) { $parameters += $resolved }
}
}
if ($Operation -and $Operation.parameters) {
foreach ($parameter in @($Operation.parameters)) {
$resolved = Resolve-Parameter $parameter "$Label operation parameter"
if ($resolved) { $parameters += $resolved }
}
}
return $parameters
}
function Assert-ExactParameters {
param([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 "JSON $Label parameters must be exactly $($wanted -join ','); actual: $($actual -join ',')"
}
}
function Get-Parameter {
param([object[]]$Parameters, [string]$Name, [string]$In, [string]$Label)
$matches = @($Parameters | Where-Object { $_.name -eq $Name -and $_.in -eq $In })
if ($matches.Count -ne 1) {
Add-Issue "JSON $Label must declare exactly one $In parameter: $Name"
return $null
}
return $matches[0]
}
function Assert-SaToken {
param([object]$Operation, [string]$Label)
if (-not $Operation) { return }
$requirements = @($Operation.security)
if ($requirements.Count -ne 1) {
Add-Issue "JSON $Label must have exactly one SaToken security requirement"
return
}
$names = @($requirements[0].PSObject.Properties.Name)
if ($names.Count -ne 1 -or $names[0] -ne 'SaToken') {
Add-Issue "JSON $Label must require only SaToken"
}
}
function Assert-GlobalOperationId {
param([string]$OperationId, [string]$ExpectedLabel)
$owners = New-Object System.Collections.Generic.List[string]
foreach ($pathProperty in @($document.paths.PSObject.Properties)) {
foreach ($methodProperty in @($pathProperty.Value.PSObject.Properties | Where-Object { $_.Name -in $httpMethods })) {
if ([string]$methodProperty.Value.operationId -ceq $OperationId) {
$owners.Add("$($methodProperty.Name.ToUpperInvariant()) $($pathProperty.Name)")
}
}
}
if ($owners.Count -ne 1 -or $owners[0] -cne $ExpectedLabel) {
Add-Issue "JSON operationId $OperationId must be globally unique on $ExpectedLabel; actual: $($owners -join ',')"
}
}
function Assert-ExactResponseSet {
param([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 "JSON $Label responses must be exactly $($wanted -join ','); actual: $($actual -join ',')"
}
}
function Get-Response {
param([object]$Operation, [string]$Label, [string]$Status)
if (-not $Operation) { return $null }
$property = $Operation.responses.PSObject.Properties[$Status]
if (-not $property) {
Add-Issue "JSON $Label missing response: $Status"
return $null
}
return Resolve-Response $property.Value "$Label $Status"
}
function Get-ResponseSchemaRef {
param([object]$Response, [string]$Label, [string]$Status)
if (-not $Response) { return '' }
$media = @($Response.content.PSObject.Properties)
if ($media.Count -ne 1 -or $media[0].Name -ne 'application/json') {
Add-Issue "JSON $Label $Status must expose only application/json"
return ''
}
$schema = $media[0].Value.schema
$ref = [string]$schema.'$ref'
if (-not $ref) {
Add-Issue "JSON $Label $Status must use a component schema ref"
return ''
}
[void](Get-LocalComponentName $ref 'schemas' "$Label $Status response schema")
$schemaKeywords = @($schema.PSObject.Properties.Name)
if ($schemaKeywords.Count -ne 1 -or $schemaKeywords[0] -cne '$ref') {
Add-Issue "JSON $Label $Status response schema must contain only its exact local schema ref"
}
return $ref
}
function Assert-RequiredResponseHeaders {
param([object]$Response, [string]$Label, [string]$Status)
if (-not $Response) { return }
$headerNames = if ($Response.headers) { @($Response.headers.PSObject.Properties.Name) } else { @() }
if ('Cache-Control' -notin $headerNames) {
Add-Issue "JSON $Label $Status response headers must include Cache-Control"
}
if ($Status -eq '429' -and 'Retry-After' -notin $headerNames) {
Add-Issue "JSON $Label $Status response headers must include Retry-After"
}
if (@($headerNames | Where-Object { $_ -ieq 'ETag' }).Count -gt 0) {
Add-Issue "JSON $Label $Status must not publish ETag; workspace reads use the shared settingsVersion owner instead"
}
$allowedHeaders = @('Cache-Control', 'traceparent', 'tracestate', 'x-request-id', 'x-correlation-id')
if ($Status -eq '429') { $allowedHeaders += 'Retry-After' }
$unexpectedHeaders = @($headerNames | Where-Object { $_ -notin $allowedHeaders })
if ($unexpectedHeaders.Count -gt 0) {
Add-Issue "JSON $Label $Status response headers may add only traceparent/tracestate/x-request-id/x-correlation-id tracing headers; unexpected: $($unexpectedHeaders -join ',')"
}
foreach ($traceName in @('traceparent', 'tracestate', 'x-request-id', 'x-correlation-id')) {
$property = if ($Response.headers) { $Response.headers.PSObject.Properties[$traceName] } else { $null }
if (-not $property) { continue }
$header = Resolve-Header $property.Value "$Label $Status $traceName"
if (-not $header) { continue }
foreach ($headerProperty in @($header.PSObject.Properties)) {
if ($headerProperty.Name -like 'x-*' -or $headerProperty.Name -in @('description', 'deprecated', 'schema')) { continue }
Add-Issue "JSON $Label $Status $traceName contains an unowned Header Object keyword: $($headerProperty.Name)"
}
if (-not $header.schema -or $header.schema.type -ne 'string' -or -not (Test-IsNonNullable $header.schema)) {
Add-Issue "JSON $Label $Status $traceName must resolve to a Header Object with a non-null string schema"
continue
}
Assert-NoConflictingSchemaKeywords $header.schema "$Label $Status $traceName schema"
Assert-AllowedSchemaKeywords $header.schema "$Label $Status $traceName schema" @('type', 'nullable')
}
}
function Assert-PrivateNoStore {
param([object]$Response, [string]$Label, [string]$Status)
if (-not $Response) { return }
$property = if ($Response.headers) { $Response.headers.PSObject.Properties['Cache-Control'] } else { $null }
if (-not $property) {
Add-Issue "JSON $Label $Status must document Cache-Control: private, no-store"
return
}
$header = Resolve-Header $property.Value "$Label $Status Cache-Control"
if (-not $header) { return }
$values = @($header.schema.enum)
if ($header.schema.type -ne '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 "JSON $Label $Status Cache-Control must be fixed by a single enum value: private, no-store"
}
Assert-NoConflictingSchemaKeywords $header.schema "$Label $Status Cache-Control schema" @('enum')
Assert-AllowedSchemaKeywords $header.schema "$Label $Status Cache-Control schema" @('type', 'enum', 'nullable')
}
function Assert-RetryAfter {
param([object]$Response, [string]$Label)
if (-not $Response) { return }
$property = if ($Response.headers) { $Response.headers.PSObject.Properties['Retry-After'] } else { $null }
if (-not $property) {
Add-Issue "JSON $Label must document Retry-After"
return
}
$header = Resolve-Header $property.Value "$Label Retry-After"
if (-not $header) { return }
if ($header.schema.type -ne '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 300) {
Add-Issue "JSON $Label Retry-After must be a non-null integer in a bounded 1..300 second range"
}
Assert-NoConflictingSchemaKeywords $header.schema "$Label Retry-After schema"
Assert-AllowedSchemaKeywords $header.schema "$Label Retry-After schema" @('type', 'minimum', 'maximum', 'nullable')
}
function Assert-ExactObject {
param([object]$Schema, [string]$Name, [string[]]$Properties, [string[]]$Required)
if (-not $Schema) { return }
$actualProperties = @($Schema.properties.PSObject.Properties.Name | Sort-Object)
$expectedProperties = @($Properties | Sort-Object)
$actualRequired = @($Schema.required | Sort-Object)
$expectedRequired = @($Required | Sort-Object)
if ($Schema.type -ne 'object' -or -not (Test-IsNonNullable $Schema) -or
-not (Test-IsJsonBoolean $Schema.additionalProperties $false) -or
($actualProperties -join ',') -cne ($expectedProperties -join ',') -or
($actualRequired -join ',') -cne ($expectedRequired -join ',')) {
Add-Issue "JSON $Name must be a non-null closed object with properties [$($expectedProperties -join ',')] and required [$($expectedRequired -join ',')]"
}
Assert-NoConflictingSchemaKeywords $Schema $Name
Assert-AllowedSchemaKeywords $Schema $Name @('type', 'properties', 'required', 'additionalProperties', 'nullable')
}
function Assert-PropertyRef {
param([object]$Schema, [string]$SchemaName, [string]$Field, [string]$ExpectedRef)
if (-not $Schema) { return }
$property = $Schema.properties.PSObject.Properties[$Field]
if (-not $property) {
Add-Issue "JSON $SchemaName missing property: $Field"
return
}
[void](Test-IsPureSchemaRef $property.Value $ExpectedRef "$SchemaName.$Field")
}
function Assert-FixedError {
param([string]$Name, [int]$Status, [string]$BusinessCode)
$schema = Get-Schema $Name
Assert-ExactObject $schema $Name @('code', 'businessCode') @('code', 'businessCode')
if (-not $schema) { return }
$codes = @($schema.properties.code.enum)
$businessCodes = @($schema.properties.businessCode.enum)
if ($schema.properties.code.type -ne 'integer' -or
-not (Test-IsNonNullable $schema.properties.code) -or
-not (Test-IsJsonArray $schema.properties.code.enum) -or $codes.Count -ne 1 -or $codes[0] -ne $Status -or
$schema.properties.businessCode.type -ne 'string' -or
-not (Test-IsNonNullable $schema.properties.businessCode) -or
-not (Test-IsJsonArray $schema.properties.businessCode.enum) -or
$businessCodes.Count -ne 1 -or [string]$businessCodes[0] -cne $BusinessCode) {
Add-Issue "JSON $Name must fix code=$Status and businessCode=$BusinessCode"
}
Assert-NoConflictingSchemaKeywords $schema.properties.code "$Name.code" @('enum')
Assert-NoConflictingSchemaKeywords $schema.properties.businessCode "$Name.businessCode" @('enum')
Assert-AllowedSchemaKeywords $schema.properties.code "$Name.code" @('type', 'enum', 'nullable')
Assert-AllowedSchemaKeywords $schema.properties.businessCode "$Name.businessCode" @('type', 'enum', 'nullable')
}
function Get-AllOfPropertyNames {
param([object]$Schema, [hashtable]$Seen, [string]$Label)
if (-not $Schema -or $Schema.type -eq 'array') { return @() }
if ($Schema.'$ref') {
$name = Get-LocalComponentName ([string]$Schema.'$ref') 'schemas' $Label
if (-not $name -or $Seen.ContainsKey($name)) { return @() }
$Seen[$name] = $true
$owner = $document.components.schemas.PSObject.Properties[$name]
if (-not $owner) {
Add-Issue "JSON $Label references missing schema owner: $name"
return @()
}
return @(Get-AllOfPropertyNames $owner.Value $Seen "$Label -> $name")
}
$names = New-Object System.Collections.Generic.List[string]
if ($Schema.properties) {
foreach ($name in @($Schema.properties.PSObject.Properties.Name)) { $names.Add($name) }
}
foreach ($branch in @($Schema.allOf)) {
foreach ($name in @(Get-AllOfPropertyNames $branch $Seen "$Label allOf")) { $names.Add($name) }
}
return @($names | Sort-Object -Unique)
}
function Test-SchemaClosureExposesSingleGenealogy {
param([object]$Schema, [hashtable]$Seen, [string]$Label)
if (-not $Schema) { return $false }
$ref = [string]$Schema.'$ref'
if ($ref) {
$leaf = @($ref -split '/')[-1]
if ($leaf -in @('RAppGenealogyVo', 'AppGenealogyVo')) {
$expectedRef = "#/components/schemas/$leaf"
if ($ref -cne $expectedRef) {
[void](Get-LocalComponentName $ref 'schemas' $Label)
}
return $true
}
$name = Get-LocalComponentName $ref 'schemas' $Label
if (-not $name -or $Seen.ContainsKey($name)) { return $false }
$Seen[$name] = $true
$owner = $document.components.schemas.PSObject.Properties[$name]
if (-not $owner) {
Add-Issue "JSON $Label references missing schema owner: $name"
return $false
}
return Test-SchemaClosureExposesSingleGenealogy $owner.Value $Seen "$Label -> $name"
}
if ($Schema.type -eq 'array') { return $false }
$propertyNames = @(Get-AllOfPropertyNames $Schema @{} "$Label structural detail")
$detailFields = @(@('genealogyId', 'genealogyName', 'canView') | Where-Object { $_ -in $propertyNames })
if ($detailFields.Count -eq 3) {
return $true
}
foreach ($keyword in @('allOf', 'anyOf', 'oneOf')) {
foreach ($branch in @($Schema.$keyword)) {
if (Test-SchemaClosureExposesSingleGenealogy $branch $Seen "$Label $keyword") { return $true }
}
}
if ($Schema.properties) {
foreach ($property in @($Schema.properties.PSObject.Properties)) {
if ($property.Value.type -eq 'array') { continue }
if (Test-SchemaClosureExposesSingleGenealogy $property.Value $Seen "$Label.$($property.Name)") { return $true }
}
}
return $false
}
function Test-OperationReturnsSingleGenealogy {
param([object]$Operation, [string]$Label)
if (-not $Operation -or -not $Operation.responses) { return $false }
$responseProperty = $Operation.responses.PSObject.Properties['200']
if (-not $responseProperty) { return $false }
$response = $responseProperty.Value
if ($response.'$ref') {
$leaf = @(([string]$response.'$ref') -split '/')[-1]
if ($leaf -eq 'RAppGenealogyVo') {
if ([string]$response.'$ref' -cne '#/components/responses/RAppGenealogyVo') {
[void](Get-LocalComponentName ([string]$response.'$ref') 'responses' "$Label 200 response")
}
return $true
}
$response = Resolve-Response $response "$Label 200 response" $true
if (-not $response) { return $false }
}
if (-not $response.content) { return $false }
foreach ($media in @($response.content.PSObject.Properties)) {
if (Test-SchemaClosureExposesSingleGenealogy $media.Value.schema @{} "$Label 200 $($media.Name)") {
return $true
}
}
return $false
}
function Assert-SoleSingleGenealogyReadOwner {
$owners = New-Object System.Collections.Generic.List[string]
foreach ($pathProperty in @($document.paths.PSObject.Properties | Where-Object { $_.Name -match '^/genealogy/app(?:/|$)' })) {
$getProperty = $pathProperty.Value.PSObject.Properties['get']
if (-not $getProperty -or $pathProperty.Name -ceq $minePath) { continue }
$label = "GET $($pathProperty.Name)"
if (Test-OperationReturnsSingleGenealogy $getProperty.Value $label) {
$owners.Add($label)
}
}
$expected = "GET $overviewPath"
$uniqueOwners = @($owners | Sort-Object -Unique)
if ($uniqueOwners.Count -ne 1 -or $uniqueOwners[0] -cne $expected) {
Add-Issue "JSON single-genealogy APP read must have exactly one global owner ($expected); actual: $($uniqueOwners -join ',')"
}
}
$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 ' | ')"
}
$mine = Get-Operation $minePath 'get'
$overview = Get-Operation $overviewPath 'get'
Assert-OnlyMethod $minePath 'get'
Assert-OnlyMethod $overviewPath 'get'
Assert-SoleSingleGenealogyReadOwner
$operationContracts = @(
[pscustomobject]@{
Path = $minePath
Operation = $mine
Label = "GET $minePath"
OperationId = 'appListMyGenealogies'
Parameters = @('header:clientid')
Responses = [ordered]@{
'200' = '#/components/schemas/RListAppGenealogyVo'
'401' = '#/components/schemas/RGenealogyWorkspaceUnauthorized'
'429' = '#/components/schemas/RGenealogyWorkspaceRateLimited'
'500' = '#/components/schemas/RGenealogyWorkspaceUnavailable'
}
},
[pscustomobject]@{
Path = $overviewPath
Operation = $overview
Label = "GET $overviewPath"
OperationId = 'appGetGenealogyOverview'
Parameters = @('header:clientid', 'path:genealogyId')
Responses = [ordered]@{
'200' = '#/components/schemas/RAppGenealogyVo'
'400' = '#/components/schemas/RGenealogyWorkspaceBadRequest'
'401' = '#/components/schemas/RGenealogyWorkspaceUnauthorized'
'404' = '#/components/schemas/RGenealogyWorkspaceNotFound'
'429' = '#/components/schemas/RGenealogyWorkspaceRateLimited'
'500' = '#/components/schemas/RGenealogyWorkspaceUnavailable'
}
}
)
foreach ($contract in $operationContracts) {
$operation = $contract.Operation
if (-not $operation) { continue }
if ([string]$operation.operationId -cne $contract.OperationId) {
Add-Issue "JSON $($contract.Label) operationId must be $($contract.OperationId)"
}
Assert-GlobalOperationId $contract.OperationId $contract.Label
Assert-SaToken $operation $contract.Label
if ($operation.PSObject.Properties['requestBody']) {
Add-Issue "JSON $($contract.Label) must not define a request body"
}
$parameters = @(Get-OperationParameters $contract.Path $operation $contract.Label)
Assert-ExactParameters $parameters $contract.Label $contract.Parameters
$clientid = Get-Parameter $parameters 'clientid' 'header' $contract.Label
if ($clientid -and (-not (Test-IsJsonBoolean $clientid.required $true) -or
$clientid.schema.type -ne 'string' -or -not (Test-IsNonNullable $clientid.schema) -or
[int]$clientid.schema.minLength -ne 1 -or [int]$clientid.schema.maxLength -ne 128)) {
Add-Issue "JSON $($contract.Label) clientid must be a required non-null string bounded to 1..128"
}
if ($clientid) { Assert-NoConflictingSchemaKeywords $clientid.schema "$($contract.Label) clientid" }
if ($clientid) { Assert-AllowedSchemaKeywords $clientid.schema "$($contract.Label) clientid" @('type', 'minLength', 'maxLength', 'nullable') }
if ($contract.Path -eq $overviewPath) {
$genealogyId = Get-Parameter $parameters 'genealogyId' 'path' $contract.Label
if ($genealogyId) {
if (-not (Test-IsJsonBoolean $genealogyId.required $true)) {
Add-Issue "JSON $($contract.Label) genealogyId must be required"
}
[void](Test-IsPureSchemaRef $genealogyId.schema '#/components/schemas/GenealogyId' "$($contract.Label) genealogyId")
}
}
$statuses = @($contract.Responses.Keys)
Assert-ExactResponseSet $operation $contract.Label $statuses
foreach ($status in $statuses) {
$response = Get-Response $operation $contract.Label $status
$actualRef = Get-ResponseSchemaRef $response $contract.Label $status
if ($actualRef -cne $contract.Responses[$status]) {
Add-Issue "JSON $($contract.Label) $status must return $($contract.Responses[$status]); actual: $actualRef"
}
Assert-RequiredResponseHeaders $response $contract.Label $status
Assert-PrivateNoStore $response $contract.Label $status
if ($status -eq '429') { Assert-RetryAfter $response "$($contract.Label) 429" }
}
}
if ($mine -and (-not (Test-IsJsonBoolean $mine.'x-current-account-viewable-only' $true) -or
[string]$mine.'x-revocation-policy' -cne 'OMIT_ONLY_AFTER_CONFIRMED_ACCESS_LOSS')) {
Add-Issue 'JSON GET /mine must machine-bind current-account viewable-only scope and confirmed-revocation omission'
}
if ($overview -and [string]$overview.'x-object-authorization-failure' -cne 'NON_DISCLOSING_GENEALOGY_NOT_AVAILABLE') {
Add-Issue 'JSON GET /overview must machine-bind non-disclosing object authorization failure'
}
$genealogyIdOwner = Get-Schema 'GenealogyId'
if ($genealogyIdOwner -and ($genealogyIdOwner.type -ne '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 'JSON GenealogyId must be a non-null 1..128 URL-safe lexical identifier'
}
if ($genealogyIdOwner) { Assert-NoConflictingSchemaKeywords $genealogyIdOwner 'GenealogyId' }
if ($genealogyIdOwner) { Assert-AllowedSchemaKeywords $genealogyIdOwner 'GenealogyId' @('type', 'minLength', 'maxLength', 'pattern', 'nullable') }
$genealogyNameOwner = Get-Schema 'GenealogyName'
if ($genealogyNameOwner -and ($genealogyNameOwner.type -ne 'string' -or -not (Test-IsNonNullable $genealogyNameOwner))) {
Add-Issue 'JSON GenealogyName shared owner must be a non-null string; G11 owns its normalization and length semantics'
}
if ($genealogyNameOwner) { Assert-NoConflictingSchemaKeywords $genealogyNameOwner 'GenealogyName' }
if ($genealogyNameOwner) { Assert-AllowedSchemaKeywords $genealogyNameOwner 'GenealogyName' @('type', 'minLength', 'maxLength', 'pattern', 'nullable') }
$listEnvelope = Get-Schema 'RListAppGenealogyVo'
$objectEnvelope = Get-Schema 'RAppGenealogyVo'
$genealogy = Get-Schema 'AppGenealogyVo'
Assert-ExactObject $listEnvelope 'RListAppGenealogyVo' @('code', 'data') @('code', 'data')
Assert-ExactObject $objectEnvelope 'RAppGenealogyVo' @('code', 'data') @('code', 'data')
foreach ($envelope in @(
[pscustomobject]@{ Name = 'RListAppGenealogyVo'; Schema = $listEnvelope },
[pscustomobject]@{ Name = 'RAppGenealogyVo'; Schema = $objectEnvelope }
)) {
if (-not $envelope.Schema) { continue }
$codes = @($envelope.Schema.properties.code.enum)
if ($envelope.Schema.properties.code.type -ne 'integer' -or
-not (Test-IsNonNullable $envelope.Schema.properties.code) -or
-not (Test-IsJsonArray $envelope.Schema.properties.code.enum) -or
$codes.Count -ne 1 -or $codes[0] -ne 200) {
Add-Issue "JSON $($envelope.Name).code must be fixed to 200"
}
Assert-NoConflictingSchemaKeywords $envelope.Schema.properties.code "$($envelope.Name).code" @('enum')
Assert-AllowedSchemaKeywords $envelope.Schema.properties.code "$($envelope.Name).code" @('type', 'enum', 'nullable')
}
if ($listEnvelope) {
$listData = $listEnvelope.properties.data
if ($listData.type -ne 'array' -or -not (Test-IsNonNullable $listData)) {
Add-Issue 'JSON RListAppGenealogyVo.data must be a non-null AppGenealogyVo array'
}
Assert-NoConflictingSchemaKeywords $listData 'RListAppGenealogyVo.data'
Assert-AllowedSchemaKeywords $listData 'RListAppGenealogyVo.data' @('type', 'items', 'nullable')
[void](Test-IsPureSchemaRef $listData.items '#/components/schemas/AppGenealogyVo' 'RListAppGenealogyVo.data.items')
}
if ($objectEnvelope) {
[void](Test-IsPureSchemaRef $objectEnvelope.properties.data '#/components/schemas/AppGenealogyVo' 'RAppGenealogyVo.data')
}
$consumedFields = @('genealogyId', 'genealogyName', 'canView', 'canManage', 'canEditContent', 'roleType')
if ($genealogy) {
if ($genealogy.type -ne 'object' -or -not (Test-IsNonNullable $genealogy)) {
Add-Issue 'JSON AppGenealogyVo must be a non-null object'
}
Assert-AllowedSchemaKeywords $genealogy 'AppGenealogyVo' @('type', 'properties', 'required', 'additionalProperties', 'nullable')
foreach ($field in $consumedFields) {
if ($field -notin @($genealogy.required)) {
Add-Issue "JSON AppGenealogyVo.required missing workspace field: $field"
}
}
Assert-PropertyRef $genealogy 'AppGenealogyVo' 'genealogyId' '#/components/schemas/GenealogyId'
Assert-PropertyRef $genealogy 'AppGenealogyVo' 'genealogyName' '#/components/schemas/GenealogyName'
foreach ($field in @('canView', 'canManage', 'canEditContent')) {
$property = $genealogy.properties.PSObject.Properties[$field]
if (-not $property -or $property.Value.type -ne 'boolean' -or -not (Test-IsNonNullable $property.Value)) {
Add-Issue "JSON AppGenealogyVo.$field must be a non-null boolean"
}
if ($property) {
Assert-NoConflictingSchemaKeywords $property.Value "AppGenealogyVo.$field"
Assert-AllowedSchemaKeywords $property.Value "AppGenealogyVo.$field" @('type', 'nullable')
}
}
$roleType = $genealogy.properties.PSObject.Properties['roleType']
$rawRoleValues = if ($roleType) { @($roleType.Value.enum) } else { @() }
$roleValues = @($rawRoleValues | Where-Object { $_ -is [string] -and -not [string]::IsNullOrWhiteSpace($_) })
$uniqueRoleValues = @($roleValues | Sort-Object -CaseSensitive -Unique)
if (-not $roleType -or $roleType.Value.type -ne 'string' -or -not (Test-IsNonNullable $roleType.Value) -or
-not (Test-IsJsonArray $roleType.Value.enum) -or $roleValues.Count -ne $rawRoleValues.Count -or
$uniqueRoleValues.Count -ne $rawRoleValues.Count -or $rawRoleValues.Count -lt 2) {
Add-Issue 'JSON AppGenealogyVo.roleType must be a non-null string with at least two stable non-empty enum values'
}
if ($roleType) {
Assert-NoConflictingSchemaKeywords $roleType.Value 'AppGenealogyVo.roleType' @('enum')
Assert-AllowedSchemaKeywords $roleType.Value 'AppGenealogyVo.roleType' @('type', 'enum', 'nullable')
}
}
Assert-FixedError 'RGenealogyWorkspaceBadRequest' 400 'GENEALOGY_ID_INVALID'
Assert-FixedError 'RGenealogyWorkspaceUnauthorized' 401 'AUTH_REQUIRED'
Assert-FixedError 'RGenealogyWorkspaceNotFound' 404 'GENEALOGY_NOT_AVAILABLE'
Assert-FixedError 'RGenealogyWorkspaceRateLimited' 429 'RATE_LIMITED'
Assert-FixedError 'RGenealogyWorkspaceUnavailable' 500 'GENEALOGY_WORKSPACE_UNAVAILABLE'
if ($issues.Count -gt 0) {
Write-Output 'GENEALOGY-WORKSPACE-OPENAPI-CONTRACT BLOCKED'
Write-Output "Issues: $($issues.Count)"
$issues | ForEach-Object { Write-Output "- $_" }
Write-Output '- /mine and /overview are authenticated private reads; do not publish a generic duplicate detail read or HTTP-200-wrapped errors.'
Write-Output '- Replace both protected exports from one backend version; do not hand-edit APP.openapi.json or APP.openapi.yaml.'
Write-Output '- Release still requires two-account deployment tests for revocation, cross-account isolation, deletion, rate limits, malformed identifiers, and service failure.'
exit 1
}
Write-Output 'GENEALOGY-WORKSPACE-OPENAPI-CONTRACT PASS'