Skip to content

Commit bb7e22c

Browse files
committed
fix publish drift issue with api operation description
1 parent 20f50d9 commit bb7e22c

6 files changed

Lines changed: 431 additions & 2 deletions

File tree

src/services/api-publisher.ts

Lines changed: 109 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import type { IApimClient } 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';
14+
import * as yaml from 'js-yaml';
1415
import { ResourceType } from '../models/resource-types.js';
1516
import { publishResource, type ResourcePublishResult } from './resource-publisher.js';
1617
import { runParallel } from '../lib/parallel-runner.js';
@@ -56,6 +57,15 @@ export async function publishApi(
5657
return rootResult;
5758
}
5859

60+
if (rootResult.specImported && rootResult.operationIdsWithNullDescription?.length) {
61+
await alignImportedOperationDescriptions(
62+
client,
63+
context,
64+
descriptor,
65+
rootResult.operationIdsWithNullDescription
66+
);
67+
}
68+
5969
// Step 2: Find and publish revisions in numeric order
6070
const publishedRevisionCount = await publishApiRevisions(client, store, context, descriptor, config);
6171

@@ -125,6 +135,7 @@ function getImportFormat(specFormat: string, _apiType?: string): string | undefi
125135
interface RootApiResult {
126136
status: 'success' | 'skipped';
127137
specImported: boolean;
138+
operationIdsWithNullDescription?: string[];
128139
isCurrent?: boolean;
129140
}
130141

@@ -162,6 +173,7 @@ async function publishRootApi(
162173

163174
// Try to read the specification file for this API
164175
let specImported = false;
176+
let operationIdsWithNullDescription: string[] = [];
165177
const includeSpecification = options?.includeSpecification ?? true;
166178
const specResult = includeSpecification
167179
? await store.readContent(config.sourceDir, descriptor, 'specification')
@@ -197,6 +209,14 @@ async function publishRootApi(
197209
value: specResult.content,
198210
},
199211
};
212+
213+
if (importFormat === 'openapi' || importFormat === 'openapi+json') {
214+
operationIdsWithNullDescription = getOpenApiOperationIdsWithNullDescription(
215+
specResult.content,
216+
specResult.format
217+
);
218+
}
219+
200220
specImported = true;
201221
logger.info(`Including ${specResult.format} specification in API import for "${getNamePart(descriptor.nameParts, 0)}"`);
202222
}
@@ -210,6 +230,7 @@ async function publishRootApi(
210230
status: 'success',
211231
action: 'put',
212232
specImported,
233+
operationIdsWithNullDescription,
213234
isCurrent,
214235
};
215236
}
@@ -453,3 +474,91 @@ function getApiIsCurrent(json: Record<string, unknown>): boolean | undefined {
453474
const isCurrent = properties?.isCurrent;
454475
return typeof isCurrent === 'boolean' ? isCurrent : undefined;
455476
}
477+
478+
async function alignImportedOperationDescriptions(
479+
client: IApimClient,
480+
context: ApimServiceContext,
481+
apiDescriptor: ResourceDescriptor,
482+
operationIdsWithNullDescription: string[]
483+
): Promise<void> {
484+
const apiName = getNamePart(apiDescriptor.nameParts, 0);
485+
486+
for (const operationName of operationIdsWithNullDescription) {
487+
const operationDescriptor: ResourceDescriptor = {
488+
type: ResourceType.ApiOperation,
489+
nameParts: [apiName, operationName],
490+
workspace: apiDescriptor.workspace,
491+
};
492+
493+
const operation = await client.getResource(context, operationDescriptor);
494+
if (!operation) {
495+
continue;
496+
}
497+
498+
const props = operation.properties as Record<string, unknown> | undefined;
499+
if (!props || props.description === null) {
500+
continue;
501+
}
502+
503+
await client.putResource(context, operationDescriptor, {
504+
...operation,
505+
properties: {
506+
...props,
507+
description: null,
508+
},
509+
});
510+
}
511+
}
512+
513+
function getOpenApiOperationIdsWithNullDescription(
514+
specContent: string,
515+
specFormat?: string
516+
): string[] {
517+
try {
518+
const parsed: unknown =
519+
specFormat === 'json'
520+
? JSON.parse(specContent)
521+
: yaml.load(specContent);
522+
523+
if (!parsed || typeof parsed !== 'object') {
524+
return [];
525+
}
526+
527+
const parsedRecord = parsed as Record<string, unknown>;
528+
const pathsValue = parsedRecord.paths;
529+
if (!pathsValue || typeof pathsValue !== 'object') {
530+
return [];
531+
}
532+
533+
const paths = pathsValue as Record<string, unknown>;
534+
535+
const methods = new Set(['get', 'put', 'post', 'delete', 'options', 'head', 'patch', 'trace']);
536+
const operationIds = new Set<string>();
537+
538+
for (const pathItem of Object.values(paths)) {
539+
if (!pathItem || typeof pathItem !== 'object') {
540+
continue;
541+
}
542+
543+
for (const [methodName, operation] of Object.entries(pathItem as Record<string, unknown>)) {
544+
if (!methods.has(methodName.toLowerCase()) || !operation || typeof operation !== 'object') {
545+
continue;
546+
}
547+
548+
const operationRecord = operation as Record<string, unknown>;
549+
const operationId = operationRecord.operationId;
550+
if (typeof operationId !== 'string' || operationId.length === 0) {
551+
continue;
552+
}
553+
554+
if (!Object.hasOwn(operationRecord, 'description') || operationRecord.description == null) {
555+
operationIds.add(operationId);
556+
}
557+
}
558+
}
559+
560+
return [...operationIds];
561+
} catch {
562+
return [];
563+
}
564+
}

src/services/publish-service.ts

Lines changed: 49 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -284,10 +284,12 @@ async function executePuts(
284284
);
285285
}
286286
} else if (tier === 2) {
287+
const tier2Descriptors = filterApiRevisionsHandledByRootApis(descriptors);
288+
287289
const { mcpApis, regularTier2 } = await splitMcpApis(
288290
store,
289291
config.sourceDir,
290-
descriptors
292+
tier2Descriptors
291293
);
292294

293295
await publishAndOutput(client, store, context, config, regularTier2, results);
@@ -472,9 +474,13 @@ async function publishTier(
472474
const tasks = descriptors.map((descriptor) => async () => {
473475
try {
474476
let publishResult: ResourcePublishResult;
477+
const apiName = descriptor.type === ResourceType.Api
478+
? getNamePart(descriptor.nameParts, 0)
479+
: undefined;
480+
const isApiRevision = typeof apiName === 'string' && isApiRevisionName(apiName);
475481

476482
// Use specialized publishers for Api and Product types
477-
if (descriptor.type === ResourceType.Api) {
483+
if (descriptor.type === ResourceType.Api && !isApiRevision) {
478484
publishResult = await publishApi(client, store, context, descriptor, config);
479485
} else if (descriptor.type === ResourceType.Product) {
480486
publishResult = await publishProduct(client, store, context, descriptor, config);
@@ -524,6 +530,47 @@ async function publishTier(
524530
});
525531
}
526532

533+
function filterApiRevisionsHandledByRootApis(
534+
descriptors: ResourceDescriptor[]
535+
): ResourceDescriptor[] {
536+
const rootApiNames = new Set<string>();
537+
for (const descriptor of descriptors) {
538+
if (descriptor.type !== ResourceType.Api) {
539+
continue;
540+
}
541+
542+
const apiName = getNamePart(descriptor.nameParts, 0);
543+
if (!isApiRevisionName(apiName)) {
544+
rootApiNames.add(apiName);
545+
}
546+
}
547+
548+
if (rootApiNames.size === 0) {
549+
return descriptors;
550+
}
551+
552+
return descriptors.filter((descriptor) => {
553+
if (descriptor.type !== ResourceType.Api) {
554+
return true;
555+
}
556+
557+
const apiName = getNamePart(descriptor.nameParts, 0);
558+
if (!isApiRevisionName(apiName)) {
559+
return true;
560+
}
561+
562+
return !rootApiNames.has(getApiRootName(apiName));
563+
});
564+
}
565+
566+
function isApiRevisionName(apiName: string): boolean {
567+
return apiName.includes(';rev=');
568+
}
569+
570+
function getApiRootName(apiName: string): string {
571+
return apiName.split(';rev=')[0] ?? apiName;
572+
}
573+
527574
/**
528575
* Execute DELETE operations in reverse dependency order (tier 4 → tier 1).
529576
*/

src/services/resource-publisher.ts

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -112,6 +112,13 @@ export async function publishResource(
112112
// Apply overrides (deep merge, preserves opaque structure)
113113
json = applyOverrides(descriptor, json, config.overrides);
114114

115+
// API operations: enforce explicit empty strings for text fields when omitted.
116+
// APIM may synthesize description from displayName when description is absent,
117+
// causing extract/publish round-trip drift.
118+
if (descriptor.type === ResourceType.ApiOperation) {
119+
json = normalizeApiOperationTextFields(json);
120+
}
121+
115122
// For KeyVault-backed NamedValues:
116123
// 1. Strip properties.value — APIM must not receive both keyVault and value
117124
// in the same PUT body, as it causes indefinite provisioning or rejection.
@@ -508,6 +515,32 @@ function normalizeMcpToolOperationIds(
508515
};
509516
}
510517

518+
function normalizeApiOperationTextFields(
519+
json: Record<string, unknown>
520+
): Record<string, unknown> {
521+
const props = json.properties as Record<string, unknown> | undefined;
522+
if (!props) {
523+
return json;
524+
}
525+
526+
const normalizedProps: Record<string, unknown> = {
527+
...props,
528+
};
529+
530+
if (normalizedProps.displayName == null) {
531+
normalizedProps.displayName = '';
532+
}
533+
534+
if (normalizedProps.description == null) {
535+
normalizedProps.description = '';
536+
}
537+
538+
return {
539+
...json,
540+
properties: normalizedProps,
541+
};
542+
}
543+
511544
function isAutoGeneratedProductSubscription(
512545
subscriptionName: string,
513546
scope?: string

0 commit comments

Comments
 (0)