Skip to content

Commit 780d890

Browse files
CopilotEMaher
andauthored
feat: update all-types round-trip test to import Petstore Swagger (V2 and V3) (#171)
* feat: update all-types test to use petstore swagger (v2 and v3) - updating to honor original spec format of imported spec * Enhance round-trip comparison by refining ignored properties for API imports Closes #170 --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: Elizabeth Maher <enewman@microsoft.com>
1 parent c6982d5 commit 780d890

18 files changed

Lines changed: 703 additions & 60 deletions

.squad/agents/apimexpert/history.md

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -106,3 +106,13 @@ the SDK surface, reference docs, or ad-hoc observation.
106106
- Classic Developer/Premium SKU only.
107107
- Docs: <https://learn.microsoft.com/rest/api/apimanagement/policy-restriction> · <https://learn.microsoft.com/azure/templates/microsoft.apimanagement/service/policyrestrictions>
108108

109+
### 2026-06-20: Link-import vs inline-import operation fidelity (round-trip)
110+
111+
When comparing a *link-imported* API (e.g. Petstore via `swagger-link`/`openapi-link`) against the `apiops publish` *inline-imported* result, operation payloads diverge in two import-path-only ways — neither is an apiops bug:
112+
113+
1. **`schemaId`/`typeName` on representations.** APIM binds operation request/response representations to an API-level schema **only on link import**. Inline import (`format: openapi, value: <spec>`, what publish uses) does NOT rebind, so the target has no `schemaId`. The schema *content* is identical and still extracted as API-level Schemas. → strip `schemaId`, `typeName` (and any derived schema token) on operation resources.
114+
115+
2. **Parameter/header `description`.** Link import drops `templateParameters`/`queryParameters`/header descriptions; inline import preserves them from the spec. So the published **target is more faithful** than the link-imported source. Authoritative descriptions live in the API schema. → strip `description` on parameter-shaped objects (`{ name, …, values }`).
116+
117+
Round-trip comparison harness (`tests/integration/all-resource-types`) normalizes both via `RepresentationSchemaRefIgnoredProperties` and `ParameterIgnoredProperties`. Symptom if not stripped: every operation shows a `properties.request/responses/templateParameters` DIFF present-on-one-side-only.
118+

.squad/decisions/decisions.md

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -117,3 +117,18 @@ All new `.ts` files in `src/` and `tests/` must include:
117117
**Constitution compliance:** §I, §III, §IV, §V, §VI, §VII, §VIII
118118

119119
---
120+
121+
### 2026-06-19: Spec dialect (Swagger 2.0 vs OpenAPI 3.0) is orthogonal to APIM `apiType`
122+
**By:** CodeReviewer
123+
**Status:** Approved
124+
**Context:** Round-trip extract→publish produced diffs on a natively Swagger 2.0 REST API.
125+
**Learned:**
126+
- APIM's `properties.type` (`http`/`soap`/`graphql`/…) does **not** encode spec dialect — both Swagger 2.0 and OpenAPI 3.0 REST APIs are `type=http`. Dialect is a separate axis and must be detected, not inferred from type.
127+
- Detect dialect from the auto-generated schema's content type: `application/vnd.ms-azure-apim.swagger.definitions+json` ⇒ Swagger 2.0, `application/vnd.oai.openapi.components+json` ⇒ OpenAPI 3.0. (Spec body itself: top-level `"swagger":"2.0"`.)
128+
- APIM's `openapi-link` export **silently converts** Swagger 2.0 → OpenAPI 3.0, dropping parameter-level metadata (e.g. `format: int64`) and rewriting schema content types — a §VII silent-data-loss trap. Fidelity requires exporting via `swagger-link` and importing via `swagger-json` (JSON only; there is no inline Swagger YAML import format).
129+
- Do **not** overload `apiType` with a synthetic value like `'Swagger2'`: `apiType` is a real APIM property echoed back on PUT and validated, so it must stay within the type enum. Carry dialect as its own parameter/type instead.
130+
- Swagger 2.0 path parameters use the inline shape `{ name, in, required, type }`; OpenAPI 3.x wraps it as `{ ..., schema: { type } }`. Any injected/sanitized params must match the document's dialect.
131+
**Decision:** Introduced `ApiSpecDialect = 'openapi3' | 'swagger2'` as a first-class, detected axis threaded through export and import; preferred an explicit dialect type over a boolean or an overloaded `apiType`.
132+
**Constitution compliance:** §II (APIM-native formats), §VII (no silent round-trip loss)
133+
134+
---

src/cli/extract-command.ts

Lines changed: 1 addition & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
/**
44
* Extract command CLI registration
55
* Commander subcommand with --resource-group, --service-name, --output,
6-
* --filter, --no-transitive, --spec-format flags.
6+
* --filter, --no-transitive flags.
77
* Includes --format json: machine-readable JSON output mode.
88
*/
99

@@ -26,7 +26,6 @@ interface ExtractOptions {
2626
output: string;
2727
filter?: string;
2828
transitive: boolean;
29-
specFormat?: string;
3029
}
3130

3231
/**
@@ -40,7 +39,6 @@ export function createExtractCommand(): Command {
4039
.option('--output <dir>', 'Output directory path', './apim-artifacts')
4140
.option('--filter <path>', 'Filter configuration YAML file')
4241
.option('--no-transitive', 'Disable transitive dependency inclusion')
43-
.option('--spec-format <format>', 'API specification format (openapi-v2-json, openapi-v3-json, openapi-v3-yaml)')
4442
.action(async (options: ExtractOptions, command: Command) => {
4543
const globalOpts = command.optsWithGlobals<{
4644
logLevel?: string;
@@ -109,7 +107,6 @@ async function executeExtract(
109107
outputDir: options.output,
110108
filter: filterConfig,
111109
includeTransitive: options.transitive,
112-
specFormat: options.specFormat,
113110
logLevel: parseLogLevel(globalOpts.logLevel ?? 'info'),
114111
};
115112

src/clients/apim-client.ts

Lines changed: 14 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@
77
*/
88

99
import { DefaultAzureCredential } from '@azure/identity';
10-
import { IApimClient } from './iapim-client.js';
10+
import { IApimClient, ApiSpecDialect } from './iapim-client.js';
1111
import { ApimServiceContext, ResourceDescriptor } from '../models/types.js';
1212
import { RESOURCE_TYPE_METADATA, ResourceType } from '../models/resource-types.js';
1313
import { buildArmUri, buildResourceLabel } from '../lib/resource-uri.js';
@@ -503,9 +503,10 @@ export class ApimClient implements IApimClient {
503503
async getApiSpecification(
504504
context: ApimServiceContext,
505505
apiName: string,
506-
apiType?: string
506+
apiType?: string,
507+
specDialect?: ApiSpecDialect
507508
): Promise<{ content: string; format: 'yaml' | 'json' | 'graphql' | 'wsdl' | 'wadl' } | undefined> {
508-
const exportFormat = this.getExportFormat(apiType);
509+
const exportFormat = this.getExportFormat(apiType, specDialect);
509510
if (exportFormat === undefined) {
510511
return undefined;
511512
}
@@ -677,23 +678,29 @@ export class ApimClient implements IApimClient {
677678
* round-tripping is preserved even when the link variant is broken.
678679
*
679680
* Additional export formats supported by APIM for type=http REST APIs:
680-
* swagger-link – Swagger 2.0 YAML
681+
* swagger-link – Swagger 2.0 JSON
681682
* openapi+json-link – OpenAPI 3.0 JSON
682683
* wadl-link – WADL
683684
* These cannot be auto-selected from properties.type alone because all REST APIs
684685
* (whether originally imported as Swagger 2.0, OpenAPI 3.0, or WADL) share
685-
* type=http in APIM. openapi-link is used as the preferred modern default.
686+
* type=http in APIM. The caller passes `specDialect` so the export format
687+
* matches the API's native source format: 'swagger2' exports via `swagger-link`,
688+
* 'openapi3' (default) via `openapi-link`. This preserves the original format
689+
* (and its schema/operation metadata) instead of converting Swagger 2.0 to
690+
* OpenAPI 3.0 on export.
686691
*
687692
* Returns undefined for WebSocket APIs — they have no traditional API specification.
688693
* getApiSpecification will return undefined early when this method returns undefined.
689694
*/
690-
private getExportFormat(apiType?: string): string | undefined {
695+
private getExportFormat(apiType?: string, specDialect?: ApiSpecDialect): string | undefined {
691696
switch (apiType?.toLowerCase()) {
692697
case 'graphql': return 'graphql-link';
693698
case 'soap': return 'wsdl-link';
694699
case 'websocket': return undefined;
695700
case 'a2a': return undefined;
696-
default: return 'openapi-link';
701+
// REST (http / odata / grpc / undefined): match the source dialect —
702+
// export Swagger 2.0 when the API was natively Swagger 2.0, else OpenAPI 3.0.
703+
default: return specDialect === 'swagger2' ? 'swagger-link' : 'openapi-link';
697704
}
698705
}
699706

src/clients/iapim-client.ts

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,16 @@
88
import { ApimServiceContext, ResourceDescriptor } from '../models/types.js';
99
import { ResourceType } from '../models/resource-types.js';
1010

11+
/**
12+
* Spec dialect for a REST (type=http) API. Both dialects share APIM's
13+
* `type=http`, so the dialect is an orthogonal axis to {@link ResourceType}/
14+
* `apiType` and is detected from the API's schema content type rather than
15+
* stored on the resource type.
16+
* - 'openapi3': OpenAPI 3.x
17+
* - 'swagger2': Swagger / OpenAPI 2.0
18+
*/
19+
export type ApiSpecDialect = 'openapi3' | 'swagger2';
20+
1121
export interface IApimClient {
1222
/**
1323
* List all resources of a given type. Handles ARM pagination (nextLink).
@@ -74,11 +84,15 @@ export interface IApimClient {
7484
* Returns the raw content string and detected format.
7585
* @param apiType - Optional API type from properties.type (e.g. 'graphql', 'soap', 'http').
7686
* Used to select the correct APIM export format. Defaults to OpenAPI link export.
87+
* @param specDialect - For a REST (http) API, the spec dialect to export so the
88+
* exported spec matches the API's native source format: 'swagger2' exports via
89+
* `swagger-link`, 'openapi3' (default) via `openapi-link`.
7790
*/
7891
getApiSpecification(
7992
context: ApimServiceContext,
8093
apiName: string,
81-
apiType?: string
94+
apiType?: string,
95+
specDialect?: ApiSpecDialect
8296
): Promise<
8397
| { content: string; format: 'yaml' | 'json' | 'graphql' | 'wsdl' | 'wadl' }
8498
| undefined

src/models/config.ts

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,6 @@ export interface ExtractConfig {
1313
outputDir: string;
1414
filter?: FilterConfig;
1515
includeTransitive: boolean;
16-
specFormat?: string;
1716
logLevel: LogLevel;
1817
otelConfigPath?: string;
1918
}

src/services/api-extractor.ts

Lines changed: 30 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@
77
* releases, tag descriptions, wikis.
88
*/
99

10-
import { IApimClient } from '../clients/iapim-client.js';
10+
import { IApimClient, ApiSpecDialect } from '../clients/iapim-client.js';
1111
import { IArtifactStore } from '../clients/iartifact-store.js';
1212
import { ApimServiceContext, ResourceDescriptor } from '../models/types.js';
1313
import { ResourceType, RESOURCE_TYPE_METADATA } from '../models/resource-types.js';
@@ -268,8 +268,17 @@ async function extractApiSpecification(
268268
return false;
269269
}
270270

271+
// REST APIs natively imported as Swagger 2.0 expose auto-generated schemas
272+
// with the Swagger-definitions content type. Export them in their native
273+
// dialect so the exported spec matches the API's source format (OpenAPI 3.0
274+
// export would convert schema content types and drop parameter-level metadata
275+
// like `format`).
276+
const specDialect: ApiSpecDialect = hasSwaggerDefinitionSchema(extractedSchemas)
277+
? 'swagger2'
278+
: 'openapi3';
279+
271280
try {
272-
const spec = await client.getApiSpecification(context, getNamePart(apiDescriptor.nameParts, 0), apiType);
281+
const spec = await client.getApiSpecification(context, getNamePart(apiDescriptor.nameParts, 0), apiType, specDialect);
273282
if (!spec) {
274283
logger.debug(`No specification found for API "${getNamePart(apiDescriptor.nameParts, 0)}"`);
275284
return false;
@@ -310,6 +319,25 @@ function hasGraphQLSchema(schemas: ExtractedResource[]): boolean {
310319
return false;
311320
}
312321

322+
/**
323+
* Returns true if any already-extracted ApiSchema resource has the Swagger 2.0
324+
* definitions content type (`application/vnd.ms-azure-apim.swagger.definitions+json`).
325+
* APIM stamps this content type on the auto-generated schema of APIs that were
326+
* natively imported as Swagger 2.0, whereas OpenAPI 3.0 APIs use
327+
* `application/vnd.oai.openapi.components+json`. Used to select Swagger 2.0
328+
* export so the original spec format round-trips faithfully.
329+
*/
330+
function hasSwaggerDefinitionSchema(schemas: ExtractedResource[]): boolean {
331+
for (const schema of schemas) {
332+
const props = schema.json.properties as Record<string, unknown> | undefined;
333+
const contentType = (props?.contentType as string | undefined)?.toLowerCase() ?? '';
334+
if (contentType.includes('swagger.definitions')) {
335+
return true;
336+
}
337+
}
338+
return false;
339+
}
340+
313341
/**
314342
* Extract API-level policy.
315343
*/

src/services/api-publisher.ts

Lines changed: 46 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@
77
* Handle SOAP/WSDL import via ?import=true&format=wsdl-link query parameter.
88
*/
99

10-
import type { IApimClient } from '../clients/iapim-client.js';
10+
import type { IApimClient, ApiSpecDialect } from '../clients/iapim-client.js';
1111
import type { IArtifactStore } from '../clients/iartifact-store.js';
1212
import type { ApimServiceContext, ResourceDescriptor } from '../models/types.js';
1313
import type { PublishConfig } from '../models/config.js';
@@ -108,8 +108,11 @@ export async function publishApi(
108108

109109
/**
110110
* Maps spec file format to APIM ContentFormat for inline import.
111+
* @param specDialect - The spec dialect of a JSON spec. Swagger 2.0 ('swagger2')
112+
* must be imported via `swagger-json` so its native format (and schema/operation
113+
* metadata) is preserved; OpenAPI 3.x ('openapi3') uses `openapi+json`.
111114
*/
112-
function getImportFormat(specFormat: string, _apiType?: string): string | undefined {
115+
function getImportFormat(specFormat: string, _apiType?: string, specDialect?: ApiSpecDialect): string | undefined {
113116
const apiType = _apiType?.toLowerCase();
114117
if (apiType === 'a2a') {
115118
return undefined;
@@ -119,9 +122,10 @@ function getImportFormat(specFormat: string, _apiType?: string): string | undefi
119122
case 'yaml':
120123
return 'openapi';
121124
case 'json':
122-
// Swagger 2.0 uses swagger-json, OpenAPI 3.x uses openapi+json.
123-
// Default to openapi+json as the more modern format — APIM accepts both.
124-
return 'openapi+json';
125+
// Match the source dialect: Swagger 2.0 uses swagger-json, OpenAPI 3.x uses
126+
// openapi+json. APIM accepts both, but importing a Swagger 2.0 document as
127+
// openapi+json would convert it to OpenAPI 3.0 and lose fidelity.
128+
return specDialect === 'swagger2' ? 'swagger-json' : 'openapi+json';
125129
case 'wsdl':
126130
return 'wsdl';
127131
case 'wadl':
@@ -189,7 +193,8 @@ async function publishRootApi(
189193
if (specResult) {
190194
const properties = json.properties as Record<string, unknown> | undefined;
191195
const apiType = properties?.type as string | undefined;
192-
const importFormat = getImportFormat(specResult.format ?? 'yaml', apiType);
196+
const specDialect = detectSpecDialect(specResult.content, specResult.format);
197+
const importFormat = getImportFormat(specResult.format ?? 'yaml', apiType, specDialect);
193198

194199
if (importFormat) {
195200
// Strip null-valued properties that cause validation errors in
@@ -221,7 +226,7 @@ async function publishRootApi(
221226
},
222227
};
223228

224-
if (importFormat === 'openapi' || importFormat === 'openapi+json') {
229+
if (importFormat === 'openapi' || importFormat === 'openapi+json' || importFormat === 'swagger-json') {
225230
operationIdsWithNullDescription = getOpenApiOperationIdsWithNullDescription(
226231
specResult.content,
227232
specResult.format
@@ -617,14 +622,36 @@ function getOpenApiOperationIdsWithNullDescription(
617622
}
618623
}
619624

625+
/**
626+
* Detects the spec dialect of a JSON spec document. Returns 'swagger2' when the
627+
* content is a Swagger 2.0 JSON document (top-level `"swagger": "2.0"`),
628+
* otherwise 'openapi3'. Only JSON specs are inspected because APIM's Swagger 2.0
629+
* inline import (`swagger-json`) accepts JSON only; YAML specs are treated as
630+
* OpenAPI 3.x.
631+
*/
632+
function detectSpecDialect(content: string, format: string | undefined): ApiSpecDialect {
633+
const isJson =
634+
!format ||
635+
format.toLowerCase().includes('json') ||
636+
content.trimStart().startsWith('{');
637+
if (!isJson) return 'openapi3';
638+
try {
639+
const spec = JSON.parse(content) as Record<string, unknown>;
640+
return spec.swagger === '2.0' ? 'swagger2' : 'openapi3';
641+
} catch {
642+
return 'openapi3';
643+
}
644+
}
645+
620646
/**
621647
* Sanitize an OpenAPI spec before importing into APIM.
622648
* Currently handles:
623649
* - Missing path parameter declarations: APIM rejects specs where a URL
624650
* template contains `{param}` placeholders that are not declared in the
625651
* operation's `parameters` array. We auto-inject the missing declarations
626-
* as `{ name, in: "path", required: true, schema: { type: "string" } }`
627-
* and log a warning for each one.
652+
* using the document's native parameter shape — OpenAPI 3.x uses
653+
* `{ ..., schema: { type: "string" } }` while Swagger 2.0 uses
654+
* `{ ..., type: "string" }` — and log a warning for each one.
628655
*/
629656
function sanitizeOpenApiSpec(
630657
content: string,
@@ -645,6 +672,11 @@ function sanitizeOpenApiSpec(
645672
return content; // Not valid JSON — return as-is
646673
}
647674

675+
// Swagger 2.0 path parameters are declared inline (`type: string`) rather
676+
// than wrapped in a `schema` object as in OpenAPI 3.x. Inject the matching
677+
// shape so the document stays valid for its native import format.
678+
const isSwagger2 = spec.swagger === '2.0';
679+
648680
const paths = spec.paths as Record<string, Record<string, unknown>> | undefined;
649681
if (!paths) return content;
650682

@@ -672,13 +704,11 @@ function sanitizeOpenApiSpec(
672704

673705
for (const name of placeholderNames) {
674706
if (!declaredPathParams.has(name)) {
675-
// Inject the missing parameter declaration
676-
const newParam: Record<string, unknown> = {
677-
name,
678-
in: 'path',
679-
required: true,
680-
schema: { type: 'string' },
681-
};
707+
// Inject the missing parameter declaration using the document's
708+
// native parameter shape (Swagger 2.0 vs OpenAPI 3.x).
709+
const newParam: Record<string, unknown> = isSwagger2
710+
? { name, in: 'path', required: true, type: 'string' }
711+
: { name, in: 'path', required: true, schema: { type: 'string' } };
682712
const updatedParams = [...params, newParam];
683713
op.parameters = updatedParams;
684714
// Update params reference for subsequent placeholder checks on this operation

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

Lines changed: 14 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -58,12 +58,19 @@ $StripTimestampProperties = @(
5858
$RequestResponseIgnoredProperties = @('description')
5959

6060
# Properties ignored on representation objects (have 'contentType' or 'schemaId'):
61-
# - description: SOAP/WSDL import generates descriptions that vary
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.
61+
# - description: varies by import path.
62+
# - schemaId/typeName: unstable schema refs APIM rebinds on import; stripped for ops.
63+
# - __schemaSemantic: synthetic token from Add-RepresentationSchemaSemantics. Only
64+
# link-imported source gets schemaId (and thus this token); inline-imported target
65+
# doesn't. Schema content is compared separately via API-level Schemas, so strip it.
6566
$RepresentationIgnoredProperties = @('description')
66-
$RepresentationSchemaRefIgnoredProperties = @('schemaId', 'typeName')
67+
$RepresentationSchemaRefIgnoredProperties = @('schemaId', 'typeName', '__schemaSemantic')
68+
69+
# Properties ignored on parameter/header objects (have 'name' + 'values':
70+
# templateParameters, queryParameters, response headers):
71+
# - description: link import (source) drops them; inline import (target) keeps them.
72+
# Authoritative copies live in the API schema, compared separately.
73+
$ParameterIgnoredProperties = @('description')
6774

6875
# Cache of normalized API schema semantics per instance/api, keyed as:
6976
# "{instance}|{apiName}" => @{ schemaId => normalizedSchemaJson }
@@ -78,7 +85,8 @@ $NormalizationContext = New-CompareNormalizationContext `
7885
-StripTimestampProperties $StripTimestampProperties `
7986
-RequestResponseIgnoredProperties $RequestResponseIgnoredProperties `
8087
-RepresentationIgnoredProperties $RepresentationIgnoredProperties `
81-
-RepresentationSchemaRefIgnoredProperties $RepresentationSchemaRefIgnoredProperties
88+
-RepresentationSchemaRefIgnoredProperties $RepresentationSchemaRefIgnoredProperties `
89+
-ParameterIgnoredProperties $ParameterIgnoredProperties
8290

8391
# ── Helpers ─────────────────────────────────────────────────────────────────
8492

0 commit comments

Comments
 (0)