From 3be93733d08fcfc9670fe295781cf2af95251cdc Mon Sep 17 00:00:00 2001 From: Alexey Zheltov Date: Fri, 9 Oct 2026 19:04:49 +0000 Subject: [PATCH 1/3] fix: skip WebSocket onHandshake operation on publish APIM auto-creates a single system-managed `onHandshake` operation with an empty urlTemplate for `type: websocket` APIs. PUT/PATCH of that operation with the extracted `urlTemplate: ""` fails with HTTP 400 ("'urlTemplate' should not be empty"), and APIM rejects any user-defined operation for WebSocket APIs. Skip ApiOperation PUTs when the parent API artifact declares `type: websocket`; the operation policy is still published. Fixes #317 --- src/services/resource-publisher.ts | 44 +++++++++++ .../unit/services/resource-publisher.test.ts | 78 +++++++++++++++++++ 2 files changed, 122 insertions(+) diff --git a/src/services/resource-publisher.ts b/src/services/resource-publisher.ts index f876c175..52c4145c 100644 --- a/src/services/resource-publisher.ts +++ b/src/services/resource-publisher.ts @@ -320,6 +320,22 @@ export async function publishResource( // APIM may synthesize description from displayName when description is absent, // causing extract/publish round-trip drift. if (descriptor.type === ResourceType.ApiOperation) { + // WebSocket APIs own a single system-managed `onHandshake` operation with an + // empty urlTemplate. APIM rejects any user PUT/PATCH that carries the empty + // urlTemplate ('urlTemplate' should not be empty) and refuses user-defined + // operations ("Operation entity cannot be defined by user for web socket api + // type"), so the operation is never published — only its policy is (#317). + if (await isWebSocketApiOperation(store, descriptor, config)) { + logger.info( + `Skipping operation "${descriptor.nameParts.join('/')}": ` + + `WebSocket API operations are managed by APIM and cannot be published.` + ); + return { + descriptor, + status: 'skipped', + action: 'noop', + }; + } json = normalizeApiOperationTextFields(json); } @@ -1487,6 +1503,34 @@ export function normalizeMcpToolOperationIds( }; } +/** + * True when the operation's parent API artifact (with overrides applied) + * declares `properties.type === 'websocket'`. + */ +async function isWebSocketApiOperation( + store: IArtifactStore, + descriptor: ResourceDescriptor, + config: PublishConfig +): Promise { + const apiDescriptor: ResourceDescriptor = { + type: ResourceType.Api, + nameParts: [getNamePart(descriptor.nameParts, 0)], + workspace: descriptor.workspace, + }; + let apiJson: Record | undefined; + try { + apiJson = await store.readResource(config.sourceDir, apiDescriptor); + } catch { + return false; + } + if (!apiJson) { + return false; + } + const merged = applyOverrides(apiDescriptor, apiJson, config.overrides); + const apiType = (merged.properties as Record | undefined)?.type; + return typeof apiType === 'string' && apiType.toLowerCase() === 'websocket'; +} + function normalizeApiOperationTextFields( json: Record ): Record { diff --git a/tests/unit/services/resource-publisher.test.ts b/tests/unit/services/resource-publisher.test.ts index b1f30dba..2f4c8b8d 100644 --- a/tests/unit/services/resource-publisher.test.ts +++ b/tests/unit/services/resource-publisher.test.ts @@ -1641,6 +1641,84 @@ describe('resource-publisher', () => { }); }); + describe('WebSocket API operations (#317)', () => { + const apiJson = { + name: 'notification-socket', + properties: { displayName: 'Notification socket', type: 'websocket', protocols: ['wss'] }, + }; + const onHandshakeJson = { + name: 'onHandshake', + properties: { + displayName: 'onHandshake', + method: 'GET', + urlTemplate: '', + templateParameters: [], + description: 'WebSocket opening handshake', + responses: [], + }, + }; + + it('skips the system-managed onHandshake operation instead of PUTting an empty urlTemplate', async () => { + const client = createMockClient(); + const store = createMockStore(); + store.readResource.mockImplementation(async (_dir: string, d: ResourceDescriptor) => + d.type === ResourceType.Api ? apiJson : onHandshakeJson + ); + + const descriptor: ResourceDescriptor = { + type: ResourceType.ApiOperation, + nameParts: ['notification-socket', 'onHandshake'], + }; + + const result = await publishResource(client, store, testContext, descriptor, testConfig); + + expect(result).toMatchObject({ descriptor, status: 'skipped', action: 'noop' }); + expect(client.putResource).not.toHaveBeenCalled(); + expect(store.readResource).toHaveBeenCalledWith( + testConfig.sourceDir, + expect.objectContaining({ type: ResourceType.Api, nameParts: ['notification-socket'] }) + ); + }); + + it('still publishes operations whose parent API is not a WebSocket API', async () => { + const client = createMockClient(); + const store = createMockStore(); + store.readResource.mockImplementation(async (_dir: string, d: ResourceDescriptor) => + d.type === ResourceType.Api + ? { name: 'orders-api', properties: { type: 'http' } } + : { name: 'get-orders', properties: { method: 'GET', urlTemplate: '/orders' } } + ); + + const descriptor: ResourceDescriptor = { + type: ResourceType.ApiOperation, + nameParts: ['orders-api', 'get-orders'], + }; + + const result = await publishResource(client, store, testContext, descriptor, testConfig); + + expect(result.status).toBe('success'); + expect(client.putResource).toHaveBeenCalledTimes(1); + }); + + it('still publishes the operation when the parent API artifact is missing', async () => { + const client = createMockClient(); + const store = createMockStore(); + store.readResource.mockImplementation(async (_dir: string, d: ResourceDescriptor) => + d.type === ResourceType.Api ? undefined : onHandshakeJson + ); + + const descriptor: ResourceDescriptor = { + type: ResourceType.ApiOperation, + nameParts: ['notification-socket', 'onHandshake'], + }; + + const result = await publishResource(client, store, testContext, descriptor, testConfig); + + expect(result.status).toBe('success'); + expect(client.putResource).toHaveBeenCalledTimes(1); + }); + }); + describe('API revision handling', () => { it('injects sourceApiId for revision APIs', async () => { const client = createMockClient(); From aa55742d1303ef6d529db5e15c99fe2d3ead0765 Mon Sep 17 00:00:00 2001 From: Alexey Zheltov Date: Fri, 9 Oct 2026 19:26:25 +0000 Subject: [PATCH 2/3] fix: surface parent API artifact read errors when checking WebSocket operations Let readResource errors propagate to publishResource's failure handling instead of treating them as a missing parent, which would fall through to a PUT that APIM rejects. A missing parent (undefined) still allows the operation PUT. Addresses review feedback on #318. --- src/services/resource-publisher.ts | 10 ++++------ .../unit/services/resource-publisher.test.ts | 20 +++++++++++++++++++ 2 files changed, 24 insertions(+), 6 deletions(-) diff --git a/src/services/resource-publisher.ts b/src/services/resource-publisher.ts index 52c4145c..f8c9a75f 100644 --- a/src/services/resource-publisher.ts +++ b/src/services/resource-publisher.ts @@ -1517,12 +1517,10 @@ async function isWebSocketApiOperation( nameParts: [getNamePart(descriptor.nameParts, 0)], workspace: descriptor.workspace, }; - let apiJson: Record | undefined; - try { - apiJson = await store.readResource(config.sourceDir, apiDescriptor); - } catch { - return false; - } + // readResource returns undefined for a missing file; any other error (e.g. + // malformed JSON) must surface as a publish failure rather than fall through + // to a PUT that APIM would reject. + const apiJson = await store.readResource(config.sourceDir, apiDescriptor); if (!apiJson) { return false; } diff --git a/tests/unit/services/resource-publisher.test.ts b/tests/unit/services/resource-publisher.test.ts index 2f4c8b8d..91d22856 100644 --- a/tests/unit/services/resource-publisher.test.ts +++ b/tests/unit/services/resource-publisher.test.ts @@ -1717,6 +1717,26 @@ describe('resource-publisher', () => { expect(result.status).toBe('success'); expect(client.putResource).toHaveBeenCalledTimes(1); }); + + it('reports a parent API artifact read error as a failure instead of PUTting the operation', async () => { + const client = createMockClient(); + const store = createMockStore(); + const readError = new Error('Unexpected token in apiInformation.json'); + store.readResource.mockImplementation(async (_dir: string, d: ResourceDescriptor) => { + if (d.type === ResourceType.Api) throw readError; + return onHandshakeJson; + }); + + const descriptor: ResourceDescriptor = { + type: ResourceType.ApiOperation, + nameParts: ['notification-socket', 'onHandshake'], + }; + + const result = await publishResource(client, store, testContext, descriptor, testConfig); + + expect(result).toMatchObject({ status: 'failed', action: 'noop', error: readError }); + expect(client.putResource).not.toHaveBeenCalled(); + }); }); describe('API revision handling', () => { From 876d0e5b93504593ace077e76f31e035b8c6673f Mon Sep 17 00:00:00 2001 From: Alexey Zheltov Date: Fri, 9 Oct 2026 19:51:04 +0000 Subject: [PATCH 3/3] fix: mirror WebSocket operation skip in dry-run preview Move the WebSocket operation check into evaluateResourceEligibility and apply it in planDryRunPublications for both the API-child and direct descriptor paths, so `--dry-run` reports SKIP for `onHandshake` exactly as a real publish does. Addresses review feedback on #318. --- src/services/dry-run-reporter.ts | 9 +++ src/services/resource-publisher.ts | 15 ++++- tests/unit/services/dry-run-reporter.test.ts | 64 ++++++++++++++++++++ 3 files changed, 85 insertions(+), 3 deletions(-) diff --git a/src/services/dry-run-reporter.ts b/src/services/dry-run-reporter.ts index c0812616..4431e810 100644 --- a/src/services/dry-run-reporter.ts +++ b/src/services/dry-run-reporter.ts @@ -376,6 +376,9 @@ async function planDryRunPublications( } else { addPlan({ descriptor: child, ...eligibility, operation: 'PUT' }); } + } else if (child.type === ResourceType.ApiOperation) { + const eligibility = await evaluateResourceEligibility(store, child, config); + addPlan({ descriptor: child, ...eligibility, operation: 'PUT' }); } else { addPlan({ descriptor: child, eligible: true, operation: 'PUT' }); } @@ -443,6 +446,12 @@ async function planDryRunPublications( continue; } + if (descriptor.type === ResourceType.ApiOperation) { + const eligibility = await evaluateResourceEligibility(store, descriptor, config); + addPlan({ descriptor, ...eligibility }); + continue; + } + addPlan({ descriptor, eligible: true }); } diff --git a/src/services/resource-publisher.ts b/src/services/resource-publisher.ts index f8c9a75f..109b0950 100644 --- a/src/services/resource-publisher.ts +++ b/src/services/resource-publisher.ts @@ -325,10 +325,10 @@ export async function publishResource( // urlTemplate ('urlTemplate' should not be empty) and refuses user-defined // operations ("Operation entity cannot be defined by user for web socket api // type"), so the operation is never published — only its policy is (#317). - if (await isWebSocketApiOperation(store, descriptor, config)) { + const eligibility = await evaluateResourceEligibility(store, descriptor, config); + if (!eligibility.eligible) { logger.info( - `Skipping operation "${descriptor.nameParts.join('/')}": ` + - `WebSocket API operations are managed by APIM and cannot be published.` + `Skipping operation "${descriptor.nameParts.join('/')}": ${eligibility.reason}` ); return { descriptor, @@ -901,6 +901,15 @@ export async function evaluateResourceEligibility( return evaluateAssociationEligibility(store, target, config); } + if (descriptor.type === ResourceType.ApiOperation) { + return (await isWebSocketApiOperation(store, descriptor, config)) + ? { + eligible: false, + reason: 'WebSocket API operations are managed by APIM and cannot be published', + } + : { eligible: true }; + } + if (descriptor.type !== ResourceType.Subscription || !json) { return { eligible: true }; } diff --git a/tests/unit/services/dry-run-reporter.test.ts b/tests/unit/services/dry-run-reporter.test.ts index b8e00d84..dc530e9e 100644 --- a/tests/unit/services/dry-run-reporter.test.ts +++ b/tests/unit/services/dry-run-reporter.test.ts @@ -942,5 +942,69 @@ describe('dry-run-reporter', () => { ]); expect(report.summary).toEqual({ creates: 3, patches: 0, deletes: 0, skips: 0 }); }); + + describe('WebSocket API operations (#317)', () => { + const api: ResourceDescriptor = { type: ResourceType.Api, nameParts: ['ws-api'] }; + const operation: ResourceDescriptor = { + type: ResourceType.ApiOperation, + nameParts: ['ws-api', 'onHandshake'], + }; + const operationPolicy: ResourceDescriptor = { + type: ResourceType.ApiOperationPolicy, + nameParts: ['ws-api', 'onHandshake'], + }; + + function setupStore(store: ReturnType) { + store.listResources.mockResolvedValue([api, operation, operationPolicy]); + store.readResource.mockImplementation( + async (_dir: string, descriptor: ResourceDescriptor) => + descriptor.type === ResourceType.Api + ? { properties: { type: 'websocket' } } + : { properties: { urlTemplate: '' } } + ); + store.readContent.mockResolvedValue(undefined); + } + + it('reports SKIP for the operation when planned as an API child (full publish)', async () => { + const client = createMockClient(); + const store = createMockStore(); + setupStore(store); + + const report = await generateDryRunReport( + store, client, testContext, testConfig, [api, operation, operationPolicy] + ); + + expect(report.actions).toContainEqual(expect.objectContaining({ + operation: 'SKIP', + type: ResourceType.ApiOperation, + name: 'ws-api/onHandshake', + reason: expect.stringContaining('WebSocket'), + })); + expect(report.actions).toContainEqual(expect.objectContaining({ + operation: 'PUT', + type: ResourceType.ApiOperationPolicy, + })); + expect(report.summary).toEqual({ creates: 2, patches: 0, deletes: 0, skips: 1 }); + }); + + it('reports SKIP for the operation when planned directly (incremental publish)', async () => { + const client = createMockClient(); + const store = createMockStore(); + setupStore(store); + + const report = await generateDryRunReport( + store, client, testContext, testConfig, [operation] + ); + + expect(report.actions).toEqual([ + expect.objectContaining({ + operation: 'SKIP', + type: ResourceType.ApiOperation, + name: 'ws-api/onHandshake', + }), + ]); + expect(report.summary).toEqual({ creates: 0, patches: 0, deletes: 0, skips: 1 }); + }); + }); }); });