Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions src/services/dry-run-reporter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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' });
}
Expand Down Expand Up @@ -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 });
}

Expand Down
51 changes: 51 additions & 0 deletions src/services/resource-publisher.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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).
const eligibility = await evaluateResourceEligibility(store, descriptor, config);
if (!eligibility.eligible) {
logger.info(
`Skipping operation "${descriptor.nameParts.join('/')}": ${eligibility.reason}`
);
return {
descriptor,
status: 'skipped',
action: 'noop',
};
}
json = normalizeApiOperationTextFields(json);
}

Expand Down Expand Up @@ -885,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 };
}
Expand Down Expand Up @@ -1487,6 +1512,32 @@ 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<boolean> {
const apiDescriptor: ResourceDescriptor = {
type: ResourceType.Api,
nameParts: [getNamePart(descriptor.nameParts, 0)],
workspace: descriptor.workspace,
};
// 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;
}
const merged = applyOverrides(apiDescriptor, apiJson, config.overrides);
const apiType = (merged.properties as Record<string, unknown> | undefined)?.type;
return typeof apiType === 'string' && apiType.toLowerCase() === 'websocket';
}

function normalizeApiOperationTextFields(
json: Record<string, unknown>
): Record<string, unknown> {
Expand Down
64 changes: 64 additions & 0 deletions tests/unit/services/dry-run-reporter.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<typeof createMockStore>) {
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 });
});
});
});
});
98 changes: 98 additions & 0 deletions tests/unit/services/resource-publisher.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1641,6 +1641,104 @@ 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);
});

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', () => {
it('injects sourceApiId for revision APIs', async () => {
const client = createMockClient();
Expand Down
Loading