diff --git a/docs/guides/environment-overrides.md b/docs/guides/environment-overrides.md index 30ebcba7..20bd721a 100644 --- a/docs/guides/environment-overrides.md +++ b/docs/guides/environment-overrides.md @@ -308,7 +308,10 @@ subscriptions: ``` > **Note:** The built-in `master` subscription is automatically skipped during publish. -> Product-scoped subscriptions auto-generated by APIM are also skipped to avoid subscription limit errors. +> When publish creates a new product, the subscriptions APIM automatically creates for it are deleted, +> so only the extracted product subscriptions (including ones with 24-character hex IDs) are published. +> Deletion is best-effort: a failed delete is logged as a warning and does not fail the publish. +> You can override their keys like any other subscription. ### Products diff --git a/src/services/product-publisher.ts b/src/services/product-publisher.ts index 8429561c..87b0e371 100644 --- a/src/services/product-publisher.ts +++ b/src/services/product-publisher.ts @@ -120,9 +120,66 @@ async function cleanupAutoCreatedProductResources( context: ApimServiceContext, productDescriptor: ResourceDescriptor ): Promise { + await cleanupProductSubscriptions(client, context, productDescriptor); await cleanupProductGroups(client, context, productDescriptor); } +// APIM auto-creates a subscription for a new product; remove it so only extracted subscriptions remain. +async function cleanupProductSubscriptions( + client: IApimClient, + context: ApimServiceContext, + productDescriptor: ResourceDescriptor +): Promise { + const productName = getNamePart(productDescriptor.nameParts, 0); + const listContext: ApimServiceContext = + productDescriptor.workspace && !isWorkspaceScope(context) + ? { + ...context, + baseUrl: `${context.baseUrl}/workspaces/${encodeURIComponent(productDescriptor.workspace)}`, + } + : context; + const productNameLower = productName.toLowerCase(); + let deleted = 0; + + for await (const subscription of client.listResources(listContext, ResourceType.Subscription)) { + const name = subscription.name; + const scope = (subscription.properties as Record | undefined)?.scope; + if ( + typeof name !== 'string' || + typeof scope !== 'string' || + getScopedProductName(scope)?.toLowerCase() !== productNameLower + ) { + continue; + } + + try { + const removed = await client.deleteResource(listContext, { + type: ResourceType.Subscription, + nameParts: [name], + }); + if (removed) { + deleted++; + } + } catch (error) { + logger.warn(`Failed to delete auto-created subscription ${name}: ${String(error)}`); + } + } + + if (deleted > 0) { + logger.info(`Deleted ${deleted} auto-created subscription(s) for product: ${productName}`); + } +} + +function getScopedProductName(scope: string): string | undefined { + const segment = scope.match(/\/products\/([^/]+)\/?$/i)?.[1]; + if (!segment) return undefined; + try { + return decodeURIComponent(segment); + } catch { + return segment; + } +} + async function cleanupProductGroups( client: IApimClient, context: ApimServiceContext, diff --git a/src/services/resource-publisher.ts b/src/services/resource-publisher.ts index 9e3c1ef1..5cb88f93 100644 --- a/src/services/resource-publisher.ts +++ b/src/services/resource-publisher.ts @@ -897,10 +897,6 @@ export async function evaluateResourceEligibility( return { eligible: false, reason: 'root-scoped subscriptions are managed by APIM' }; } - if (isAutoGeneratedProductSubscription(getNamePart(descriptor.nameParts, 0), scope)) { - return { eligible: false, reason: 'auto-generated product subscription' }; - } - for (const target of findSubscriptionTargets(json, descriptor.workspace)) { const eligibility = await evaluateAssociationEligibility( store, @@ -1508,17 +1504,6 @@ function normalizeApiOperationTextFields( }; } -function isAutoGeneratedProductSubscription( - subscriptionName: string, - scope?: string -): boolean { - if (!scope || !isAutoGeneratedId(subscriptionName)) { - return false; - } - - return scope.includes('/products/'); -} - /** * Publish an ApiTag as a workspace link resource. * Workspace scope uses `tags/{tag}/apiLinks/{api}` with a link payload body. diff --git a/tests/unit/services/product-publisher.test.ts b/tests/unit/services/product-publisher.test.ts index d9bf7984..27d1a833 100644 --- a/tests/unit/services/product-publisher.test.ts +++ b/tests/unit/services/product-publisher.test.ts @@ -479,7 +479,7 @@ describe('product-publisher', () => { ]); }); - it('does not delete auto-generated product subscriptions after product publish', async () => { + it('deletes subscriptions APIM auto-created for a newly created product', async () => { const client = createMockClient(); const store = createMockStore(); const autoGeneratedId = generatedSubscriptionId('c'); @@ -496,48 +496,113 @@ describe('product-publisher', () => { displayName: null, }, }; + yield { + id: `${testContext.baseUrl}/subscriptions/other-product-sub`, + name: 'other-product-sub', + properties: { scope: `${testContext.baseUrl}/products/other-product` }, + }; + yield { + id: `${testContext.baseUrl}/subscriptions/api-sub`, + name: 'api-sub', + properties: { scope: `${testContext.baseUrl}/apis/my-product` }, + }; }; const result = await publishProduct(client, store, testContext, productDescriptor, testConfig); expect(result.status).toBe('success'); - expect(client.deleteResource).not.toHaveBeenCalledWith( + expect(client.deleteResource).toHaveBeenCalledTimes(1); + expect(client.deleteResource).toHaveBeenCalledWith(testContext, { + type: ResourceType.Subscription, + nameParts: [autoGeneratedId], + }); + }); + + it('matches URL-encoded product names in subscription scopes', async () => { + const client = createMockClient(); + const store = createMockStore(); + const autoGeneratedId = generatedSubscriptionId('9'); + client.getResource.mockResolvedValue(undefined); + store.readAssociation.mockResolvedValue([]); + store.readContent.mockResolvedValue(undefined); + + client.listResources = async function* () { + yield { + name: autoGeneratedId, + properties: { scope: `${testContext.baseUrl}/products/My%20Product` }, + }; + }; + + const result = await publishProduct( + client, + store, testContext, - expect.objectContaining({ - type: ResourceType.Subscription, - nameParts: [autoGeneratedId], - }) + { type: ResourceType.Product, nameParts: ['my product'] }, + testConfig ); + + expect(result.status).toBe('success'); + expect(client.deleteResource).toHaveBeenCalledWith(testContext, { + type: ResourceType.Subscription, + nameParts: [autoGeneratedId], + }); }); - it('does not delete product-scoped subscriptions on first product creation', async () => { + it('deletes auto-created subscriptions in the workspace of a new workspace product', async () => { const client = createMockClient(); const store = createMockStore(); + const autoGeneratedId = generatedSubscriptionId('e'); + const workspaceBaseUrl = `${testContext.baseUrl}/workspaces/team`; client.getResource.mockResolvedValue(undefined); store.readAssociation.mockResolvedValue([]); store.readContent.mockResolvedValue(undefined); + const listedContexts: ApimServiceContext[] = []; + + client.listResources = async function* (context: ApimServiceContext, type: ResourceType) { + if (type !== ResourceType.Subscription) return; + listedContexts.push(context); + yield { + id: `${workspaceBaseUrl}/subscriptions/${autoGeneratedId}`, + name: autoGeneratedId, + properties: { scope: `${workspaceBaseUrl}/products/store` }, + }; + }; + + const result = await publishProduct( + client, + store, + testContext, + { type: ResourceType.Product, nameParts: ['store'], workspace: 'team' }, + testConfig + ); + + expect(result.status).toBe('success'); + expect(listedContexts).toEqual([expect.objectContaining({ baseUrl: workspaceBaseUrl })]); + expect(client.deleteResource).toHaveBeenCalledWith( + expect.objectContaining({ baseUrl: workspaceBaseUrl }), + { type: ResourceType.Subscription, nameParts: [autoGeneratedId] } + ); + }); + + it('continues publishing when deleting an auto-created subscription fails', async () => { + const client = createMockClient(); + const store = createMockStore(); + client.getResource.mockResolvedValue(undefined); + client.deleteResource.mockRejectedValue(new Error('Delete failed')); + store.readAssociation.mockResolvedValue([]); + store.readContent.mockResolvedValue(undefined); client.listResources = async function* () { yield { - id: `${testContext.baseUrl}/subscriptions/src-sub-product`, - name: 'src-sub-product', - properties: { - scope: `${testContext.baseUrl}/products/my-product`, - displayName: 'Kitchen Sink Product Subscription', - }, + name: generatedSubscriptionId('f'), + properties: { scope: `${testContext.baseUrl}/products/my-product` }, }; }; const result = await publishProduct(client, store, testContext, productDescriptor, testConfig); expect(result.status).toBe('success'); - expect(client.deleteResource).not.toHaveBeenCalledWith( - testContext, - expect.objectContaining({ - type: ResourceType.Subscription, - nameParts: ['src-sub-product'], - }) - ); + expect(result.relatedResults).toEqual([]); }); it('does not run cleanup when product already exists', async () => { diff --git a/tests/unit/services/resource-publisher.test.ts b/tests/unit/services/resource-publisher.test.ts index 61c6078e..d3f0682b 100644 --- a/tests/unit/services/resource-publisher.test.ts +++ b/tests/unit/services/resource-publisher.test.ts @@ -1296,7 +1296,7 @@ describe('resource-publisher', () => { expect(client.putResource).not.toHaveBeenCalled(); }); - it('should skip auto-generated product subscription with empty displayName', async () => { + it('should publish 24-hex product subscription with empty displayName', async () => { const client = createMockClient(); const store = createMockStore(); const autoGeneratedId = generatedSubscriptionId('a'); @@ -1322,12 +1322,12 @@ describe('resource-publisher', () => { const result = await publishResource(client, store, testContext, descriptor, testConfig); - expect(result.status).toBe('skipped'); - expect(result.action).toBe('noop'); - expect(client.putResource).not.toHaveBeenCalled(); + expect(result.status).toBe('success'); + expect(result.action).toBe('put'); + expect(client.putResource).toHaveBeenCalledTimes(1); }); - it('should skip auto-generated product subscription when displayName is set', async () => { + it('should apply key overrides to 24-hex product subscription', async () => { const client = createMockClient(); const store = createMockStore(); const autoGeneratedId = generatedSubscriptionId('b'); @@ -1349,12 +1349,25 @@ describe('resource-publisher', () => { type: ResourceType.Subscription, nameParts: [autoGeneratedId], }; + const config: PublishConfig = { + ...testConfig, + overrides: { + subscriptions: { + [autoGeneratedId]: { + properties: { primaryKey: 'prod-primary', secondaryKey: 'prod-secondary' }, + }, + }, + }, + }; - const result = await publishResource(client, store, testContext, descriptor, testConfig); + const result = await publishResource(client, store, testContext, descriptor, config); - expect(result.status).toBe('skipped'); - expect(result.action).toBe('noop'); - expect(client.putResource).not.toHaveBeenCalled(); + expect(result.status).toBe('success'); + const putJson = client.putResource.mock.calls[0][2] as Record; + expect(putJson.properties).toMatchObject({ + primaryKey: 'prod-primary', + secondaryKey: 'prod-secondary', + }); }); it('should publish user-defined product subscription', async () => {