Skip to content

Commit 257dc6d

Browse files
committed
skipping schema ids, doing semantic compare
1 parent 38145fe commit 257dc6d

2 files changed

Lines changed: 512 additions & 225 deletions

File tree

tests/integration/all-resource-types/Compare-ApimInstance.ps1

Lines changed: 102 additions & 225 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,8 @@ param(
2828
Set-StrictMode -Version Latest
2929
$ErrorActionPreference = 'Stop'
3030

31+
Import-Module (Join-Path $PSScriptRoot 'modules/CompareSemantics.psm1') -Force
32+
3133
# ── Constants ───────────────────────────────────────────────────────────────
3234

3335
# Use the newest APIM ARM API version so resource types introduced in newer
@@ -57,12 +59,26 @@ $RequestResponseIgnoredProperties = @('description')
5759

5860
# Properties ignored on representation objects (have 'contentType' or 'schemaId'):
5961
# - description: SOAP/WSDL import generates descriptions that vary
60-
# - schemaId/typeName: For SOAP APIs whose operations have auto-generated IDs,
61-
# PATCH reconciliation is skipped and these fields may differ. For REST APIs,
62-
# PATCH reconciliation restores them after spec import, so they should match.
63-
# Ignored conditionally only for SOAP-like auto-generated operation IDs.
62+
# - schemaId/typeName: Operation reconciliation strips these before PATCH because
63+
# APIM rebinds representation schema refs during import. Values are therefore
64+
# not stable for round-trip comparison and are ignored for operation resources.
6465
$RepresentationIgnoredProperties = @('description')
65-
$SoapAutoGeneratedRepresentationIgnoredProperties = @('schemaId', 'typeName')
66+
$RepresentationSchemaRefIgnoredProperties = @('schemaId', 'typeName')
67+
68+
# Cache of normalized API schema semantics per instance/api, keyed as:
69+
# "{instance}|{apiName}" => @{ schemaId => normalizedSchemaJson }
70+
$ApiSchemaSemanticCache = @{}
71+
72+
$NormalizationContext = New-CompareNormalizationContext `
73+
-SourceName $SourceApimName -TargetName $TargetApimName `
74+
-SourceSub $SourceSubscriptionId -TargetSub $TargetSubscriptionId `
75+
-SourceRg $SourceResourceGroup -TargetRg $TargetResourceGroup `
76+
-StripTopLevelFields $StripTopLevelFields `
77+
-StripReadOnlyProperties $StripReadOnlyProperties `
78+
-StripTimestampProperties $StripTimestampProperties `
79+
-RequestResponseIgnoredProperties $RequestResponseIgnoredProperties `
80+
-RepresentationIgnoredProperties $RepresentationIgnoredProperties `
81+
-RepresentationSchemaRefIgnoredProperties $RepresentationSchemaRefIgnoredProperties
6682

6783
# ── Helpers ─────────────────────────────────────────────────────────────────
6884

@@ -120,6 +136,49 @@ function Get-ResourceName {
120136
return ($ResourceId -split '/')[-1]
121137
}
122138

139+
function Copy-JsonObject {
140+
<# Deep-clones a PSCustomObject/hashtable via JSON round-trip. #>
141+
param([Parameter(Mandatory)] $Value)
142+
return ($Value | ConvertTo-Json -Depth 100 | ConvertFrom-Json -Depth 100)
143+
}
144+
145+
function Get-ApiSchemaSemanticMap {
146+
<#
147+
.SYNOPSIS
148+
Returns a map of { schemaId => normalized schema JSON } for one API.
149+
#>
150+
[CmdletBinding()]
151+
param(
152+
[Parameter(Mandatory)] [string] $InstanceKey,
153+
[Parameter(Mandatory)] [string] $BaseUrl,
154+
[Parameter(Mandatory)] [string] $ApiName
155+
)
156+
157+
$cacheKey = "$InstanceKey|$ApiName"
158+
if ($ApiSchemaSemanticCache.Contains($cacheKey)) {
159+
return $ApiSchemaSemanticCache[$cacheKey]
160+
}
161+
162+
$schemaMap = @{}
163+
try {
164+
$schemas = Get-ArmResourceList -Url "$BaseUrl/apis/$ApiName/schemas"
165+
foreach ($schema in $schemas) {
166+
$schemaId = Get-ResourceName -ResourceId $schema.id
167+
if (-not $schemaId) { continue }
168+
169+
$schemaNorm = ConvertTo-NormalizedResource -Resource $schema
170+
$schemaSemantics = $schemaNorm | ConvertTo-Json -Depth 50 -Compress
171+
$schemaMap[$schemaId] = $schemaSemantics
172+
}
173+
}
174+
catch {
175+
Write-Verbose "Could not load schemas for API $ApiName on $InstanceKey$_"
176+
}
177+
178+
$ApiSchemaSemanticCache[$cacheKey] = $schemaMap
179+
return $schemaMap
180+
}
181+
123182
function Build-ResourceMap {
124183
<#
125184
.SYNOPSIS
@@ -145,43 +204,10 @@ function Build-ResourceMap {
145204
[Parameter(Mandatory)] [string] $TargetRg
146205
)
147206

148-
$map = [ordered]@{}
149-
150-
# Handle empty or null input
151-
if ($null -eq $Items -or $Items.Count -eq 0) {
152-
return $map
153-
}
154-
155-
$autoIdItems = [System.Collections.Generic.List[object]]::new()
156-
157-
foreach ($item in $Items) {
158-
$rName = Get-ResourceName -ResourceId $item.id
159-
if ($rName -in $ExcludeNames) { continue }
160-
161-
# Auto-generated IDs: 24-char lowercase hex OR UUID format (8-4-4-4-12)
162-
# These get regenerated on publish, so key by normalised content instead
163-
if ($rName -match '^[0-9a-f]{24}$' -or $rName -match '^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$') {
164-
$autoIdItems.Add($item)
165-
} else {
166-
$map[$rName] = $item
167-
}
168-
}
169-
170-
# Sort auto-ID items by canonical normalized resource JSON (same normalization
171-
# pipeline used for diffing) so positional keys remain stable across instances.
172-
if ($autoIdItems.Count -gt 0) {
173-
$sorted = $autoIdItems | Sort-Object {
174-
$normResource = ConvertTo-NormalizedResource -Resource $_
175-
$normResource | ConvertTo-Json -Depth 50 -Compress
176-
}
177-
$i = 0
178-
foreach ($item in $sorted) {
179-
$map["{{auto-id-$i}}"] = $item
180-
$i++
181-
}
207+
return CompareSemantics\Build-ResourceMap -Items $Items -ExcludeNames $ExcludeNames -NormalizeResource {
208+
param($resource)
209+
ConvertTo-NormalizedResource -Resource $resource
182210
}
183-
184-
return $map
185211
}
186212

187213
function ConvertTo-NormalizedPropertyValue {
@@ -203,95 +229,7 @@ function ConvertTo-NormalizedPropertyValue {
203229
[switch] $IgnoreRepresentationSchemaRefs
204230
)
205231

206-
if ($null -eq $Value) { return $null }
207-
208-
# --- String ---
209-
if ($Value -is [string]) {
210-
$s = $Value
211-
# Normalize ARM resource-ID paths (subscription, RG, service name)
212-
$s = $s -replace [regex]::Escape("/subscriptions/$SourceSub/resourceGroups/$SourceRg/providers/Microsoft.ApiManagement/service/$SourceName"), '/subscriptions/{{sub}}/resourceGroups/{{rg}}/providers/Microsoft.ApiManagement/service/{{apim-name}}'
213-
$s = $s -replace [regex]::Escape("/subscriptions/$TargetSub/resourceGroups/$TargetRg/providers/Microsoft.ApiManagement/service/$TargetName"), '/subscriptions/{{sub}}/resourceGroups/{{rg}}/providers/Microsoft.ApiManagement/service/{{apim-name}}'
214-
# Broader subscription/RG normalization for other resource types
215-
$s = $s -replace [regex]::Escape("/subscriptions/$SourceSub/resourceGroups/$SourceRg"), '/subscriptions/{{sub}}/resourceGroups/{{rg}}'
216-
$s = $s -replace [regex]::Escape("/subscriptions/$TargetSub/resourceGroups/$TargetRg"), '/subscriptions/{{sub}}/resourceGroups/{{rg}}'
217-
$s = $s -replace [regex]::Escape("/subscriptions/$SourceSub"), '/subscriptions/{{sub}}'
218-
$s = $s -replace [regex]::Escape("/subscriptions/$TargetSub"), '/subscriptions/{{sub}}'
219-
# Neutralize service name in any remaining positions
220-
$s = $s -replace [regex]::Escape($SourceName), '{{apim-name}}'
221-
$s = $s -replace [regex]::Escape($TargetName), '{{apim-name}}'
222-
# Normalize Key Vault URIs — different vault names per RG
223-
$s = $s -replace 'https://[a-zA-Z0-9-]+\.vault\.azure\.net', 'https://{{keyvault}}.vault.azure.net'
224-
# Normalize Key Vault secret names (src-* vs tgt-*)
225-
$s = $s -replace '/secrets/(src|tgt)-', '/secrets/{{prefix}}-'
226-
# Normalize App Insights resource IDs (different AI instance names per RG)
227-
$s = $s -replace '/providers/Microsoft\.Insights/components/[a-zA-Z0-9-]+', '/providers/Microsoft.Insights/components/{{appinsights}}'
228-
# Normalize Event Hub namespace names in resource IDs
229-
$s = $s -replace '/providers/Microsoft\.EventHub/namespaces/[a-zA-Z0-9-]+', '/providers/Microsoft.EventHub/namespaces/{{eventhub}}'
230-
# Normalize auto-generated APIM IDs (24-char hex strings like schema IDs, named value IDs)
231-
$s = $s -replace '\b[0-9a-f]{24}\b', '{{auto-id}}'
232-
# Normalize GUIDs
233-
$s = $s -replace '[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}', '{{guid}}'
234-
return $s
235-
}
236-
237-
# --- Array ---
238-
if ($Value -is [System.Collections.IEnumerable] -and $Value -isnot [string] -and $Value -isnot [System.Collections.IDictionary]) {
239-
$normalized = @(foreach ($item in $Value) {
240-
ConvertTo-NormalizedPropertyValue -Value $item `
241-
-SourceName $SourceName -TargetName $TargetName `
242-
-SourceSub $SourceSub -TargetSub $TargetSub `
243-
-SourceRg $SourceRg -TargetRg $TargetRg `
244-
-IgnoreRepresentationSchemaRefs:$IgnoreRepresentationSchemaRefs
245-
})
246-
# Sort for order-independent comparison
247-
$sorted = $normalized | Sort-Object { ($_ | ConvertTo-Json -Depth 50 -Compress) }
248-
return @($sorted)
249-
}
250-
251-
# --- Object / Hashtable ---
252-
if ($Value -is [System.Collections.IDictionary]) {
253-
$out = [ordered]@{}
254-
# Detect request/response objects (have 'representations' array) and representation items
255-
$isRequestResponse = $Value.Contains('representations')
256-
$isRepresentation = $Value.Contains('contentType') -or $Value.Contains('schemaId')
257-
foreach ($key in ($Value.Keys | Sort-Object)) {
258-
if ($IsRoot -and $key -in $StripReadOnlyProperties) { continue }
259-
if ($key -in $StripTimestampProperties) { continue } # Strip timestamps at any level
260-
if ($isRequestResponse -and $key -in $RequestResponseIgnoredProperties) { continue }
261-
if ($isRepresentation -and $key -in $RepresentationIgnoredProperties) { continue }
262-
if ($IgnoreRepresentationSchemaRefs -and $isRepresentation -and $key -in $SoapAutoGeneratedRepresentationIgnoredProperties) { continue }
263-
$out[$key] = ConvertTo-NormalizedPropertyValue -Value $Value[$key] `
264-
-SourceName $SourceName -TargetName $TargetName `
265-
-SourceSub $SourceSub -TargetSub $TargetSub `
266-
-SourceRg $SourceRg -TargetRg $TargetRg `
267-
-IgnoreRepresentationSchemaRefs:$IgnoreRepresentationSchemaRefs
268-
}
269-
return $out
270-
}
271-
272-
# PSCustomObject (from ConvertFrom-Json) — NOT primitives which also have PSObject
273-
if ($Value -is [PSCustomObject]) {
274-
$out = [ordered]@{}
275-
# Detect request/response objects (have 'representations' array) and representation items
276-
$isRequestResponse = $null -ne ($Value.PSObject.Properties | Where-Object { $_.Name -eq 'representations' })
277-
$isRepresentation = $null -ne ($Value.PSObject.Properties | Where-Object { $_.Name -eq 'contentType' -or $_.Name -eq 'schemaId' })
278-
foreach ($prop in ($Value.PSObject.Properties | Sort-Object Name)) {
279-
if ($IsRoot -and $prop.Name -in $StripReadOnlyProperties) { continue }
280-
if ($prop.Name -in $StripTimestampProperties) { continue } # Strip timestamps at any level
281-
if ($isRequestResponse -and $prop.Name -in $RequestResponseIgnoredProperties) { continue }
282-
if ($isRepresentation -and $prop.Name -in $RepresentationIgnoredProperties) { continue }
283-
if ($IgnoreRepresentationSchemaRefs -and $isRepresentation -and $prop.Name -in $SoapAutoGeneratedRepresentationIgnoredProperties) { continue }
284-
$out[$prop.Name] = ConvertTo-NormalizedPropertyValue -Value $prop.Value `
285-
-SourceName $SourceName -TargetName $TargetName `
286-
-SourceSub $SourceSub -TargetSub $TargetSub `
287-
-SourceRg $SourceRg -TargetRg $TargetRg `
288-
-IgnoreRepresentationSchemaRefs:$IgnoreRepresentationSchemaRefs
289-
}
290-
return $out
291-
}
292-
293-
# Primitive (int, bool, etc.)
294-
return $Value
232+
return CompareSemantics\ConvertTo-NormalizedPropertyValue -Value $Value -Context $NormalizationContext -IsRoot:$IsRoot -IgnoreRepresentationSchemaRefs:$IgnoreRepresentationSchemaRefs
295233
}
296234

297235
function ConvertTo-NormalizedResource {
@@ -304,54 +242,19 @@ function ConvertTo-NormalizedResource {
304242
[Parameter(Mandatory)] $Resource
305243
)
306244

307-
$resourceId = if ($Resource.PSObject.Properties['id']) { [string] $Resource.id } else { '' }
245+
$resourceId = if ($Resource.PSObject.Properties['id']) { [string]$Resource.id } else { '' }
308246
$ignoreRepresentationSchemaRefs = Test-ShouldIgnoreRepresentationSchemaRefs -ResourceId $resourceId
309-
310-
$clone = [ordered]@{}
311-
foreach ($prop in $Resource.PSObject.Properties) {
312-
if ($prop.Name -in $StripTopLevelFields) { continue }
313-
$clone[$prop.Name] = $prop.Value
314-
}
315-
316-
# Normalize the properties bag (read-only fields, instance names, etc.)
317-
if ($clone.Contains('properties')) {
318-
$clone['properties'] = ConvertTo-NormalizedPropertyValue -Value $clone['properties'] `
319-
-SourceName $SourceApimName -TargetName $TargetApimName `
320-
-SourceSub $SourceSubscriptionId -TargetSub $TargetSubscriptionId `
321-
-SourceRg $SourceResourceGroup -TargetRg $TargetResourceGroup `
322-
-IsRoot `
323-
-IgnoreRepresentationSchemaRefs:$ignoreRepresentationSchemaRefs
324-
}
325-
326-
# Normalize any other top-level bags (e.g., location, sku)
327-
foreach ($key in @($clone.Keys)) {
328-
if ($key -eq 'properties') { continue }
329-
$clone[$key] = ConvertTo-NormalizedPropertyValue -Value $clone[$key] `
330-
-SourceName $SourceApimName -TargetName $TargetApimName `
331-
-SourceSub $SourceSubscriptionId -TargetSub $TargetSubscriptionId `
332-
-SourceRg $SourceResourceGroup -TargetRg $TargetResourceGroup `
333-
-IgnoreRepresentationSchemaRefs:$ignoreRepresentationSchemaRefs
334-
}
335-
336-
return $clone
247+
return CompareSemantics\ConvertTo-NormalizedResource -Resource $Resource -Context $NormalizationContext -IgnoreRepresentationSchemaRefs:$ignoreRepresentationSchemaRefs
337248
}
338249

339250
function Test-ShouldIgnoreRepresentationSchemaRefs {
340251
<#
341252
.SYNOPSIS
342-
Returns $true for operation resources with auto-generated IDs, where
343-
schemaId/typeName can legitimately differ after SOAP/WSDL import.
253+
Returns $true for operation resources where representation schema refs
254+
are intentionally stripped during reconciliation PATCH.
344255
#>
345256
param([string] $ResourceId)
346-
347-
if (-not $ResourceId) { return $false }
348-
if ($ResourceId -notmatch '/operations/([^/]+)$') { return $false }
349-
350-
$operationName = $Matches[1]
351-
return (
352-
$operationName -match '^[0-9a-f]{24}$' -or
353-
$operationName -match '^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$'
354-
)
257+
return Test-IsApiOperationResource -ResourceId $ResourceId
355258
}
356259

357260
function Compare-NormalizedResources {
@@ -366,59 +269,7 @@ function Compare-NormalizedResources {
366269
[string] $Path = ''
367270
)
368271

369-
$diffs = [System.Collections.Generic.List[string]]::new()
370-
371-
$sourceJson = $Source | ConvertTo-Json -Depth 50 -Compress
372-
$targetJson = $Target | ConvertTo-Json -Depth 50 -Compress
373-
374-
if ($sourceJson -eq $targetJson) { return ,$diffs }
375-
376-
# Walk keys for a readable diff
377-
$allKeys = @()
378-
if ($Source -is [System.Collections.IDictionary]) { $allKeys += $Source.Keys }
379-
if ($Target -is [System.Collections.IDictionary]) { $allKeys += $Target.Keys }
380-
$allKeys = $allKeys | Select-Object -Unique | Sort-Object
381-
382-
foreach ($key in $allKeys) {
383-
$currentPath = if ($Path) { "$Path.$key" } else { $key }
384-
$hasSource = $Source -is [System.Collections.IDictionary] -and $Source.Contains($key)
385-
$hasTarget = $Target -is [System.Collections.IDictionary] -and $Target.Contains($key)
386-
387-
if ($hasSource -and -not $hasTarget) {
388-
$diffs.Add(" MISSING in target: $currentPath")
389-
continue
390-
}
391-
if (-not $hasSource -and $hasTarget) {
392-
$diffs.Add(" EXTRA in target: $currentPath")
393-
continue
394-
}
395-
396-
$sv = $Source[$key]
397-
$tv = $Target[$key]
398-
$svJson = $sv | ConvertTo-Json -Depth 50 -Compress
399-
$tvJson = $tv | ConvertTo-Json -Depth 50 -Compress
400-
401-
if ($svJson -ne $tvJson) {
402-
# If both are dicts, recurse for finer detail
403-
if ($sv -is [System.Collections.IDictionary] -and $tv -is [System.Collections.IDictionary]) {
404-
$sub = Compare-NormalizedResources -Source $sv -Target $tv -Path $currentPath
405-
if ($sub -is [System.Collections.IEnumerable] -and $sub -isnot [string]) {
406-
foreach ($d in $sub) { $diffs.Add($d) }
407-
}
408-
}
409-
else {
410-
$diffs.Add(" DIFF at $currentPath`n source: $svJson`n target: $tvJson")
411-
}
412-
}
413-
}
414-
415-
# Fallback: if JSON differs but no key-level diffs found, report the full diff
416-
if ($diffs.Count -eq 0) {
417-
$pathPrefix = if ($Path) { "${Path}: " } else { '' }
418-
$diffs.Add(" ${pathPrefix}JSON differs`n source: $sourceJson`n target: $targetJson")
419-
}
420-
421-
return ,$diffs
272+
return CompareSemantics\Compare-NormalizedResources -Source $Source -Target $Target -Path $Path
422273
}
423274

424275
function Test-SkipSecretValue {
@@ -535,8 +386,34 @@ function Compare-ResourceType {
535386
$srcResource = $sourceMap[$name]
536387
$tgtResource = $targetMap[$name]
537388

538-
$srcNorm = ConvertTo-NormalizedResource -Resource $srcResource
539-
$tgtNorm = ConvertTo-NormalizedResource -Resource $tgtResource
389+
# For API operations, compare representation schema semantics (schema payload)
390+
# rather than unstable schemaId/typeName values.
391+
$srcId = if ($srcResource.PSObject.Properties['id']) { [string]$srcResource.id } else { '' }
392+
$tgtId = if ($tgtResource.PSObject.Properties['id']) { [string]$tgtResource.id } else { '' }
393+
394+
if ((Test-IsApiOperationResource -ResourceId $srcId) -or (Test-IsApiOperationResource -ResourceId $tgtId)) {
395+
$srcWork = Copy-JsonObject -Value $srcResource
396+
$tgtWork = Copy-JsonObject -Value $tgtResource
397+
398+
$srcApiName = Get-ApiNameFromOperationResourceId -ResourceId $srcId
399+
$tgtApiName = Get-ApiNameFromOperationResourceId -ResourceId $tgtId
400+
$apiName = if ($srcApiName) { $srcApiName } else { $tgtApiName }
401+
402+
if ($apiName) {
403+
$srcSchemaMap = Get-ApiSchemaSemanticMap -InstanceKey 'source' -BaseUrl $SourceBase -ApiName $apiName
404+
$tgtSchemaMap = Get-ApiSchemaSemanticMap -InstanceKey 'target' -BaseUrl $TargetBase -ApiName $apiName
405+
406+
Add-RepresentationSchemaSemantics -Resource $srcWork -SchemaSemanticMap $srcSchemaMap
407+
Add-RepresentationSchemaSemantics -Resource $tgtWork -SchemaSemanticMap $tgtSchemaMap
408+
}
409+
410+
$srcNorm = ConvertTo-NormalizedResource -Resource $srcWork
411+
$tgtNorm = ConvertTo-NormalizedResource -Resource $tgtWork
412+
}
413+
else {
414+
$srcNorm = ConvertTo-NormalizedResource -Resource $srcResource
415+
$tgtNorm = ConvertTo-NormalizedResource -Resource $tgtResource
416+
}
540417

541418
# Skip secret named-value .value
542419
if ($SkipSecretValues -and (Test-SkipSecretValue $srcResource)) {

0 commit comments

Comments
 (0)