Skip to content

Commit e8b704f

Browse files
authored
Merge pull request #318 from Alexey-Zheltov/fix/websocket-onhandshake-skip
fix: skip WebSocket onHandshake operation on publish
2 parents 88a29b2 + 876d0e5 commit e8b704f

4 files changed

Lines changed: 222 additions & 0 deletions

File tree

‎src/services/dry-run-reporter.ts‎

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -376,6 +376,9 @@ async function planDryRunPublications(
376376
} else {
377377
addPlan({ descriptor: child, ...eligibility, operation: 'PUT' });
378378
}
379+
} else if (child.type === ResourceType.ApiOperation) {
380+
const eligibility = await evaluateResourceEligibility(store, child, config);
381+
addPlan({ descriptor: child, ...eligibility, operation: 'PUT' });
379382
} else {
380383
addPlan({ descriptor: child, eligible: true, operation: 'PUT' });
381384
}
@@ -443,6 +446,12 @@ async function planDryRunPublications(
443446
continue;
444447
}
445448

449+
if (descriptor.type === ResourceType.ApiOperation) {
450+
const eligibility = await evaluateResourceEligibility(store, descriptor, config);
451+
addPlan({ descriptor, ...eligibility });
452+
continue;
453+
}
454+
446455
addPlan({ descriptor, eligible: true });
447456
}
448457

‎src/services/resource-publisher.ts‎

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -320,6 +320,22 @@ export async function publishResource(
320320
// APIM may synthesize description from displayName when description is absent,
321321
// causing extract/publish round-trip drift.
322322
if (descriptor.type === ResourceType.ApiOperation) {
323+
// WebSocket APIs own a single system-managed `onHandshake` operation with an
324+
// empty urlTemplate. APIM rejects any user PUT/PATCH that carries the empty
325+
// urlTemplate ('urlTemplate' should not be empty) and refuses user-defined
326+
// operations ("Operation entity cannot be defined by user for web socket api
327+
// type"), so the operation is never published — only its policy is (#317).
328+
const eligibility = await evaluateResourceEligibility(store, descriptor, config);
329+
if (!eligibility.eligible) {
330+
logger.info(
331+
`Skipping operation "${descriptor.nameParts.join('/')}": ${eligibility.reason}`
332+
);
333+
return {
334+
descriptor,
335+
status: 'skipped',
336+
action: 'noop',
337+
};
338+
}
323339
json = normalizeApiOperationTextFields(json);
324340
}
325341

@@ -892,6 +908,15 @@ export async function evaluateResourceEligibility(
892908
return evaluateAssociationEligibility(store, target, config);
893909
}
894910

911+
if (descriptor.type === ResourceType.ApiOperation) {
912+
return (await isWebSocketApiOperation(store, descriptor, config))
913+
? {
914+
eligible: false,
915+
reason: 'WebSocket API operations are managed by APIM and cannot be published',
916+
}
917+
: { eligible: true };
918+
}
919+
895920
if (descriptor.type !== ResourceType.Subscription || !json) {
896921
return { eligible: true };
897922
}
@@ -1511,6 +1536,32 @@ export function normalizeMcpToolOperationIds(
15111536
};
15121537
}
15131538

1539+
/**
1540+
* True when the operation's parent API artifact (with overrides applied)
1541+
* declares `properties.type === 'websocket'`.
1542+
*/
1543+
async function isWebSocketApiOperation(
1544+
store: IArtifactStore,
1545+
descriptor: ResourceDescriptor,
1546+
config: PublishConfig
1547+
): Promise<boolean> {
1548+
const apiDescriptor: ResourceDescriptor = {
1549+
type: ResourceType.Api,
1550+
nameParts: [getNamePart(descriptor.nameParts, 0)],
1551+
workspace: descriptor.workspace,
1552+
};
1553+
// readResource returns undefined for a missing file; any other error (e.g.
1554+
// malformed JSON) must surface as a publish failure rather than fall through
1555+
// to a PUT that APIM would reject.
1556+
const apiJson = await store.readResource(config.sourceDir, apiDescriptor);
1557+
if (!apiJson) {
1558+
return false;
1559+
}
1560+
const merged = applyOverrides(apiDescriptor, apiJson, config.overrides);
1561+
const apiType = (merged.properties as Record<string, unknown> | undefined)?.type;
1562+
return typeof apiType === 'string' && apiType.toLowerCase() === 'websocket';
1563+
}
1564+
15141565
function normalizeApiOperationTextFields(
15151566
json: Record<string, unknown>
15161567
): Record<string, unknown> {

‎tests/unit/services/dry-run-reporter.test.ts‎

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -942,5 +942,69 @@ describe('dry-run-reporter', () => {
942942
]);
943943
expect(report.summary).toEqual({ creates: 3, patches: 0, deletes: 0, skips: 0 });
944944
});
945+
946+
describe('WebSocket API operations (#317)', () => {
947+
const api: ResourceDescriptor = { type: ResourceType.Api, nameParts: ['ws-api'] };
948+
const operation: ResourceDescriptor = {
949+
type: ResourceType.ApiOperation,
950+
nameParts: ['ws-api', 'onHandshake'],
951+
};
952+
const operationPolicy: ResourceDescriptor = {
953+
type: ResourceType.ApiOperationPolicy,
954+
nameParts: ['ws-api', 'onHandshake'],
955+
};
956+
957+
function setupStore(store: ReturnType<typeof createMockStore>) {
958+
store.listResources.mockResolvedValue([api, operation, operationPolicy]);
959+
store.readResource.mockImplementation(
960+
async (_dir: string, descriptor: ResourceDescriptor) =>
961+
descriptor.type === ResourceType.Api
962+
? { properties: { type: 'websocket' } }
963+
: { properties: { urlTemplate: '' } }
964+
);
965+
store.readContent.mockResolvedValue(undefined);
966+
}
967+
968+
it('reports SKIP for the operation when planned as an API child (full publish)', async () => {
969+
const client = createMockClient();
970+
const store = createMockStore();
971+
setupStore(store);
972+
973+
const report = await generateDryRunReport(
974+
store, client, testContext, testConfig, [api, operation, operationPolicy]
975+
);
976+
977+
expect(report.actions).toContainEqual(expect.objectContaining({
978+
operation: 'SKIP',
979+
type: ResourceType.ApiOperation,
980+
name: 'ws-api/onHandshake',
981+
reason: expect.stringContaining('WebSocket'),
982+
}));
983+
expect(report.actions).toContainEqual(expect.objectContaining({
984+
operation: 'PUT',
985+
type: ResourceType.ApiOperationPolicy,
986+
}));
987+
expect(report.summary).toEqual({ creates: 2, patches: 0, deletes: 0, skips: 1 });
988+
});
989+
990+
it('reports SKIP for the operation when planned directly (incremental publish)', async () => {
991+
const client = createMockClient();
992+
const store = createMockStore();
993+
setupStore(store);
994+
995+
const report = await generateDryRunReport(
996+
store, client, testContext, testConfig, [operation]
997+
);
998+
999+
expect(report.actions).toEqual([
1000+
expect.objectContaining({
1001+
operation: 'SKIP',
1002+
type: ResourceType.ApiOperation,
1003+
name: 'ws-api/onHandshake',
1004+
}),
1005+
]);
1006+
expect(report.summary).toEqual({ creates: 0, patches: 0, deletes: 0, skips: 1 });
1007+
});
1008+
});
9451009
});
9461010
});

‎tests/unit/services/resource-publisher.test.ts‎

Lines changed: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1642,6 +1642,104 @@ describe('resource-publisher', () => {
16421642
});
16431643
});
16441644

1645+
describe('WebSocket API operations (#317)', () => {
1646+
const apiJson = {
1647+
name: 'notification-socket',
1648+
properties: { displayName: 'Notification socket', type: 'websocket', protocols: ['wss'] },
1649+
};
1650+
const onHandshakeJson = {
1651+
name: 'onHandshake',
1652+
properties: {
1653+
displayName: 'onHandshake',
1654+
method: 'GET',
1655+
urlTemplate: '',
1656+
templateParameters: [],
1657+
description: 'WebSocket opening handshake',
1658+
responses: [],
1659+
},
1660+
};
1661+
1662+
it('skips the system-managed onHandshake operation instead of PUTting an empty urlTemplate', async () => {
1663+
const client = createMockClient();
1664+
const store = createMockStore();
1665+
store.readResource.mockImplementation(async (_dir: string, d: ResourceDescriptor) =>
1666+
d.type === ResourceType.Api ? apiJson : onHandshakeJson
1667+
);
1668+
1669+
const descriptor: ResourceDescriptor = {
1670+
type: ResourceType.ApiOperation,
1671+
nameParts: ['notification-socket', 'onHandshake'],
1672+
};
1673+
1674+
const result = await publishResource(client, store, testContext, descriptor, testConfig);
1675+
1676+
expect(result).toMatchObject({ descriptor, status: 'skipped', action: 'noop' });
1677+
expect(client.putResource).not.toHaveBeenCalled();
1678+
expect(store.readResource).toHaveBeenCalledWith(
1679+
testConfig.sourceDir,
1680+
expect.objectContaining({ type: ResourceType.Api, nameParts: ['notification-socket'] })
1681+
);
1682+
});
1683+
1684+
it('still publishes operations whose parent API is not a WebSocket API', async () => {
1685+
const client = createMockClient();
1686+
const store = createMockStore();
1687+
store.readResource.mockImplementation(async (_dir: string, d: ResourceDescriptor) =>
1688+
d.type === ResourceType.Api
1689+
? { name: 'orders-api', properties: { type: 'http' } }
1690+
: { name: 'get-orders', properties: { method: 'GET', urlTemplate: '/orders' } }
1691+
);
1692+
1693+
const descriptor: ResourceDescriptor = {
1694+
type: ResourceType.ApiOperation,
1695+
nameParts: ['orders-api', 'get-orders'],
1696+
};
1697+
1698+
const result = await publishResource(client, store, testContext, descriptor, testConfig);
1699+
1700+
expect(result.status).toBe('success');
1701+
expect(client.putResource).toHaveBeenCalledTimes(1);
1702+
});
1703+
1704+
it('still publishes the operation when the parent API artifact is missing', async () => {
1705+
const client = createMockClient();
1706+
const store = createMockStore();
1707+
store.readResource.mockImplementation(async (_dir: string, d: ResourceDescriptor) =>
1708+
d.type === ResourceType.Api ? undefined : onHandshakeJson
1709+
);
1710+
1711+
const descriptor: ResourceDescriptor = {
1712+
type: ResourceType.ApiOperation,
1713+
nameParts: ['notification-socket', 'onHandshake'],
1714+
};
1715+
1716+
const result = await publishResource(client, store, testContext, descriptor, testConfig);
1717+
1718+
expect(result.status).toBe('success');
1719+
expect(client.putResource).toHaveBeenCalledTimes(1);
1720+
});
1721+
1722+
it('reports a parent API artifact read error as a failure instead of PUTting the operation', async () => {
1723+
const client = createMockClient();
1724+
const store = createMockStore();
1725+
const readError = new Error('Unexpected token in apiInformation.json');
1726+
store.readResource.mockImplementation(async (_dir: string, d: ResourceDescriptor) => {
1727+
if (d.type === ResourceType.Api) throw readError;
1728+
return onHandshakeJson;
1729+
});
1730+
1731+
const descriptor: ResourceDescriptor = {
1732+
type: ResourceType.ApiOperation,
1733+
nameParts: ['notification-socket', 'onHandshake'],
1734+
};
1735+
1736+
const result = await publishResource(client, store, testContext, descriptor, testConfig);
1737+
1738+
expect(result).toMatchObject({ status: 'failed', action: 'noop', error: readError });
1739+
expect(client.putResource).not.toHaveBeenCalled();
1740+
});
1741+
});
1742+
16451743
describe('API revision handling', () => {
16461744
it('injects sourceApiId for revision APIs', async () => {
16471745
const client = createMockClient();

0 commit comments

Comments
 (0)