Skip to content

Commit 8bcd103

Browse files
CopilotEMaher
authored andcommitted
Make ApiOperation a first-class persisted resource with PATCH reconciliation
- Add operationInformation.json infoFile to ApiOperation resource type - Add patchResource to IApimClient interface and ApimClient implementation - Replace filterOperationsWithSchemaRefs workaround with unified PATCH reconciliation after spec import; removes commitId-gated branch - Update unit tests to reflect new PATCH behavior - Update documentation for ApiOperation artifact format
1 parent 8c3ede0 commit 8bcd103

10 files changed

Lines changed: 263 additions & 113 deletions

File tree

docs/reference/artifact-format.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -143,7 +143,7 @@ All 34 APIM resource types and their artifact mappings:
143143
| ApiPolicy | `apis/{api}` | `policy.xml` |
144144
| ApiTag | `apis/{api}/tags/{tag}` | `tagInformation.json` |
145145
| ApiDiagnostic | `apis/{api}/diagnostics/{diagnostic}` | `diagnosticInformation.json` |
146-
| ApiOperation | `apis/{api}/operations/{operation}` | _(none)_ |
146+
| ApiOperation | `apis/{api}/operations/{operation}` | `operationInformation.json` |
147147
| ApiOperationPolicy | `apis/{api}/operations/{operation}` | `policy.xml` |
148148
| ApiSchema | `apis/{api}/schemas/{schema}` | `schemaInformation.json` |
149149
| ApiRelease | `apis/{api}/releases/{release}` | `releaseInformation.json` |

docs/reference/resource-types.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -59,7 +59,7 @@ Resources scoped to a specific API.
5959
| ApiPolicy | `/apis/{name}/policies/policy` | `apis/{0}` | `policy.xml` | Policy applied to all operations in an API |
6060
| ApiTag | `/apis/{name}/tags/{tag}` | `apis/{0}/tags/{1}` | `tagInformation.json` | Tag applied to an API |
6161
| ApiDiagnostic | `/apis/{name}/diagnostics/{diag}` | `apis/{0}/diagnostics/{1}` | `diagnosticInformation.json` | Diagnostic settings scoped to an API |
62-
| ApiOperation | `/apis/{name}/operations/{op}` | `apis/{0}/operations/{1}` | *(none)* | Individual API operation (GET /users, POST /orders, etc.) |
62+
| ApiOperation | `/apis/{name}/operations/{op}` | `apis/{0}/operations/{1}` | `operationInformation.json` | Individual API operation (GET /users, POST /orders, etc.) |
6363
| ApiOperationPolicy | `/apis/{name}/operations/{op}/policies/policy` | `apis/{0}/operations/{1}` | `policy.xml` | Policy applied to a specific API operation |
6464
| ApiSchema | `/apis/{name}/schemas/{schema}` | `apis/{0}/schemas/{1}` | `schemaInformation.json` | Schema definition for request/response validation |
6565
| ApiRelease | `/apis/{name}/releases/{release}` | `apis/{0}/releases/{1}` | `releaseInformation.json` | API release record (makes a revision current) |

src/clients/apim-client.ts

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -375,6 +375,30 @@ export class ApimClient implements IApimClient {
375375
}
376376
}
377377

378+
async patchResource(
379+
context: ApimServiceContext,
380+
descriptor: ResourceDescriptor,
381+
payload: Record<string, unknown>
382+
): Promise<Record<string, unknown>> {
383+
const url = buildArmUri(context, descriptor);
384+
385+
const response = await this.request(url, {
386+
method: 'PATCH',
387+
body: JSON.stringify(payload),
388+
});
389+
390+
const responseText = await response.text();
391+
if (!responseText.trim()) {
392+
return {};
393+
}
394+
395+
try {
396+
return JSON.parse(responseText) as Record<string, unknown>;
397+
} catch {
398+
throw new SyntaxError(`Non-JSON response from APIM PATCH ${url}: ${responseText.substring(0, 200)}`);
399+
}
400+
}
401+
378402
async deleteResource(
379403
context: ApimServiceContext,
380404
descriptor: ResourceDescriptor

src/clients/iapim-client.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,17 @@ export interface IApimClient {
3939
payload: Record<string, unknown>
4040
): Promise<Record<string, unknown>>;
4141

42+
/**
43+
* Partially update a resource (PATCH).
44+
* Only the properties present in the payload are updated.
45+
* Returns the response JSON body.
46+
*/
47+
patchResource(
48+
context: ApimServiceContext,
49+
descriptor: ResourceDescriptor,
50+
payload: Record<string, unknown>
51+
): Promise<Record<string, unknown>>;
52+
4253
/**
4354
* Delete a resource (DELETE).
4455
* Returns true if deleted, false if already absent (404).

src/models/resource-types.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -204,7 +204,7 @@ export const RESOURCE_TYPE_METADATA: Record<ResourceType, ResourceTypeMetadata>
204204
[ResourceType.ApiOperation]: {
205205
armPathSuffix: 'apis/{0}/operations/{1}',
206206
artifactDirectory: 'apis/{0}/operations/{1}',
207-
infoFile: null,
207+
infoFile: 'operationInformation.json',
208208
supportsGet: true,
209209
},
210210
[ResourceType.ApiOperationPolicy]: {

src/services/api-publisher.ts

Lines changed: 77 additions & 83 deletions
Original file line numberDiff line numberDiff line change
@@ -265,11 +265,27 @@ async function publishApiRevisions(
265265
return sortedRevisions.length;
266266
}
267267

268+
/**
269+
* Properties allowed in operation PATCH bodies.
270+
* Restricted to fields that are valid for all API types (HTTP, SOAP, GraphQL, WebSocket, A2A).
271+
* Avoids sending fields APIM rejects on non-HTTP API types.
272+
*/
273+
const OPERATION_PATCH_ALLOWLIST: ReadonlySet<string> = new Set([
274+
'displayName',
275+
'description',
276+
'method',
277+
'urlTemplate',
278+
'templateParameters',
279+
'request',
280+
'responses',
281+
'policies',
282+
]);
283+
268284
/**
269285
* Resource types that are auto-created by APIM when importing a spec.
270286
* Both ApiOperation and ApiSchema are initially excluded, but we selectively
271-
* re-add: operations with schema references, and explicitly named schemas
272-
* (non-auto-generated 24-char hex IDs).
287+
* re-add: explicitly named schemas (non-auto-generated 24-char hex IDs).
288+
* Operations are always reconciled via PATCH after spec import.
273289
*/
274290
const SPEC_MANAGED_CHILD_TYPES = new Set<ResourceType>([
275291
ResourceType.ApiOperation,
@@ -280,7 +296,8 @@ const SPEC_MANAGED_CHILD_TYPES = new Set<ResourceType>([
280296
* Publish all child resources of an API in parallel.
281297
* When specImported is true, skips ApiSchema and ApiOperation since APIM
282298
* auto-creates those from the imported specification. However:
283-
* - Operations with schema references are re-PUT explicitly so APIM preserves those links
299+
* - All operations are reconciled via PATCH after spec import to restore
300+
* persisted metadata (description, schema references, etc.)
284301
* - Explicitly named schemas (non-24-char-hex IDs) are also re-published
285302
*/
286303
async function publishApiChildren(
@@ -302,40 +319,7 @@ async function publishApiChildren(
302319
!(specImported && SPEC_MANAGED_CHILD_TYPES.has(d.type))
303320
);
304321

305-
// When a spec was imported, APIM auto-creates operations and schemas but loses
306-
// schemaId/typeName references in representations. Re-include operations that
307-
// carry such references so those links are explicitly restored via PUT.
308322
if (specImported) {
309-
// In incremental mode, avoid re-publishing operation artifacts after spec
310-
// import. This prevents stale operation JSON from overwriting newly
311-
// imported OpenAPI operation metadata (for example descriptions).
312-
const shouldRepublishSchemaRefOps = !config.commitId;
313-
314-
const operationDescriptors = allDescriptors.filter(
315-
(d) =>
316-
d.type === ResourceType.ApiOperation &&
317-
getNamePart(d.nameParts, 0) === getNamePart(apiDescriptor.nameParts, 0)
318-
);
319-
320-
if (shouldRepublishSchemaRefOps) {
321-
const schemaRefOps = await filterOperationsWithSchemaRefs(
322-
store,
323-
config.sourceDir,
324-
operationDescriptors
325-
);
326-
327-
if (schemaRefOps.length > 0) {
328-
logger.debug(
329-
`Re-publishing ${schemaRefOps.length} operation(s) with schema references after spec import for "${getNamePart(apiDescriptor.nameParts, 0)}"`
330-
);
331-
childDescriptors = [...childDescriptors, ...schemaRefOps];
332-
}
333-
} else {
334-
logger.debug(
335-
`Skipping schema-reference operation re-publish for "${getNamePart(apiDescriptor.nameParts, 0)}" in incremental mode`
336-
);
337-
}
338-
339323
// Re-include explicitly named schemas (non-auto-generated IDs).
340324
// Auto-generated schemas have 24-char hex names and are recreated by spec import.
341325
// Explicitly named schemas (like "src-rest-schema-item") must be published.
@@ -377,67 +361,77 @@ async function publishApiChildren(
377361
await runParallel(tasks, 5);
378362
}
379363
}
380-
}
381364

382-
/**
383-
* Returns the subset of operation descriptors whose stored artifact contains
384-
* at least one representation with a schemaId or typeName property.
385-
* These operations must be re-PUT after a spec import so APIM retains the
386-
* schema linkage that the import path discards.
387-
*/
388-
async function filterOperationsWithSchemaRefs(
389-
store: IArtifactStore,
390-
sourceDir: string,
391-
operationDescriptors: ResourceDescriptor[]
392-
): Promise<ResourceDescriptor[]> {
393-
const result: ResourceDescriptor[] = [];
394-
395-
for (const descriptor of operationDescriptors) {
396-
const json = await store.readResource(sourceDir, descriptor);
397-
if (json && operationHasSchemaRefs(json)) {
398-
result.push(descriptor);
399-
}
365+
// After spec import, reconcile all operations by PATCHing with persisted metadata.
366+
// This ensures APIM retains the original operation state (description, schema refs, etc.)
367+
// regardless of what the importer defaulted. PATCH is idempotent and only updates
368+
// the fields present in the persisted JSON.
369+
if (specImported) {
370+
await reconcileOperationsAfterSpecImport(client, store, context, apiDescriptor, config, allDescriptors);
400371
}
401-
402-
return result;
403372
}
404373

405374
/**
406-
* Returns true if the operation JSON contains at least one representation
407-
* (in request or any response) that has a schemaId or typeName property.
375+
* Reconcile all API operations after a spec import by PATCHing each operation
376+
* with its persisted metadata. Only allow-listed properties are sent in the
377+
* PATCH body so APIM does not reject the request for non-HTTP API types.
378+
*
379+
* Skips operations with auto-generated IDs (24-char hex) — these are SOAP
380+
* operations recreated by the WSDL importer; their persisted JSON, if any,
381+
* should not override the importer's result.
408382
*/
409-
function operationHasSchemaRefs(json: Record<string, unknown>): boolean {
410-
const props = json.properties as Record<string, unknown> | undefined;
411-
if (!props) return false;
383+
async function reconcileOperationsAfterSpecImport(
384+
client: IApimClient,
385+
store: IArtifactStore,
386+
context: ApimServiceContext,
387+
apiDescriptor: ResourceDescriptor,
388+
config: PublishConfig,
389+
allDescriptors: ResourceDescriptor[]
390+
): Promise<void> {
391+
const operationDescriptors = allDescriptors.filter(
392+
(d) =>
393+
d.type === ResourceType.ApiOperation &&
394+
getNamePart(d.nameParts, 0) === getNamePart(apiDescriptor.nameParts, 0) &&
395+
!isAutoGeneratedId(getNamePart(d.nameParts, 1))
396+
);
412397

413-
const representationGroups: unknown[] = [];
398+
if (operationDescriptors.length === 0) return;
414399

415-
const request = props.request as Record<string, unknown> | undefined;
416-
if (request?.representations) {
417-
representationGroups.push(request.representations);
418-
}
400+
const tasks = operationDescriptors.map((descriptor) => async () => {
401+
const json = await store.readResource(config.sourceDir, descriptor);
402+
if (!json) return;
419403

420-
const responses = props.responses as unknown[] | undefined;
421-
if (Array.isArray(responses)) {
422-
for (const response of responses) {
423-
const r = response as Record<string, unknown>;
424-
if (r.representations) {
425-
representationGroups.push(r.representations);
404+
const props = json.properties as Record<string, unknown> | undefined;
405+
if (!props) return;
406+
407+
// Build a PATCH body with only allow-listed properties present in the persisted JSON.
408+
const patchProps: Record<string, unknown> = {};
409+
for (const key of OPERATION_PATCH_ALLOWLIST) {
410+
if (Object.hasOwn(props, key)) {
411+
patchProps[key] = props[key];
426412
}
427413
}
428-
}
429414

430-
for (const group of representationGroups) {
431-
if (!Array.isArray(group)) continue;
432-
for (const rep of group) {
433-
const r = rep as Record<string, unknown>;
434-
if (r.schemaId != null || r.typeName != null) {
435-
return true;
436-
}
415+
if (Object.keys(patchProps).length === 0) return;
416+
417+
const patchBody: Record<string, unknown> = { properties: patchProps };
418+
419+
try {
420+
await client.patchResource(context, descriptor, patchBody);
421+
logger.debug(`Reconciled operation "${getNamePart(descriptor.nameParts, 1)}" after spec import`);
422+
} catch (error) {
423+
logger.warn(
424+
`Failed to reconcile operation "${getNamePart(descriptor.nameParts, 1)}" after spec import: ${(error as Error).message}`
425+
);
437426
}
438-
}
427+
});
439428

440-
return false;
429+
if (tasks.length > 0) {
430+
logger.debug(
431+
`Reconciling ${tasks.length} operation(s) after spec import for "${getNamePart(apiDescriptor.nameParts, 0)}"`
432+
);
433+
await runParallel(tasks, 5);
434+
}
441435
}
442436

443437
/**

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

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -57,10 +57,10 @@ $RequestResponseIgnoredProperties = @('description')
5757

5858
# Properties ignored on representation objects (have 'contentType' or 'schemaId'):
5959
# - description: SOAP/WSDL import generates descriptions that vary
60-
# - schemaId/typeName: When APIs are published via spec import (OpenAPI/Swagger),
61-
# APIM recreates operations from the spec but does NOT populate schemaId/typeName
62-
# in representations — the spec itself provides schema resolution. These fields
63-
# appear in extracted artifacts but are absent after spec-based publish.
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+
# Kept here for SOAP compatibility.
6464
$RepresentationIgnoredProperties = @('description', 'schemaId', 'typeName')
6565

6666
# ── Helpers ─────────────────────────────────────────────────────────────────

tests/unit/clients/artifact-store.test.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -59,8 +59,8 @@ describe('ArtifactStore', () => {
5959

6060
it('should handle types with no info file gracefully', async () => {
6161
const descriptor: ResourceDescriptor = {
62-
type: ResourceType.ApiOperation,
63-
nameParts: ['my-api', 'getUsers'],
62+
type: ResourceType.ProductTag,
63+
nameParts: ['my-product', 'my-tag'],
6464
};
6565

6666
// writeResource should be a no-op

tests/unit/lib/resource-path.test.ts

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -128,13 +128,24 @@ describe('buildArtifactFilePath', () => {
128128
);
129129
});
130130

131-
it('should return undefined for types with no info file (ApiOperation)', () => {
131+
it('should return undefined for types with no info file (ProductTag)', () => {
132+
const descriptor: ResourceDescriptor = {
133+
type: ResourceType.ProductTag,
134+
nameParts: ['my-product', 'my-tag'],
135+
};
136+
const filePath = buildArtifactFilePath(baseDir, descriptor);
137+
expect(filePath).toBeUndefined();
138+
});
139+
140+
it('should return operationInformation.json path for ApiOperation', () => {
132141
const descriptor: ResourceDescriptor = {
133142
type: ResourceType.ApiOperation,
134143
nameParts: ['my-api', 'getUsers'],
135144
};
136145
const filePath = buildArtifactFilePath(baseDir, descriptor);
137-
expect(filePath).toBeUndefined();
146+
expect(filePath).toBe(
147+
path.join(baseDir, 'apis', 'my-api', 'operations', 'getUsers', 'operationInformation.json')
148+
);
138149
});
139150

140151
it('should return policy.xml path for ServicePolicy', () => {
@@ -496,6 +507,7 @@ describe('buildArtifactFilePath + parseArtifactPath roundtrip for API child reso
496507
{ type: ResourceType.ApiSchema, nameParts: ['my-api', 'my-schema'] },
497508
{ type: ResourceType.ApiRelease, nameParts: ['my-api', 'my-release'] },
498509
{ type: ResourceType.GraphQLResolver, nameParts: ['my-api', 'my-resolver'] },
510+
{ type: ResourceType.ApiOperation, nameParts: ['my-api', 'my-op'] },
499511
{ type: ResourceType.ApiOperationPolicy, nameParts: ['my-api', 'my-op'] },
500512
];
501513

0 commit comments

Comments
 (0)