From d5a3dcb1f313b351e19cf53786ea344d5cbc8f47 Mon Sep 17 00:00:00 2001 From: Dapeng Zhang Date: Wed, 20 May 2026 17:11:57 +0800 Subject: [PATCH 1/3] Fix tuple resource list handling in mgmt generator Classify array-returning tuple resource list operations as resource lists and emit all matching collection GetAll overloads so Compute extension image list operations remain available. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../docs/resource-detection.md | 33 +++-- .../emitter/src/resource-detection.ts | 10 +- .../emitter/test/resource-detection.test.ts | 103 ++++++++++++++- .../ResourceCollectionClientProvider.cs | 120 ++++++++++++------ 4 files changed, 201 insertions(+), 65 deletions(-) diff --git a/eng/packages/http-client-csharp-mgmt/docs/resource-detection.md b/eng/packages/http-client-csharp-mgmt/docs/resource-detection.md index e27010f4b1b6..bbe22dfcfb29 100644 --- a/eng/packages/http-client-csharp-mgmt/docs/resource-detection.md +++ b/eng/packages/http-client-csharp-mgmt/docs/resource-detection.md @@ -297,24 +297,21 @@ For each remaining operation `O`: 2. If `O`'s response is not a collection of some item model `T`, skip — it cannot be a `List`. 3. Otherwise, look for a resource `R` in the **detected resource set** - such that `R.model == T` and `O.path` identifies a collection of - `R`. A path identifies a collection of `R` when either: - - `O.path` is exactly `R.instancePath` with the trailing name segment - removed. This is the normal list-by-parent/list-by-resource-group - shape. Singleton resources are handled the same way: the collection - path is the singleton instance path with its literal singleton name - removed. - - `O.path` has the same ARM resource type as `R` and ends on that - resource type. This covers scope-level list operations whose path - intentionally omits part of the instance path, such as a - subscription-level list of resource-group-scoped resources. - - Exact collection-path matches take precedence over scope-level - resource-type matches. If multiple resources still match, attach `O` - to the resource with the **shortest** matching `instancePath` as - `R.List`. (The shortest match is the closest containing resource - whose model is `T` — i.e. `R` itself, not some deeper resource that - happens to also be of model `T`.) + such that `R.model == T` and `O.path` is a prefix of + `R.instancePath`. This covers normal list-by-parent/list-by-resource-group + operations, singleton resources, and tuple-resource list operations at + intermediate collection paths. + + If no prefix match is found, a scope-level list can still match when + `O.path` has the same ARM resource type as `R`, has compatible scope + nesting, and ends on that resource type. This covers scope-level list + operations whose path intentionally omits part of the instance path, such as + a subscription-level list of resource-group-scoped resources. + + If multiple resources match, attach `O` to the resource with the + **shortest** matching `instancePath` as `R.List`. (The shortest match is + the closest containing resource whose model is `T` — i.e. `R` itself, + not some deeper resource that happens to also be of model `T`.) 4. If no such `R` exists, `O` falls through to Pass 2. #### Pass 2 — Everything else is an `Action` diff --git a/eng/packages/http-client-csharp-mgmt/emitter/src/resource-detection.ts b/eng/packages/http-client-csharp-mgmt/emitter/src/resource-detection.ts index 67d3d02f1802..d61a7b3c792f 100644 --- a/eng/packages/http-client-csharp-mgmt/emitter/src/resource-detection.ts +++ b/eng/packages/http-client-csharp-mgmt/emitter/src/resource-detection.ts @@ -390,7 +390,6 @@ function getDirectResponseModelId( function getPagingItemModelIdLocal( method: SdkMethod ): string | undefined { - if (method.kind !== "paging" && method.kind !== "lropaging") return undefined; const r = method.response?.type; if (r?.kind === "array" && r.valueType.kind === "model") { return (r.valueType as SdkModelType).crossLanguageDefinitionId; @@ -491,12 +490,11 @@ function findListTargetResource( (resource) => resource.resourceModelId === itemModelId ); - const exactCollectionMatches = candidates.filter( - (resource) => - resource.metadata.resourceIdPattern.trimLastSegment?.equals(operationPath) + const collectionMatches = candidates.filter((resource) => + operationPath.isPrefixOf(resource.metadata.resourceIdPattern) ); - if (exactCollectionMatches.length > 0) { - return shortestResourcePath(exactCollectionMatches); + if (collectionMatches.length > 0) { + return shortestResourcePath(collectionMatches); } const operationType = operationPath.resourceType; diff --git a/eng/packages/http-client-csharp-mgmt/emitter/test/resource-detection.test.ts b/eng/packages/http-client-csharp-mgmt/emitter/test/resource-detection.test.ts index c02be59fb9df..1a8573a3912a 100644 --- a/eng/packages/http-client-csharp-mgmt/emitter/test/resource-detection.test.ts +++ b/eng/packages/http-client-csharp-mgmt/emitter/test/resource-detection.test.ts @@ -2277,6 +2277,105 @@ interface ProfileRevisions { ); }); + it("tuple-resource intermediate collection path is assigned as list", async () => { + const program = await typeSpecCompile( + ` +/** Virtual machine extension image resource */ +model VirtualMachineExtensionImage is TrackedResource { + ...ResourceNameParameter< + Resource = VirtualMachineExtensionImage, + KeyName = "type", + SegmentName = "types", + NamePattern = "" + >; +} + +/** Virtual machine extension image properties */ +model VirtualMachineExtensionImageProperties { + /** Display name */ + displayName?: string; +} + +alias ImageTypePath = { + ...ApiVersionParameter; + ...SubscriptionIdParameter; + ...LocationResourceParameter; + /** Publisher name */ + @path + @segment("publishers") + publisherName: string; +}; + +#suppress "@azure-tools/typespec-azure-resource-manager/arm-resource-interface-requires-decorator" "Testing static tuple resource paths" +interface VirtualMachineExtensionImages { + @get + @route("/subscriptions/{subscriptionId}/providers/Microsoft.Compute/locations/{location}/publishers/{publisherName}/artifacttypes/vmextension/types/{type}/versions/{version}") + get( + ...ImageTypePath, + ...KeysOf, + /** Version name */ + @path + @segment("versions") + version: string + ): ArmResponse | ErrorResponse; + + @get + @route("/subscriptions/{subscriptionId}/providers/Microsoft.Compute/locations/{location}/publishers/{publisherName}/artifacttypes/vmextension/types") + listTypes(...ImageTypePath): ArmResponse | ErrorResponse; + + @get + @route("/subscriptions/{subscriptionId}/providers/Microsoft.Compute/locations/{location}/publishers/{publisherName}/artifacttypes/vmextension/types/{type}/versions") + listVersions( + ...ImageTypePath, + ...KeysOf + ): ArmResponse | ErrorResponse; +} +`, + runner, + { providerNamespace: "Microsoft.Compute" } + ); + const context = createEmitterContext(program); + const sdkContext = await createCSharpSdkContext(context); + const [root] = createModel(sdkContext); + + const armProviderSchema = buildArmProviderSchema(sdkContext, root); + ok(armProviderSchema); + + const imageModel = root.models.find( + (m) => m.name === "VirtualMachineExtensionImage" + ); + ok(imageModel, "VirtualMachineExtensionImage model should exist"); + + const imageResource = armProviderSchema.resources.find( + (r) => + r.resourceModelId === imageModel.crossLanguageDefinitionId && + r.metadata.resourceIdPattern.path.endsWith( + "/types/{type}/versions/{version}" + ) + ); + ok( + imageResource, + "VirtualMachineExtensionImage resource should be detected" + ); + + const listPaths = imageResource.metadata.methods + .filter((m) => m.kind === "List") + .map((m) => m.operationPath.path) + .sort(); + + deepStrictEqual(listPaths, [ + "/subscriptions/{subscriptionId}/providers/Microsoft.Compute/locations/{location}/publishers/{publisherName}/artifacttypes/vmextension/types", + "/subscriptions/{subscriptionId}/providers/Microsoft.Compute/locations/{location}/publishers/{publisherName}/artifacttypes/vmextension/types/{type}/versions" + ]); + strictEqual( + armProviderSchema.nonResourceMethods.some((m) => + m.operationPath.path.includes("/artifacttypes/vmextension/types") + ), + false, + "Tuple-resource list operations should not remain non-resource methods" + ); + }); + it("custom Azure resource with @customAzureResource decorator (TrafficManager pattern)", async () => { const program = await typeSpecCompile( ` @@ -2426,8 +2525,8 @@ interface TrafficEndpoints { ); strictEqual( trafficProfileResource.metadata.methods.length, - 3, - "TrafficProfile should have 3 methods present in the code model (get, createOrUpdate, delete)" + 4, + "TrafficProfile should have 4 methods present in the code model (get, createOrUpdate, delete, list)" ); // Find the TrafficEndpoint resource diff --git a/eng/packages/http-client-csharp-mgmt/generator/Azure.Generator.Management/src/Providers/ResourceCollectionClientProvider.cs b/eng/packages/http-client-csharp-mgmt/generator/Azure.Generator.Management/src/Providers/ResourceCollectionClientProvider.cs index da1e7e5d8315..fc40f2f7fbdd 100644 --- a/eng/packages/http-client-csharp-mgmt/generator/Azure.Generator.Management/src/Providers/ResourceCollectionClientProvider.cs +++ b/eng/packages/http-client-csharp-mgmt/generator/Azure.Generator.Management/src/Providers/ResourceCollectionClientProvider.cs @@ -33,7 +33,7 @@ internal sealed class ResourceCollectionClientProvider : TypeProvider private readonly IReadOnlyList _extraCtorParameters; private readonly IReadOnlyList _extraFields; private readonly ResourceClientProvider _resource; - private readonly ResourceMethod? _getAll; + private readonly IReadOnlyList _getAlls; private readonly ResourceMethod? _create; private readonly ResourceMethod? _get; @@ -60,35 +60,33 @@ internal ResourceCollectionClientProvider(ResourceClientProvider resource, Input _resourceTypeExpression = Static(_resource.Type).As().ResourceType(); - InitializeMethods(resourceMethods, ref _get, ref _create, ref _getAll); - _operationContext = InitializeContext(this, resourceMetadata, _getAll); + var contextualPath = GetContextualPath(resourceMetadata); + (_get, _create, _getAlls) = InitializeMethods(resourceMethods, contextualPath); + _operationContext = InitializeContext(this, contextualPath, _getAlls.Count > 0 ? _getAlls[0] : null); - // this depends on _getAll being initialized + // this depends on _getAlls being initialized (_extraCtorParameters, _extraFields) = BuildExtraConstructorParametersAndFields(); } - private static OperationContext InitializeContext(ResourceCollectionClientProvider enclosingType, ArmResourceMetadata resourceMetadata, ResourceMethod? getAll) + private static OperationContext InitializeContext(ResourceCollectionClientProvider enclosingType, RequestPathPattern contextualPath, ResourceMethod? canonicalGetAll) { - var contextualPath = GetContextualPath(resourceMetadata); - if (getAll is not null) + if (canonicalGetAll is null) { - var secondaryContextualPath = getAll.OperationPath; - // validate the contextualPath should be an ancestor of the secondaryContextualPath, otherwise report diagnostic. - if (!contextualPath.IsAncestorOf(secondaryContextualPath)) - { - // Report diagnostic - ManagementClientGenerator.Instance.Emitter.ReportDiagnostic( - code: "malformed-resource-detected", - message: $"The contextual path '{contextualPath}' is not an ancestor of the secondary contextual path '{secondaryContextualPath}'.", - targetCrossLanguageDefinitionId: getAll.InputMethod.CrossLanguageDefinitionId - ); - } - return OperationContext.Create(contextualPath, secondaryContextualPath, enclosingType.FindField); + return OperationContext.Create(contextualPath); } - else + + var secondaryContextualPath = canonicalGetAll.OperationPath; + // validate the contextualPath should be an ancestor of the secondaryContextualPath, otherwise report diagnostic. + if (!contextualPath.IsAncestorOf(secondaryContextualPath)) { - return OperationContext.Create(contextualPath); + // Report diagnostic + ManagementClientGenerator.Instance.Emitter.ReportDiagnostic( + code: "malformed-resource-detected", + message: $"The contextual path '{contextualPath}' is not an ancestor of the secondary contextual path '{secondaryContextualPath}'.", + targetCrossLanguageDefinitionId: canonicalGetAll.InputMethod.CrossLanguageDefinitionId + ); } + return OperationContext.Create(contextualPath, secondaryContextualPath, enclosingType.FindField); } private FieldProvider FindField(string variableName) @@ -128,7 +126,7 @@ private static RequestPathPattern GetContextualPath(ArmResourceMetadata resource var parameter = new ParameterProvider( contextualParameter.VariableName, $"The {contextualParameter.VariableName} for the resource.", - ResourceHelpers.GetRequestPathParameterType(contextualParameter.VariableName, _getAll!.InputMethod)); + ResourceHelpers.GetRequestPathParameterType(contextualParameter.VariableName, _getAlls[0].InputMethod)); var field = new FieldProvider(FieldModifiers.Private | FieldModifiers.ReadOnly, parameter.Type, $"_{contextualParameter.VariableName}", this, description: $"The {contextualParameter.VariableName}."); parameter.Field = field; extraParameters.Add(parameter); @@ -137,32 +135,64 @@ private static RequestPathPattern GetContextualPath(ArmResourceMetadata resource return (extraParameters, extraFields); } - private static void InitializeMethods( + private static (ResourceMethod? Get, ResourceMethod? Create, IReadOnlyList GetAlls) InitializeMethods( IReadOnlyList resourceMethods, - ref ResourceMethod? getMethod, - ref ResourceMethod? createMethod, - ref ResourceMethod? getAllMethod) + RequestPathPattern contextualPath) { + ResourceMethod? getMethod = null; + ResourceMethod? createMethod = null; + var listMethods = new List(); + foreach (var method in resourceMethods) { - if (getAllMethod is not null && createMethod is not null && getMethod is not null) - { - break; // we already have all methods we need - } - switch (method.Kind) { case ResourceOperationKind.Read: - getMethod = method; + getMethod ??= method; break; case ResourceOperationKind.List: - getAllMethod = method; + listMethods.Add(method); break; case ResourceOperationKind.Create: - createMethod = method; + createMethod ??= method; break; } } + + return (getMethod, createMethod, SortGetAllMethodsByScopeBreadth(listMethods, contextualPath)); + } + + private static IReadOnlyList SortGetAllMethodsByScopeBreadth( + IReadOnlyList listMethods, + RequestPathPattern contextualPath) + { + if (listMethods.Count <= 1) + { + return listMethods; + } + + return [.. listMethods + .OrderBy(m => CountExtraVariableSegments(contextualPath, m.OperationPath)) + .ThenBy(m => m.OperationPath.Count)]; + } + + private static int CountExtraVariableSegments(RequestPathPattern contextualPath, RequestPathPattern operationPath) + { + if (!contextualPath.IsAncestorOf(operationPath)) + { + return int.MaxValue; + } + + var extra = contextualPath.TrimAncestorFrom(operationPath); + int count = 0; + foreach (var segment in extra) + { + if (!segment.IsConstant) + { + count++; + } + } + return count; } public ResourceClientProvider Resource => _resource; @@ -340,16 +370,28 @@ protected override MethodProvider[] BuildMethods() private MethodProvider[] BuildGetAllMethods() { - if (_getAll is null) + if (_getAlls.Count == 0) { return []; } - // implement paging method GetAll - _getAllSyncMethodProvider = BuildGetAllMethod(_getAll, false); - var getAllAsync = BuildGetAllMethod(_getAll, true); + var methods = new List(_getAlls.Count * 2); + for (int i = 0; i < _getAlls.Count; i++) + { + var listMethod = _getAlls[i]; + var sync = BuildGetAllMethod(listMethod, false); + var async = BuildGetAllMethod(listMethod, true); + + if (i == 0) + { + _getAllSyncMethodProvider = sync; + } + + methods.Add(async); + methods.Add(sync); + } - return [getAllAsync, _getAllSyncMethodProvider]; + return [.. methods]; } private MethodProvider[] BuildEnumeratorMethods() From d4967ad2c79f9338e3c5f8bee9c76e139995e0ae Mon Sep 17 00:00:00 2001 From: Dapeng Zhang Date: Mon, 25 May 2026 11:16:39 +0800 Subject: [PATCH 2/3] Remove unintended Storage change Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- sdk/storage/Azure.ResourceManager.Storage/tsp-location.yaml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/sdk/storage/Azure.ResourceManager.Storage/tsp-location.yaml b/sdk/storage/Azure.ResourceManager.Storage/tsp-location.yaml index 703c91b65ba6..a8b5e68e20d1 100644 --- a/sdk/storage/Azure.ResourceManager.Storage/tsp-location.yaml +++ b/sdk/storage/Azure.ResourceManager.Storage/tsp-location.yaml @@ -1,6 +1,6 @@ directory: specification/storage/Storage.Management -commit: c847b84234b3fd7caf6774a1e00bfc0de6dc389e +commit: 7811c124444dfbd4bd149ad88f2648854dd9201b repo: Azure/azure-rest-api-specs -additionalDirectories: +additionalDirectories: emitterPackageJsonPath: eng/azure-typespec-http-client-csharp-mgmt-emitter-package.json From dfe60969ce3f8e75aca85ea8e5f5f77fa957ecd9 Mon Sep 17 00:00:00 2001 From: Dapeng Zhang Date: Mon, 25 May 2026 11:20:30 +0800 Subject: [PATCH 3/3] Revert unintended Storage spec update Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- sdk/storage/Azure.ResourceManager.Storage/tsp-location.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/sdk/storage/Azure.ResourceManager.Storage/tsp-location.yaml b/sdk/storage/Azure.ResourceManager.Storage/tsp-location.yaml index a8b5e68e20d1..5566efd021a3 100644 --- a/sdk/storage/Azure.ResourceManager.Storage/tsp-location.yaml +++ b/sdk/storage/Azure.ResourceManager.Storage/tsp-location.yaml @@ -1,5 +1,5 @@ directory: specification/storage/Storage.Management -commit: 7811c124444dfbd4bd149ad88f2648854dd9201b +commit: c847b84234b3fd7caf6774a1e00bfc0de6dc389e repo: Azure/azure-rest-api-specs additionalDirectories: