$ErrorActionPreference = 'Stop' $root = Split-Path -Parent $PSScriptRoot $document = Get-Content -Raw -Encoding UTF8 -LiteralPath (Join-Path $root 'APP.openapi.json') | ConvertFrom-Json $yaml = Get-Content -Raw -Encoding UTF8 -LiteralPath (Join-Path $root 'APP.openapi.yaml') $issues = New-Object System.Collections.Generic.List[string] $profilePath = '/genealogy/app/auth/profile' $versionPattern = '^[A-Za-z0-9][A-Za-z0-9._~-]{0,127}$' $trimmedValuePattern = '^\S(?:.*\S)?$' function Add-Issue { param([string]$Message) $script:issues.Add($Message) } 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-ResponseSchemaRef { param([object]$Operation, [string]$Status) if (-not $Operation) { return '' } $responseProperty = $Operation.responses.PSObject.Properties[$Status] if (-not $responseProperty) { Add-Issue "JSON PUT $profilePath missing $Status response" return '' } $response = $responseProperty.Value if ($response.'$ref') { $responseName = ([string]$response.'$ref').Split('/')[-1] $owner = $document.components.responses.PSObject.Properties[$responseName] if (-not $owner) { Add-Issue "JSON missing response owner: $responseName" return '' } $response = $owner.Value } $media = $response.content.PSObject.Properties['application/json'] if (-not $media) { Add-Issue "JSON PUT $profilePath $Status must use application/json" return '' } return [string]$media.Value.schema.'$ref' } function Assert-PrivateNoStore { param([object]$Operation, [string]$Label) if (-not $Operation) { return } $responseProperty = $Operation.responses.PSObject.Properties['200'] if (-not $responseProperty) { return } $response = $responseProperty.Value if ($response.'$ref') { $responseName = ([string]$response.'$ref').Split('/')[-1] $owner = $document.components.responses.PSObject.Properties[$responseName] if (-not $owner) { return } $response = $owner.Value } $headerProperty = if ($response.headers) { $response.headers.PSObject.Properties['Cache-Control'] } else { $null } if (-not $headerProperty) { Add-Issue "JSON $Label 200 must document Cache-Control: private, no-store" return } $header = $headerProperty.Value if ($header.'$ref') { $headerName = ([string]$header.'$ref').Split('/')[-1] $owner = $document.components.headers.PSObject.Properties[$headerName] if ($owner) { $header = $owner.Value } } $evidence = ([string]$header.description) + ' ' + ([string]$header.example) + ' ' + ([string]$header.schema.example) if ($header.schema.type -ne 'string' -or $evidence -notmatch '(?i)(private.*no-store|no-store.*private)') { Add-Issue "JSON $Label Cache-Control header must specify private, no-store" } } function Assert-Required { param([object]$Schema, [string]$SchemaName, [string[]]$Fields) if (-not $Schema) { return } foreach ($field in $Fields) { if ($field -notin @($Schema.required)) { Add-Issue "JSON $SchemaName.required missing: $field" } } } function Assert-ClearableString { param( [object]$Property, [string]$Field, [int]$Maximum, [switch]$Email ) if (-not $Property) { Add-Issue "JSON AppProfileMergeUpdateBody missing property: $Field" return } $branches = @($Property.oneOf) if ($branches.Count -ne 2) { Add-Issue "JSON AppProfileMergeUpdateBody.$Field must use exactly two oneOf branches" return } $clearBranch = @($branches | Where-Object { @($_.enum).Count -eq 1 -and [string]$_.enum[0] -eq '' }) $valueBranch = @($branches | Where-Object { $_.type -eq 'string' -and [int]$_.minLength -eq 1 }) if ($clearBranch.Count -ne 1 -or $clearBranch[0].type -ne 'string') { Add-Issue "JSON AppProfileMergeUpdateBody.$Field must use exact empty string as its only clear command" } if ($valueBranch.Count -ne 1 -or [int]$valueBranch[0].maxLength -ne $Maximum -or [string]$valueBranch[0].pattern -ne $trimmedValuePattern) { Add-Issue "JSON AppProfileMergeUpdateBody.$Field non-empty branch must be trimmed length 1..$Maximum" } if ($Email -and $valueBranch.Count -eq 1 -and $valueBranch[0].format -ne 'email') { Add-Issue 'JSON AppProfileMergeUpdateBody.email non-empty branch must use email format' } } $pathProperty = $document.paths.PSObject.Properties[$profilePath] $pathItem = if ($pathProperty) { $pathProperty.Value } else { $null } $operation = if ($pathItem) { $pathItem.put } else { $null } if (-not $operation) { Add-Issue "JSON missing PUT $profilePath" } if ($pathItem -and $pathItem.patch) { Add-Issue "JSON $profilePath must not publish a second PATCH update owner" } if ($operation) { $hasSaToken = $false foreach ($requirement in @($operation.security)) { if ($requirement.PSObject.Properties.Name -contains 'SaToken') { $hasSaToken = $true } } if (-not $hasSaToken) { Add-Issue "JSON PUT $profilePath must require SaToken" } $clientHeaders = @($operation.parameters | Where-Object { $_.name -eq 'clientid' -and $_.in -eq 'header' }) if ($clientHeaders.Count -ne 1 -or $clientHeaders[0].required -ne $true -or $clientHeaders[0].schema.type -ne 'string') { Add-Issue "JSON PUT $profilePath must require one string clientid header" } $versionHeaders = @($operation.parameters | Where-Object { $_.name -eq 'If-Match' -and $_.in -eq 'header' }) if ($versionHeaders.Count -ne 1 -or $versionHeaders[0].required -ne $true -or $versionHeaders[0].schema.type -ne 'string' -or $versionHeaders[0].schema.minLength -ne 1 -or $versionHeaders[0].schema.maxLength -ne 128 -or $versionHeaders[0].schema.pattern -ne $versionPattern) { Add-Issue "JSON PUT $profilePath must require If-Match with the opaque profileVersion shape" } $requestMedia = $operation.requestBody.content.PSObject.Properties['application/json'] if ($operation.requestBody.required -ne $true -or -not $requestMedia) { Add-Issue "JSON PUT $profilePath must require an application/json body" } elseif ($requestMedia.Value.schema.'$ref' -ne '#/components/schemas/AppProfileMergeUpdateBody') { Add-Issue "JSON PUT $profilePath must use AppProfileMergeUpdateBody" } $semantics = [string]$operation.description foreach ($semanticPattern in @( '(?i)merge update', '(?i)present editable propert(?:y|ies).*update', '(?i)omitted editable propert(?:y|ies).*unchanged', '(?i)exact empty string.*clear.*realName.*email', '(?i)repeat(?:ed|ing).*no additional side effects' )) { if ($semantics -notmatch $semanticPattern) { Add-Issue "JSON PUT $profilePath description is missing merge/idempotency semantics: $semanticPattern" } } foreach ($status in @('200', '400', '401', '409', '422', '429', '500')) { if (-not $operation.responses.PSObject.Properties[$status]) { Add-Issue "JSON PUT $profilePath missing documented response: $status" } } } $body = Get-Schema 'AppProfileMergeUpdateBody' if ($body) { $propertyNames = @($body.properties.PSObject.Properties.Name | Sort-Object) if (($propertyNames -join ',') -ne 'email,nickName,realName') { Add-Issue "JSON AppProfileMergeUpdateBody must own only email,nickName,realName; actual: $($propertyNames -join ',')" } if ($body.type -ne 'object' -or $body.additionalProperties -ne $false -or [int]$body.minProperties -ne 1 -or [int]$body.maxProperties -ne 3) { Add-Issue 'JSON AppProfileMergeUpdateBody must be a closed object with 1..3 dirty properties' } if (@($body.required).Count -ne 0) { Add-Issue 'JSON AppProfileMergeUpdateBody editable properties must be optional for dirty-only merge' } $nickName = $body.properties.nickName if ($nickName.type -ne 'string' -or [int]$nickName.minLength -ne 1 -or [int]$nickName.maxLength -ne 30 -or [string]$nickName.pattern -ne $trimmedValuePattern) { Add-Issue 'JSON AppProfileMergeUpdateBody.nickName must be a trimmed non-empty string of length 1..30' } Assert-ClearableString $body.properties.realName 'realName' 30 Assert-ClearableString $body.properties.email 'email' 100 -Email } $successRef = Get-ResponseSchemaRef $operation '200' if ($successRef -ne '#/components/schemas/RAppProfileVo') { Add-Issue "JSON PUT $profilePath 200 must return RAppProfileVo; actual: $successRef" } $conflictRef = Get-ResponseSchemaRef $operation '409' if ($conflictRef -ne '#/components/schemas/RProfileVersionChanged') { Add-Issue "JSON PUT $profilePath 409 must return RProfileVersionChanged; actual: $conflictRef" } $validationRef = Get-ResponseSchemaRef $operation '422' if ($validationRef -ne '#/components/schemas/RProfileValidationError') { Add-Issue "JSON PUT $profilePath 422 must return RProfileValidationError; actual: $validationRef" } Assert-PrivateNoStore $operation "PUT $profilePath" Assert-PrivateNoStore $(if ($pathItem) { $pathItem.get } else { $null }) "GET $profilePath" $profileEnvelope = Get-Schema 'RAppProfileVo' $profile = Get-Schema 'AppProfileVo' $conflict = Get-Schema 'RProfileVersionChanged' $validation = Get-Schema 'RProfileValidationError' Assert-Required $profileEnvelope 'RAppProfileVo' @('code', 'data') Assert-Required $profile 'AppProfileVo' @('phone', 'profileVersion') Assert-Required $conflict 'RProfileVersionChanged' @('code', 'businessCode') Assert-Required $validation 'RProfileValidationError' @('code', 'businessCode', 'fieldErrors') if ($profileEnvelope) { if ($profileEnvelope.properties.code.type -ne 'integer' -or $profileEnvelope.properties.data.'$ref' -ne '#/components/schemas/AppProfileVo') { Add-Issue 'JSON RAppProfileVo must contain integer code and AppProfileVo data' } } if ($profile) { $version = $profile.properties.profileVersion if ($version.type -ne 'string' -or [int]$version.minLength -ne 1 -or [int]$version.maxLength -ne 128 -or $version.pattern -ne $versionPattern) { Add-Issue 'JSON AppProfileVo.profileVersion must be a 1..128 URL-safe opaque string' } } if ($conflict) { if ($conflict.properties.code.type -ne 'integer' -or $conflict.properties.businessCode.type -ne 'string' -or (@($conflict.properties.businessCode.enum) -join ',') -ne 'PROFILE_VERSION_CHANGED') { Add-Issue 'JSON RProfileVersionChanged must expose the single PROFILE_VERSION_CHANGED business code' } } if ($validation) { $fieldErrors = $validation.properties.fieldErrors $fieldNames = @($fieldErrors.properties.PSObject.Properties.Name | Sort-Object) if ($validation.properties.code.type -ne 'integer' -or $validation.properties.businessCode.type -ne 'string' -or (@($validation.properties.businessCode.enum) -join ',') -ne 'PROFILE_VALIDATION_FAILED' -or $fieldErrors.type -ne 'object' -or $fieldErrors.additionalProperties -ne $false -or ($fieldNames -join ',') -ne 'email,nickName,realName') { Add-Issue 'JSON RProfileValidationError must expose only nickName/realName/email field errors with PROFILE_VALIDATION_FAILED' } else { foreach ($field in $fieldNames) { if ($fieldErrors.properties.$field.type -ne 'string' -or [int]$fieldErrors.properties.$field.minLength -lt 1 -or [int]$fieldErrors.properties.$field.maxLength -gt 200) { Add-Issue "JSON RProfileValidationError.fieldErrors.$field must be a bounded non-empty string" } } } } foreach ($yamlFact in @( ' /genealogy/app/auth/profile:', ' operationId:', ' name: If-Match', '#/components/schemas/AppProfileMergeUpdateBody', '#/components/schemas/RAppProfileVo', '#/components/schemas/RProfileVersionChanged', '#/components/schemas/RProfileValidationError', ' AppProfileMergeUpdateBody:', ' AppProfileVo:', ' profileVersion:', ' RProfileVersionChanged:', ' - PROFILE_VERSION_CHANGED', ' RProfileValidationError:', ' - PROFILE_VALIDATION_FAILED', ' fieldErrors:', ' Cache-Control:' )) { if (-not $yaml.Contains($yamlFact)) { Add-Issue "YAML fact is missing: $yamlFact" } } if ($issues.Count -gt 0) { $lines = New-Object System.Collections.Generic.List[string] $lines.Add('PROFILE-UPDATE-OPENAPI-CONTRACT BLOCKED') foreach ($issue in $issues) { $lines.Add("- $issue") } $lines.Add('- Keep PUT as the only App owner, but define it as an atomic dirty-only merge: omitted fields stay unchanged; exact empty string clears only realName/email.') $lines.Add('- profileVersion is returned in the canonical profile; If-Match carries it on write, CORS must allow If-Match, and stale versions return 409 PROFILE_VERSION_CHANGED.') $lines.Add('- The response omits cleared optional fields. Timeout is outcome-unknown and must be reconciled with GET before retrying.') $lines.Add('- Replace both protected exports from one backend version; do not hand-edit APP.openapi.json or APP.openapi.yaml.') throw ($lines -join [Environment]::NewLine) } Write-Output 'PROFILE-UPDATE-OPENAPI-CONTRACT PASS'