Skip to content

Commit d320668

Browse files
Peter HaugeCopilot
andcommitted
fix: use workspace-specific ARM paths for association resources
Workspace-scoped extraction and publishing used service-scope ARM paths for association resources (ProductApi, ProductGroup, ApiTag, ProductTag), causing HTTP 500 errors. Workspace associations use different ARM endpoints (e.g. apiLinks instead of apis) and link response shapes. - Add workspaceArmPathSuffix and workspaceLinkIdProperty metadata - Handle inverted parent-child for ApiTag and ProductTag in workspace scope - Fix double-workspace prefix bug in buildArmUri - Parse link responses to extract real resource names - Add workspace association publishing with link payloads - Centralize workspace scope detection with ARM path regex - Fix parseTemplatePath to re-sort captures by placeholder index - Add VersionSet workspace support; remove Documentation (no endpoint) - Add workspace association resources to integration test Bicep - Add workspace product/API children comparison to roundtrip test Closes #135 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent 930f92c commit d320668

15 files changed

Lines changed: 609 additions & 38 deletions

src/clients/apim-client.ts

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import { RESOURCE_TYPE_METADATA, ResourceType } from '../models/resource-types.j
1313
import { buildArmUri, buildResourceLabel } from '../lib/resource-uri.js';
1414
import { deriveListPaths } from '../lib/resource-path.js';
1515
import { logger } from '../lib/logger.js';
16+
import { isWorkspaceScope } from '../lib/workspace-link.js';
1617
import { USER_AGENT } from '../lib/user-agent.js';
1718

1819
/**
@@ -232,7 +233,12 @@ export class ApimClient implements IApimClient {
232233
let url: string;
233234

234235
const meta = RESOURCE_TYPE_METADATA[type];
235-
const { listPath, childListPath } = deriveListPaths(meta.armPathSuffix);
236+
// Use workspace-specific ARM path when context is workspace-scoped
237+
const isWorkspaceScoped = isWorkspaceScope(context);
238+
const armPath = isWorkspaceScoped && meta.workspaceArmPathSuffix
239+
? meta.workspaceArmPathSuffix
240+
: meta.armPathSuffix;
241+
const { listPath, childListPath } = deriveListPaths(armPath);
236242

237243
if (parent) {
238244
// For child resources, use parent's ARM URI as base.

src/lib/resource-path.ts

Lines changed: 24 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -181,8 +181,30 @@ function templateToRegex(template: string): RegExp {
181181
export function parseTemplatePath(template: string, path: string): string[] | undefined {
182182
const match = templateToRegex(template).exec(path);
183183
if (!match) return undefined;
184-
// Captures correspond directly to {0}, {1}, … positions in the template
185-
return match.slice(1);
184+
185+
const captures = match.slice(1);
186+
187+
// Extract placeholder indices in left-to-right appearance order.
188+
// For `tags/{1}/apiLinks/{0}` this yields [1, 0].
189+
const placeholderIndices: number[] = [];
190+
const placeholderPattern = /\{(\d+)\}/g;
191+
let m: RegExpExecArray | null;
192+
while ((m = placeholderPattern.exec(template)) !== null) {
193+
placeholderIndices.push(Number(m[1]));
194+
}
195+
196+
// Re-sort captures so result[i] corresponds to placeholder {i}.
197+
// When indices are sequential (the common case), this is a no-op.
198+
const sorted = new Array<string>(captures.length);
199+
for (let i = 0; i < captures.length; i++) {
200+
const idx = placeholderIndices[i];
201+
const val = captures[i];
202+
if (idx !== undefined && val !== undefined) {
203+
sorted[idx] = val;
204+
}
205+
}
206+
207+
return sorted;
186208
}
187209

188210
/**

src/lib/resource-uri.ts

Lines changed: 28 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
import { ApimServiceContext, ResourceDescriptor } from '../models/types.js';
99
import { RESOURCE_TYPE_METADATA, ResourceType } from '../models/resource-types.js';
1010
import { formatTemplatePath, parseTemplatePath, countTemplatePlaceholders, makeFullPath, makeRelativePath } from './resource-path.js';
11+
import { isWorkspaceScope } from './workspace-link.js';
1112

1213
/**
1314
* Builds the full ARM resource URI for a given descriptor and service context.
@@ -23,20 +24,34 @@ export function buildArmUri(
2324
): string {
2425
const metadata = RESOURCE_TYPE_METADATA[descriptor.type];
2526

27+
// Use workspace-specific ARM path when in workspace scope.
28+
// Workspace scope is indicated either by descriptor.workspace being set or
29+
// by the context.baseUrl already containing a /workspaces/ segment.
30+
const isWorkspaceScoped = !!descriptor.workspace || isWorkspaceScope(context);
31+
const armPathTemplate = isWorkspaceScoped && metadata.workspaceArmPathSuffix
32+
? metadata.workspaceArmPathSuffix
33+
: metadata.armPathSuffix;
34+
2635
// Validate that all positional placeholders have a corresponding name-part
27-
const placeholderCount = countTemplatePlaceholders(metadata.armPathSuffix);
36+
const placeholderCount = countTemplatePlaceholders(armPathTemplate);
2837
if (descriptor.nameParts.length < placeholderCount) {
2938
throw new Error(
3039
`Unresolved placeholder in ARM path for ${descriptor.type}: expected ${placeholderCount} name-parts, got ${descriptor.nameParts.length}`
3140
);
3241
}
3342

3443
// URL-encode each name part before filling the ARM path template
35-
const armPath = formatTemplatePath(metadata.armPathSuffix, descriptor.nameParts.map(encodeURIComponent));
44+
const armPath = formatTemplatePath(armPathTemplate, descriptor.nameParts.map(encodeURIComponent));
45+
46+
// Add workspace prefix if workspace-scoped AND the context base URL does not
47+
// already include it. The workspace extractor modifies context.baseUrl to
48+
// include `/workspaces/{name}`, so adding it again from descriptor.workspace
49+
// would produce a double-prefix.
50+
const contextAlreadyHasWorkspace = isWorkspaceScope(context);
51+
const needsWorkspacePrefix = descriptor.workspace && !contextAlreadyHasWorkspace;
3652

37-
// Add workspace prefix if workspace-scoped; prepend '/' to produce an absolute path
38-
const fullPath = descriptor.workspace
39-
? makeFullPath(`workspaces/${encodeURIComponent(descriptor.workspace)}/${armPath}`)
53+
const fullPath = needsWorkspacePrefix
54+
? makeFullPath(`workspaces/${encodeURIComponent(descriptor.workspace!)}/${armPath}`)
4055
: makeFullPath(armPath);
4156

4257
return `${context.baseUrl}${fullPath}?api-version=${context.apiVersion}`;
@@ -85,6 +100,14 @@ export function parseArmUri(
85100
if (nameParts) {
86101
return { type, nameParts: nameParts.map((m) => decodeURIComponent(m)), workspace };
87102
}
103+
104+
// Also try workspace-specific ARM paths (e.g. `products/{0}/groupLinks/{1}`)
105+
if (workspace && metadata.workspaceArmPathSuffix) {
106+
const wsNameParts = parseTemplatePath(metadata.workspaceArmPathSuffix, relativePath);
107+
if (wsNameParts) {
108+
return { type, nameParts: wsNameParts.map((m) => decodeURIComponent(m)), workspace };
109+
}
110+
}
88111
}
89112

90113
return undefined;

src/lib/workspace-link.ts

Lines changed: 108 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,108 @@
1+
// Copyright (c) Microsoft Corporation.
2+
// Licensed under the MIT license.
3+
/**
4+
* Workspace link response helpers.
5+
*
6+
* In workspace scope, association resources (ProductApi, ProductGroup, ApiTag,
7+
* ProductTag) use "link" endpoints that return objects shaped like:
8+
* { name: "opaqueLinkId", properties: { apiId: "/subscriptions/.../apis/myApi" } }
9+
*
10+
* This module provides helpers to extract the actual resource name from
11+
* these link responses and to build link payloads for publishing.
12+
*/
13+
14+
import { ApimServiceContext } from '../models/types.js';
15+
16+
/**
17+
* Extracts the resource name from a workspace link response item.
18+
*
19+
* Link responses store the associated resource's full ARM ID in a property
20+
* (e.g. `properties.apiId`). This function extracts the last path segment
21+
* (the resource name) from that ARM ID.
22+
*
23+
* @param json - Raw link response item from LIST
24+
* @param linkIdProperty - Property name containing the ARM ID (e.g. 'apiId', 'groupId')
25+
* @returns The extracted resource name, or undefined if not found
26+
*/
27+
export function extractNameFromLink(
28+
json: Record<string, unknown>,
29+
linkIdProperty: string
30+
): string | undefined {
31+
const properties = json.properties as Record<string, unknown> | undefined;
32+
if (!properties) {
33+
return undefined;
34+
}
35+
36+
const armId = properties[linkIdProperty];
37+
if (typeof armId !== 'string' || armId.length === 0) {
38+
return undefined;
39+
}
40+
41+
// ARM resource IDs look like: /subscriptions/.../apis/myApiName
42+
// Extract the last segment as the resource name
43+
const segments = armId.split('/');
44+
const lastSegment = segments[segments.length - 1];
45+
return lastSegment && lastSegment.length > 0
46+
? decodeURIComponent(lastSegment)
47+
: undefined;
48+
}
49+
50+
/**
51+
* Builds the full ARM resource ID for a workspace-scoped resource.
52+
* Used when creating link resources that reference another resource via its ARM ID.
53+
*
54+
* @param context - APIM service context (with workspace baseUrl)
55+
* @param resourcePath - The ARM path segment (e.g. 'apis/myApi' or 'groups/myGroup')
56+
* @returns Full ARM resource ID
57+
*/
58+
export function buildWorkspaceResourceId(
59+
context: ApimServiceContext,
60+
resourcePath: string
61+
): string {
62+
// context.baseUrl for workspace scope is:
63+
// https://management.azure.com/subscriptions/{sub}/resourceGroups/{rg}/providers/Microsoft.ApiManagement/service/{svc}/workspaces/{ws}
64+
// We need to return:
65+
// /subscriptions/{sub}/resourceGroups/{rg}/providers/Microsoft.ApiManagement/service/{svc}/workspaces/{ws}/{resourcePath}
66+
const url = new URL(context.baseUrl);
67+
return `${url.pathname}/${resourcePath}`;
68+
}
69+
70+
/**
71+
* Checks whether a service context is workspace-scoped.
72+
* Workspace-scoped contexts have `/workspaces/{name}` appended after the
73+
* APIM service segment in their base URL. We match the specific ARM path
74+
* structure rather than a naive substring check, so an API or resource
75+
* named "workspaces" won't cause a false positive.
76+
*/
77+
const WORKSPACE_SCOPE_PATTERN = /\/Microsoft\.ApiManagement\/service\/[^/]+\/workspaces\/[^/]+/i;
78+
79+
export function isWorkspaceScope(context: ApimServiceContext): boolean {
80+
return WORKSPACE_SCOPE_PATTERN.test(context.baseUrl);
81+
}
82+
83+
/**
84+
* Builds the PUT payload for creating a workspace link resource.
85+
*
86+
* @param context - Workspace-scoped APIM context
87+
* @param linkIdProperty - Property name for the ARM ID (e.g. 'apiId', 'groupId')
88+
* @param resourceType - The ARM resource type segment (e.g. 'apis', 'groups', 'products')
89+
* @param resourceName - The resource name to link to
90+
* @returns PUT payload for the link resource
91+
*/
92+
export function buildLinkPayload(
93+
context: ApimServiceContext,
94+
linkIdProperty: string,
95+
resourceType: string,
96+
resourceName: string
97+
): Record<string, unknown> {
98+
const resourceId = buildWorkspaceResourceId(
99+
context,
100+
`${resourceType}/${encodeURIComponent(resourceName)}`
101+
);
102+
103+
return {
104+
properties: {
105+
[linkIdProperty]: resourceId,
106+
},
107+
};
108+
}

src/models/resource-types.ts

Lines changed: 22 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -75,6 +75,19 @@ export interface ResourceTypeMetadata {
7575
* without maintaining a separate hardcoded list.
7676
*/
7777
readonly workspaceSupported?: boolean;
78+
/**
79+
* Alternative ARM path suffix used in workspace scope.
80+
* Some association resources use a different "links" endpoint pattern
81+
* in workspace scope (e.g. `products/{0}/apiLinks/{1}` instead of
82+
* `products/{0}/apis/{1}`).
83+
*/
84+
readonly workspaceArmPathSuffix?: string;
85+
/**
86+
* The property name in a workspace link response that contains the
87+
* linked resource's full ARM resource ID (e.g. 'apiId', 'groupId').
88+
* Only relevant when `workspaceArmPathSuffix` is set.
89+
*/
90+
readonly workspaceLinkIdProperty?: string;
7891
}
7992

8093
export const RESOURCE_TYPE_METADATA: Record<ResourceType, ResourceTypeMetadata> = {
@@ -103,6 +116,7 @@ export const RESOURCE_TYPE_METADATA: Record<ResourceType, ResourceTypeMetadata>
103116
artifactDirectory: 'versionSets/{0}',
104117
infoFile: 'versionSetInformation.json',
105118
supportsGet: true,
119+
workspaceSupported: true,
106120
},
107121
[ResourceType.Backend]: {
108122
armPathSuffix: 'backends/{0}',
@@ -163,18 +177,24 @@ export const RESOURCE_TYPE_METADATA: Record<ResourceType, ResourceTypeMetadata>
163177
artifactDirectory: 'products/{0}',
164178
infoFile: 'apis.json',
165179
supportsGet: false,
180+
workspaceArmPathSuffix: 'products/{0}/apiLinks/{1}',
181+
workspaceLinkIdProperty: 'apiId',
166182
},
167183
[ResourceType.ProductGroup]: {
168184
armPathSuffix: 'products/{0}/groups/{1}',
169185
artifactDirectory: 'products/{0}',
170186
infoFile: 'groups.json',
171187
supportsGet: false,
188+
workspaceArmPathSuffix: 'products/{0}/groupLinks/{1}',
189+
workspaceLinkIdProperty: 'groupId',
172190
},
173191
[ResourceType.ProductTag]: {
174192
armPathSuffix: 'products/{0}/tags/{1}',
175193
artifactDirectory: 'products/{0}',
176194
infoFile: null, // Embedded in productInformation.json
177195
supportsGet: false,
196+
workspaceArmPathSuffix: 'tags/{1}/productLinks/{0}',
197+
workspaceLinkIdProperty: 'productId',
178198
},
179199
[ResourceType.Api]: {
180200
armPathSuffix: 'apis/{0}',
@@ -194,6 +214,8 @@ export const RESOURCE_TYPE_METADATA: Record<ResourceType, ResourceTypeMetadata>
194214
artifactDirectory: 'apis/{0}/tags/{1}',
195215
infoFile: 'tagInformation.json',
196216
supportsGet: true,
217+
workspaceArmPathSuffix: 'tags/{1}/apiLinks/{0}',
218+
workspaceLinkIdProperty: 'apiId',
197219
},
198220
[ResourceType.ApiDiagnostic]: {
199221
armPathSuffix: 'apis/{0}/diagnostics/{1}',
@@ -244,7 +266,6 @@ export const RESOURCE_TYPE_METADATA: Record<ResourceType, ResourceTypeMetadata>
244266
artifactDirectory: 'documentations/{0}',
245267
infoFile: 'documentationInformation.json',
246268
supportsGet: true,
247-
workspaceSupported: true,
248269
},
249270
[ResourceType.ApiSchema]: {
250271
armPathSuffix: 'apis/{0}/schemas/{1}',

0 commit comments

Comments
 (0)