2828Set-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+
123182function 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
187213function 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
297235function 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
339250function 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
357260function 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
424275function 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