Skip to content

Commit fd888d4

Browse files
committed
updating to honor original spec format
1 parent 837a6eb commit fd888d4

7 files changed

Lines changed: 242 additions & 27 deletions

File tree

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';
@@ -493,9 +493,10 @@ export class ApimClient implements IApimClient {
493493
async getApiSpecification(
494494
context: ApimServiceContext,
495495
apiName: string,
496-
apiType?: string
496+
apiType?: string,
497+
specDialect?: ApiSpecDialect
497498
): Promise<{ content: string; format: 'yaml' | 'json' | 'graphql' | 'wsdl' | 'wadl' } | undefined> {
498-
const exportFormat = this.getExportFormat(apiType);
499+
const exportFormat = this.getExportFormat(apiType, specDialect);
499500
if (exportFormat === undefined) {
500501
return undefined;
501502
}
@@ -667,23 +668,29 @@ export class ApimClient implements IApimClient {
667668
* round-tripping is preserved even when the link variant is broken.
668669
*
669670
* Additional export formats supported by APIM for type=http REST APIs:
670-
* swagger-link – Swagger 2.0 YAML
671+
* swagger-link – Swagger 2.0 JSON
671672
* openapi+json-link – OpenAPI 3.0 JSON
672673
* wadl-link – WADL
673674
* These cannot be auto-selected from properties.type alone because all REST APIs
674675
* (whether originally imported as Swagger 2.0, OpenAPI 3.0, or WADL) share
675-
* type=http in APIM. openapi-link is used as the preferred modern default.
676+
* type=http in APIM. The caller passes `specDialect` so the export format
677+
* matches the API's native source format: 'swagger2' exports via `swagger-link`,
678+
* 'openapi3' (default) via `openapi-link`. This preserves the original format
679+
* (and its schema/operation metadata) instead of converting Swagger 2.0 to
680+
* OpenAPI 3.0 on export.
676681
*
677682
* Returns undefined for WebSocket APIs — they have no traditional API specification.
678683
* getApiSpecification will return undefined early when this method returns undefined.
679684
*/
680-
private getExportFormat(apiType?: string): string | undefined {
685+
private getExportFormat(apiType?: string, specDialect?: ApiSpecDialect): string | undefined {
681686
switch (apiType?.toLowerCase()) {
682687
case 'graphql': return 'graphql-link';
683688
case 'soap': return 'wsdl-link';
684689
case 'websocket': return undefined;
685690
case 'a2a': return undefined;
686-
default: return 'openapi-link';
691+
// REST (http / odata / grpc / undefined): match the source dialect —
692+
// export Swagger 2.0 when the API was natively Swagger 2.0, else OpenAPI 3.0.
693+
default: return specDialect === 'swagger2' ? 'swagger-link' : 'openapi-link';
687694
}
688695
}
689696

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/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';
@@ -380,8 +380,17 @@ async function extractApiSpecification(
380380
return false;
381381
}
382382

383+
// REST APIs natively imported as Swagger 2.0 expose auto-generated schemas
384+
// with the Swagger-definitions content type. Export them in their native
385+
// dialect so the exported spec matches the API's source format (OpenAPI 3.0
386+
// export would convert schema content types and drop parameter-level metadata
387+
// like `format`).
388+
const specDialect: ApiSpecDialect = hasSwaggerDefinitionSchema(extractedSchemas)
389+
? 'swagger2'
390+
: 'openapi3';
391+
383392
try {
384-
const spec = await client.getApiSpecification(context, getNamePart(apiDescriptor.nameParts, 0), apiType);
393+
const spec = await client.getApiSpecification(context, getNamePart(apiDescriptor.nameParts, 0), apiType, specDialect);
385394
if (!spec) {
386395
logger.debug(`No specification found for API "${getNamePart(apiDescriptor.nameParts, 0)}"`);
387396
return false;
@@ -422,6 +431,25 @@ function hasGraphQLSchema(schemas: ExtractedResource[]): boolean {
422431
return false;
423432
}
424433

434+
/**
435+
* Returns true if any already-extracted ApiSchema resource has the Swagger 2.0
436+
* definitions content type (`application/vnd.ms-azure-apim.swagger.definitions+json`).
437+
* APIM stamps this content type on the auto-generated schema of APIs that were
438+
* natively imported as Swagger 2.0, whereas OpenAPI 3.0 APIs use
439+
* `application/vnd.oai.openapi.components+json`. Used to select Swagger 2.0
440+
* export so the original spec format round-trips faithfully.
441+
*/
442+
function hasSwaggerDefinitionSchema(schemas: ExtractedResource[]): boolean {
443+
for (const schema of schemas) {
444+
const props = schema.json.properties as Record<string, unknown> | undefined;
445+
const contentType = (props?.contentType as string | undefined)?.toLowerCase() ?? '';
446+
if (contentType.includes('swagger.definitions')) {
447+
return true;
448+
}
449+
}
450+
return false;
451+
}
452+
425453
/**
426454
* Extract API-level policy.
427455
*/

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';
@@ -105,8 +105,11 @@ export async function publishApi(
105105

106106
/**
107107
* Maps spec file format to APIM ContentFormat for inline import.
108+
* @param specDialect - The spec dialect of a JSON spec. Swagger 2.0 ('swagger2')
109+
* must be imported via `swagger-json` so its native format (and schema/operation
110+
* metadata) is preserved; OpenAPI 3.x ('openapi3') uses `openapi+json`.
108111
*/
109-
function getImportFormat(specFormat: string, _apiType?: string): string | undefined {
112+
function getImportFormat(specFormat: string, _apiType?: string, specDialect?: ApiSpecDialect): string | undefined {
110113
const apiType = _apiType?.toLowerCase();
111114
if (apiType === 'a2a') {
112115
return undefined;
@@ -116,9 +119,10 @@ function getImportFormat(specFormat: string, _apiType?: string): string | undefi
116119
case 'yaml':
117120
return 'openapi';
118121
case 'json':
119-
// Swagger 2.0 uses swagger-json, OpenAPI 3.x uses openapi+json.
120-
// Default to openapi+json as the more modern format — APIM accepts both.
121-
return 'openapi+json';
122+
// Match the source dialect: Swagger 2.0 uses swagger-json, OpenAPI 3.x uses
123+
// openapi+json. APIM accepts both, but importing a Swagger 2.0 document as
124+
// openapi+json would convert it to OpenAPI 3.0 and lose fidelity.
125+
return specDialect === 'swagger2' ? 'swagger-json' : 'openapi+json';
122126
case 'wsdl':
123127
return 'wsdl';
124128
case 'wadl':
@@ -181,7 +185,8 @@ async function publishRootApi(
181185
if (specResult) {
182186
const properties = json.properties as Record<string, unknown> | undefined;
183187
const apiType = properties?.type as string | undefined;
184-
const importFormat = getImportFormat(specResult.format ?? 'yaml', apiType);
188+
const specDialect = detectSpecDialect(specResult.content, specResult.format);
189+
const importFormat = getImportFormat(specResult.format ?? 'yaml', apiType, specDialect);
185190

186191
if (importFormat) {
187192
// Strip null-valued properties that cause validation errors in
@@ -213,7 +218,7 @@ async function publishRootApi(
213218
},
214219
};
215220

216-
if (importFormat === 'openapi' || importFormat === 'openapi+json') {
221+
if (importFormat === 'openapi' || importFormat === 'openapi+json' || importFormat === 'swagger-json') {
217222
operationIdsWithNullDescription = getOpenApiOperationIdsWithNullDescription(
218223
specResult.content,
219224
specResult.format
@@ -609,14 +614,36 @@ function getOpenApiOperationIdsWithNullDescription(
609614
}
610615
}
611616

617+
/**
618+
* Detects the spec dialect of a JSON spec document. Returns 'swagger2' when the
619+
* content is a Swagger 2.0 JSON document (top-level `"swagger": "2.0"`),
620+
* otherwise 'openapi3'. Only JSON specs are inspected because APIM's Swagger 2.0
621+
* inline import (`swagger-json`) accepts JSON only; YAML specs are treated as
622+
* OpenAPI 3.x.
623+
*/
624+
function detectSpecDialect(content: string, format: string | undefined): ApiSpecDialect {
625+
const isJson =
626+
!format ||
627+
format.toLowerCase().includes('json') ||
628+
content.trimStart().startsWith('{');
629+
if (!isJson) return 'openapi3';
630+
try {
631+
const spec = JSON.parse(content) as Record<string, unknown>;
632+
return spec.swagger === '2.0' ? 'swagger2' : 'openapi3';
633+
} catch {
634+
return 'openapi3';
635+
}
636+
}
637+
612638
/**
613639
* Sanitize an OpenAPI spec before importing into APIM.
614640
* Currently handles:
615641
* - Missing path parameter declarations: APIM rejects specs where a URL
616642
* template contains `{param}` placeholders that are not declared in the
617643
* operation's `parameters` array. We auto-inject the missing declarations
618-
* as `{ name, in: "path", required: true, schema: { type: "string" } }`
619-
* and log a warning for each one.
644+
* using the document's native parameter shape — OpenAPI 3.x uses
645+
* `{ ..., schema: { type: "string" } }` while Swagger 2.0 uses
646+
* `{ ..., type: "string" }` — and log a warning for each one.
620647
*/
621648
function sanitizeOpenApiSpec(
622649
content: string,
@@ -637,6 +664,11 @@ function sanitizeOpenApiSpec(
637664
return content; // Not valid JSON — return as-is
638665
}
639666

667+
// Swagger 2.0 path parameters are declared inline (`type: string`) rather
668+
// than wrapped in a `schema` object as in OpenAPI 3.x. Inject the matching
669+
// shape so the document stays valid for its native import format.
670+
const isSwagger2 = spec.swagger === '2.0';
671+
640672
const paths = spec.paths as Record<string, Record<string, unknown>> | undefined;
641673
if (!paths) return content;
642674

@@ -664,13 +696,11 @@ function sanitizeOpenApiSpec(
664696

665697
for (const name of placeholderNames) {
666698
if (!declaredPathParams.has(name)) {
667-
// Inject the missing parameter declaration
668-
const newParam: Record<string, unknown> = {
669-
name,
670-
in: 'path',
671-
required: true,
672-
schema: { type: 'string' },
673-
};
699+
// Inject the missing parameter declaration using the document's
700+
// native parameter shape (Swagger 2.0 vs OpenAPI 3.x).
701+
const newParam: Record<string, unknown> = isSwagger2
702+
? { name, in: 'path', required: true, type: 'string' }
703+
: { name, in: 'path', required: true, schema: { type: 'string' } };
674704
const updatedParams = [...params, newParam];
675705
op.parameters = updatedParams;
676706
// Update params reference for subsequent placeholder checks on this operation

tests/unit/clients/apim-client.test.ts

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1146,6 +1146,51 @@ describe('ApimClient.getApiSpecification', () => {
11461146
expect(apimCallUrl).toContain('format=openapi-link');
11471147
});
11481148

1149+
it('should use swagger-link format when source dialect is swagger2', async () => {
1150+
fetchSpy.mockResolvedValueOnce(
1151+
new Response(JSON.stringify({ value: {} }), {
1152+
status: 200,
1153+
headers: { 'Content-Type': 'application/json' },
1154+
})
1155+
);
1156+
1157+
await client.getApiSpecification(testContext, 'rest-api', 'http', 'swagger2');
1158+
1159+
const apimCallUrl = fetchSpy.mock.calls[0][0] as string;
1160+
expect(apimCallUrl).toContain('format=swagger-link');
1161+
expect(apimCallUrl).not.toContain('format=openapi-link');
1162+
});
1163+
1164+
it('should still use openapi-link when source dialect is openapi3', async () => {
1165+
fetchSpy.mockResolvedValueOnce(
1166+
new Response(JSON.stringify({ value: {} }), {
1167+
status: 200,
1168+
headers: { 'Content-Type': 'application/json' },
1169+
})
1170+
);
1171+
1172+
await client.getApiSpecification(testContext, 'rest-api', 'http', 'openapi3');
1173+
1174+
const apimCallUrl = fetchSpy.mock.calls[0][0] as string;
1175+
expect(apimCallUrl).toContain('format=openapi-link');
1176+
expect(apimCallUrl).not.toContain('format=swagger-link');
1177+
});
1178+
1179+
it('should ignore specDialect for non-REST apiTypes (graphql stays graphql-link)', async () => {
1180+
fetchSpy.mockResolvedValueOnce(
1181+
new Response(JSON.stringify({ value: {} }), {
1182+
status: 200,
1183+
headers: { 'Content-Type': 'application/json' },
1184+
})
1185+
);
1186+
1187+
await client.getApiSpecification(testContext, 'gql-api', 'graphql', 'swagger2');
1188+
1189+
const apimCallUrl = fetchSpy.mock.calls[0][0] as string;
1190+
expect(apimCallUrl).toContain('format=graphql-link');
1191+
expect(apimCallUrl).not.toContain('format=swagger-link');
1192+
});
1193+
11491194
it('should return undefined for websocket apiType without making an HTTP request', async () => {
11501195
// WebSocket APIs have no traditional API specification. getExportFormat returns
11511196
// undefined for websocket, causing getApiSpecification to short-circuit.

tests/unit/services/api-product-extractor.test.ts

Lines changed: 62 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -190,7 +190,7 @@ describe('api-extractor', () => {
190190
);
191191

192192
expect(result.specification).toBe(true);
193-
expect(getApiSpecification).toHaveBeenCalledWith(testContext, 'linked-gql', 'graphql');
193+
expect(getApiSpecification).toHaveBeenCalledWith(testContext, 'linked-gql', 'graphql', 'openapi3');
194194
expect(store.writeContent).toHaveBeenCalledWith(
195195
'/output',
196196
expect.objectContaining({ nameParts: ['linked-gql'] }),
@@ -200,6 +200,67 @@ describe('api-extractor', () => {
200200
);
201201
});
202202

203+
it('should export the swagger2 dialect when the API has a Swagger-definitions schema', async () => {
204+
const getApiSpecification = vi.fn().mockResolvedValue({
205+
content: '{"swagger":"2.0"}',
206+
format: 'json',
207+
});
208+
const client = createMockClient({
209+
getApiSpecification,
210+
// Yield a Swagger 2.0 auto-generated schema for the ApiSchema list step
211+
listResources: async function* (_ctx: ApimServiceContext, type: ResourceType) {
212+
if (type === ResourceType.ApiSchema) {
213+
yield {
214+
name: 'swagger-defs',
215+
properties: { contentType: 'application/vnd.ms-azure-apim.swagger.definitions+json' },
216+
};
217+
}
218+
},
219+
getResource: vi.fn().mockResolvedValue(undefined),
220+
});
221+
const store = createMockStore();
222+
223+
const result = await extractApiResources(
224+
client, store, testContext,
225+
{ type: ResourceType.Api, nameParts: ['swagger-rest'] },
226+
{ name: 'swagger-rest', properties: { type: 'http' } },
227+
'/output'
228+
);
229+
230+
expect(result.specification).toBe(true);
231+
expect(getApiSpecification).toHaveBeenCalledWith(testContext, 'swagger-rest', 'http', 'swagger2');
232+
});
233+
234+
it('should export the openapi3 dialect when the API has an OpenAPI components schema', async () => {
235+
const getApiSpecification = vi.fn().mockResolvedValue({
236+
content: '{"openapi":"3.0.1"}',
237+
format: 'json',
238+
});
239+
const client = createMockClient({
240+
getApiSpecification,
241+
listResources: async function* (_ctx: ApimServiceContext, type: ResourceType) {
242+
if (type === ResourceType.ApiSchema) {
243+
yield {
244+
name: 'openapi-defs',
245+
properties: { contentType: 'application/vnd.oai.openapi.components+json' },
246+
};
247+
}
248+
},
249+
getResource: vi.fn().mockResolvedValue(undefined),
250+
});
251+
const store = createMockStore();
252+
253+
const result = await extractApiResources(
254+
client, store, testContext,
255+
{ type: ResourceType.Api, nameParts: ['openapi-rest'] },
256+
{ name: 'openapi-rest', properties: { type: 'http' } },
257+
'/output'
258+
);
259+
260+
expect(result.specification).toBe(true);
261+
expect(getApiSpecification).toHaveBeenCalledWith(testContext, 'openapi-rest', 'http', 'openapi3');
262+
});
263+
203264
it('should extract API policy and collect content', async () => {
204265
const policyContent = '<policies><inbound><base /></inbound></policies>';
205266
const client = createMockClient({

0 commit comments

Comments
 (0)