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
5 changes: 4 additions & 1 deletion docs/guides/environment-overrides.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
57 changes: 57 additions & 0 deletions src/services/product-publisher.ts
Original file line number Diff line number Diff line change
Expand Up @@ -120,9 +120,66 @@ async function cleanupAutoCreatedProductResources(
context: ApimServiceContext,
productDescriptor: ResourceDescriptor
): Promise<void> {
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<void> {
const productName = getNamePart(productDescriptor.nameParts, 0);
Comment thread
azaslonov marked this conversation as resolved.
Resolved
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)) {
Comment thread
azaslonov marked this conversation as resolved.
Resolved
const name = subscription.name;
const scope = (subscription.properties as Record<string, unknown> | 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)}`);
Comment thread
azaslonov marked this conversation as resolved.
Resolved
}
}
Comment thread
azaslonov marked this conversation as resolved.
Resolved

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,
Expand Down
15 changes: 0 additions & 15 deletions src/services/resource-publisher.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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.
Expand Down
105 changes: 85 additions & 20 deletions tests/unit/services/product-publisher.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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');
Expand All @@ -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([]);
});
Comment thread
azaslonov marked this conversation as resolved.
Resolved

it('does not run cleanup when product already exists', async () => {
Expand Down
31 changes: 22 additions & 9 deletions tests/unit/services/resource-publisher.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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');
Expand All @@ -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');
Expand All @@ -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<string, unknown>;
expect(putJson.properties).toMatchObject({
primaryKey: 'prod-primary',
secondaryKey: 'prod-secondary',
});
});

it('should publish user-defined product subscription', async () => {
Expand Down
Loading