diff --git a/.chronus/changes/introduce-tests-for-bug-2025-11-2-2-42-19.md b/.chronus/changes/introduce-tests-for-bug-2025-11-2-2-42-19.md new file mode 100644 index 0000000000..be8150d8e1 --- /dev/null +++ b/.chronus/changes/introduce-tests-for-bug-2025-11-2-2-42-19.md @@ -0,0 +1,8 @@ +--- +# Change versionKind to one of: internal, fix, dependencies, feature, deprecation, breaking +changeKind: fix +packages: + - "@azure-tools/typespec-azure-resource-manager" +--- + +Fix issues in `resolveArmResources` when we have singleton resource with a customized name diff --git a/core b/core index fecf4f2300..f1fa49ad1d 160000 --- a/core +++ b/core @@ -1 +1 @@ -Subproject commit fecf4f2300b88dc663f27dcb3f37bff78313a17b +Subproject commit f1fa49ad1dfdc5585ba44e2ad60091916cb2b061 diff --git a/packages/typespec-azure-resource-manager/src/resource.ts b/packages/typespec-azure-resource-manager/src/resource.ts index 348625bbe6..6b3f5304ab 100644 --- a/packages/typespec-azure-resource-manager/src/resource.ts +++ b/packages/typespec-azure-resource-manager/src/resource.ts @@ -167,6 +167,8 @@ interface ResolvedResourceOperations { parent?: ResolvedResource; /** The scope of this resource */ scope?: string; + /** For singleton resources, the names that can be used to reference the resource */ + singletonResourceNames?: string[]; } /** Resolved operations, including operations for non-arm resources */ export interface ResolvedResource { @@ -190,6 +192,8 @@ export interface ResolvedResource { parent?: ResolvedResource; /** The scope of this resource */ scope?: string | ResolvedResource; + /** For singleton resources, the names that can be used to reference the resource */ + singletonResourceNames?: string[]; } /** Description of the resource type */ @@ -621,15 +625,20 @@ function getResourceScope( return undefined; } -function isVariableSegment(segment: string): boolean { - return (segment.startsWith("{") && segment.endsWith("}")) || segment === "default"; +function isVariableSegment(segment: string, singletonResourceName?: string): boolean { + return (segment.startsWith("{") && segment.endsWith("}")) || segment === singletonResourceName; } function getResourceInfo( program: Program, operation: ArmResourceOperation, + singletonResourceName: string | undefined, ): ResolvedResourceInfo | undefined { - const pathInfo = getResourcePathElements(operation.httpOperation.path, operation.kind); + const pathInfo = getResourcePathElements( + operation.httpOperation.path, + operation.kind, + singletonResourceName, + ); if (pathInfo === undefined) return undefined; return { ...pathInfo, @@ -640,6 +649,7 @@ function getResourceInfo( export function getResourcePathElements( path: string, kind: ArmOperationKind, + singletonResourceName?: string, ): ResourcePathInfo | undefined { const segments = path.split("/").filter((s) => s.length > 0); const providerIndex = segments.findLastIndex((s) => s === "providers"); @@ -652,7 +662,15 @@ export function getResourcePathElements( break; } - if (i + 1 < segments.length && isVariableSegment(segments[i + 1])) { + // if the next segment is the last segment + if ( + i + 1 === segments.length - 1 && + isVariableSegment(segments[i + 1], singletonResourceName) + ) { + typeSegments.push(segments[i]); + instanceSegments.push(segments[i]); + instanceSegments.push(segments[i + 1]); + } else if (i + 1 < segments.length && isVariableSegment(segments[i + 1])) { typeSegments.push(segments[i]); instanceSegments.push(segments[i]); instanceSegments.push(segments[i + 1]); @@ -862,9 +880,10 @@ export function resolveArmResourceOperations( if (armOperation === undefined) continue; armOperation.kind = operation.kind; + const singletonResourceName = getSingletonResourceKey(program, resourceType); armOperation.resourceModelName = operation.resource?.name ?? resourceType.name; - const resourceInfo = getResourceInfo(program, armOperation); + const resourceInfo = getResourceInfo(program, armOperation, singletonResourceName); if (resourceInfo === undefined) continue; armOperation.name = operation.name; armOperation.resourceKind = operation.resourceKind; @@ -893,6 +912,8 @@ export function resolveArmResourceOperations( resourceType: resourceInfo.resourceType, resourceInstancePath: resourceInfo.resourceInstancePath, resourceName: resourceInfo.resourceName, + singletonResourceNames: + singletonResourceName !== undefined ? [singletonResourceName] : undefined, operations: { lifecycle: { read: undefined, diff --git a/packages/typespec-azure-resource-manager/test/resource-resolution.test.ts b/packages/typespec-azure-resource-manager/test/resource-resolution.test.ts index bfa7f63403..15d472f797 100644 --- a/packages/typespec-azure-resource-manager/test/resource-resolution.test.ts +++ b/packages/typespec-azure-resource-manager/test/resource-resolution.test.ts @@ -122,6 +122,7 @@ describe("unit tests for resource manager helpers", () => { title: string; path: string; kind: ArmOperationKind; + singletonResourceName?: string; expected: ResourcePathInfo; }[] = [ { @@ -257,39 +258,42 @@ describe("unit tests for resource manager helpers", () => { title: "generic extension resource weird read path with default", path: "/subscriptions/{subscriptionId}/resourceGroups/{resourceGroupName}/providers/{providerName}/{resourceType}/{resourceName}/{childResourceType}/{childResourceName}/providers/Microsoft.Bar/bars/{barName}/basses/default/actionName/doSomething/doSomethingElse/andAnotherThing", kind: "read", + singletonResourceName: "default", expected: { resourceType: { provider: "Microsoft.Bar", - types: ["bars", "basses"], + types: ["bars"], }, resourceInstancePath: - "/subscriptions/{subscriptionId}/resourceGroups/{resourceGroupName}/providers/{providerName}/{resourceType}/{resourceName}/{childResourceType}/{childResourceName}/providers/Microsoft.Bar/bars/{barName}/basses/default", + "/subscriptions/{subscriptionId}/resourceGroups/{resourceGroupName}/providers/{providerName}/{resourceType}/{resourceName}/{childResourceType}/{childResourceName}/providers/Microsoft.Bar/bars/{barName}", }, }, { title: "handles paths with leading and trailing slashes", path: "/subscriptions/{subscriptionId}/resourceGroups/{resourceGroupName}/providers/{providerName}/{resourceType}/{resourceName}/{childResourceType}/{childResourceName}/providers/Microsoft.Bar/bars/{barName}/basses/default/actionName/doSomething/doSomethingElse/andAnotherThing/", kind: "read", + singletonResourceName: "default", expected: { resourceType: { provider: "Microsoft.Bar", - types: ["bars", "basses"], + types: ["bars"], }, resourceInstancePath: - "/subscriptions/{subscriptionId}/resourceGroups/{resourceGroupName}/providers/{providerName}/{resourceType}/{resourceName}/{childResourceType}/{childResourceName}/providers/Microsoft.Bar/bars/{barName}/basses/default", + "/subscriptions/{subscriptionId}/resourceGroups/{resourceGroupName}/providers/{providerName}/{resourceType}/{resourceName}/{childResourceType}/{childResourceName}/providers/Microsoft.Bar/bars/{barName}", }, }, { title: "handles paths without leading and trailing slashes", path: "subscriptions/{subscriptionId}/resourceGroups/{resourceGroupName}/providers/{providerName}/{resourceType}/{resourceName}/{childResourceType}/{childResourceName}/providers/Microsoft.Bar/bars/{barName}/basses/default/actionName/doSomething/doSomethingElse/andAnotherThing", kind: "read", + singletonResourceName: "default", expected: { resourceType: { provider: "Microsoft.Bar", - types: ["bars", "basses"], + types: ["bars"], }, resourceInstancePath: - "/subscriptions/{subscriptionId}/resourceGroups/{resourceGroupName}/providers/{providerName}/{resourceType}/{resourceName}/{childResourceType}/{childResourceName}/providers/Microsoft.Bar/bars/{barName}/basses/default", + "/subscriptions/{subscriptionId}/resourceGroups/{resourceGroupName}/providers/{providerName}/{resourceType}/{resourceName}/{childResourceType}/{childResourceName}/providers/Microsoft.Bar/bars/{barName}", }, }, { @@ -319,9 +323,9 @@ describe("unit tests for resource manager helpers", () => { }, }, ]; - for (const { title, path, kind, expected } of cases) { + for (const { title, path, kind, singletonResourceName, expected } of cases) { it(`parses path for ${title} operations correctly`, () => { - const result = getResourcePathElements(path, kind); + const result = getResourcePathElements(path, kind, singletonResourceName); expect(result).toEqual(expected); }); } @@ -3546,4 +3550,331 @@ model DependentProperties { expect(ResB.resourceName).toEqual("ResB"); expect(ResB.providerNamespace).toEqual("Microsoft.ServiceB"); }); + + it("supports singleton resource", async () => { + const { program } = await Tester.compile(` +using Azure.Core; + +@armProviderNamespace +namespace Microsoft.ContosoProviderHub; + +interface Operations extends Azure.ResourceManager.Operations {} + +@singleton +model Employee is TrackedResource { + ...ResourceNameParameter; +} + +model EmployeeProperties { + age?: int32; + + @visibility(Lifecycle.Read) + provisioningState?: ProvisioningState; +} + +@lroStatus +union ProvisioningState { + ResourceProvisioningState, + + Provisioning: "Provisioning", + + Updating: "Updating", + + Deleting: "Deleting", + + Accepted: "Accepted", + + string, +} + +@armResourceOperations +interface Employees { + get is ArmResourceRead; + createOrUpdate is ArmResourceCreateOrReplaceAsync; + update is ArmCustomPatchSync< + Employee, + Azure.ResourceManager.Foundations.ResourceUpdateModel + >; +} +`); + const provider = resolveArmResources(program); + expect(provider).toBeDefined(); + expect(provider.resources).toBeDefined(); + expect(provider.resources).toHaveLength(1); + const employee = provider.resources![0]; + ok(employee); + expect(employee).toMatchObject({ + kind: "Tracked", + providerNamespace: "Microsoft.ContosoProviderHub", + type: expect.anything(), + resourceType: { + provider: "Microsoft.ContosoProviderHub", + types: ["employees"], + }, + scope: "ResourceGroup", + parent: undefined, + }); + }); + it("supports singleton resource with customized name", async () => { + const { program } = await Tester.compile(` +using Azure.Core; + +@armProviderNamespace +namespace Microsoft.ContosoProviderHub; + +interface Operations extends Azure.ResourceManager.Operations {} + +@singleton("current") +model Employee is TrackedResource { + ...ResourceNameParameter; +} + +model EmployeeProperties { + age?: int32; + + @visibility(Lifecycle.Read) + provisioningState?: ProvisioningState; +} + +@lroStatus +union ProvisioningState { + ResourceProvisioningState, + + Provisioning: "Provisioning", + + Updating: "Updating", + + Deleting: "Deleting", + + Accepted: "Accepted", + + string, +} + +@armResourceOperations +interface Employees { + get is ArmResourceRead; + createOrUpdate is ArmResourceCreateOrReplaceAsync; + update is ArmCustomPatchSync< + Employee, + Azure.ResourceManager.Foundations.ResourceUpdateModel + >; +} +`); + const provider = resolveArmResources(program); + expect(provider).toBeDefined(); + expect(provider.resources).toBeDefined(); + expect(provider.resources).toHaveLength(1); + const employee = provider.resources![0]; + ok(employee); + expect(employee).toMatchObject({ + kind: "Tracked", + providerNamespace: "Microsoft.ContosoProviderHub", + type: expect.anything(), + resourceType: { + provider: "Microsoft.ContosoProviderHub", + types: ["employees"], + }, + scope: "ResourceGroup", + parent: undefined, + }); + }); + it("supports multiple singleton resources", async () => { + const { program } = await Tester.compile(` +using Azure.Core; + +@armProviderNamespace +namespace Microsoft.ContosoProviderHub; + +interface Operations extends Azure.ResourceManager.Operations {} + +@singleton +model Employee is TrackedResource { + ...ResourceNameParameter; +} + +model EmployeeProperties { + age?: int32; + + @visibility(Lifecycle.Read) + provisioningState?: ProvisioningState; +} + +@singleton +model Building is TrackedResource { + ...ResourceNameParameter; +} + +model BuildingProperties { + address?: string; + + @visibility(Lifecycle.Read) + provisioningState?: ProvisioningState; +} + +@armResourceOperations +interface Employees { + get is ArmResourceRead; + createOrUpdate is ArmResourceCreateOrReplaceAsync; + update is ArmCustomPatchSync< + Employee, + Azure.ResourceManager.Foundations.ResourceUpdateModel + >; +} + +@armResourceOperations +interface Buildings { + get is ArmResourceRead; + createOrUpdate is ArmResourceCreateOrReplaceAsync; + update is ArmCustomPatchSync< + Building, + Azure.ResourceManager.Foundations.ResourceUpdateModel + >; +} + +@lroStatus +union ProvisioningState { + ResourceProvisioningState, + + Provisioning: "Provisioning", + + Updating: "Updating", + + Deleting: "Deleting", + + Accepted: "Accepted", + + string, +} +`); + const provider = resolveArmResources(program); + expect(provider).toBeDefined(); + expect(provider.resources).toBeDefined(); + expect(provider.resources).toHaveLength(2); + const employee = provider.resources![0]; + ok(employee); + expect(employee).toMatchObject({ + kind: "Tracked", + providerNamespace: "Microsoft.ContosoProviderHub", + type: expect.anything(), + resourceType: { + provider: "Microsoft.ContosoProviderHub", + types: ["employees"], + }, + scope: "ResourceGroup", + parent: undefined, + }); + const building = provider.resources![1]; + ok(building); + expect(building).toMatchObject({ + kind: "Tracked", + providerNamespace: "Microsoft.ContosoProviderHub", + type: expect.anything(), + resourceType: { + provider: "Microsoft.ContosoProviderHub", + types: ["buildings"], + }, + scope: "ResourceGroup", + parent: undefined, + }); + }); + it("supports multiple singleton resources with different names", async () => { + const { program } = await Tester.compile(` +using Azure.Core; + +@armProviderNamespace +namespace Microsoft.ContosoProviderHub; + +interface Operations extends Azure.ResourceManager.Operations {} + +@singleton +model Employee is TrackedResource { + ...ResourceNameParameter; +} + +model EmployeeProperties { + age?: int32; + + @visibility(Lifecycle.Read) + provisioningState?: ProvisioningState; +} + +@singleton("current") +model Building is TrackedResource { + ...ResourceNameParameter; +} + +model BuildingProperties { + address?: string; + + @visibility(Lifecycle.Read) + provisioningState?: ProvisioningState; +} + +@armResourceOperations +interface Employees { + get is ArmResourceRead; + createOrUpdate is ArmResourceCreateOrReplaceAsync; + update is ArmCustomPatchSync< + Employee, + Azure.ResourceManager.Foundations.ResourceUpdateModel + >; +} + +@armResourceOperations +interface Buildings { + get is ArmResourceRead; + createOrUpdate is ArmResourceCreateOrReplaceAsync; + update is ArmCustomPatchSync< + Building, + Azure.ResourceManager.Foundations.ResourceUpdateModel + >; +} + +@lroStatus +union ProvisioningState { + ResourceProvisioningState, + + Provisioning: "Provisioning", + + Updating: "Updating", + + Deleting: "Deleting", + + Accepted: "Accepted", + + string, +} +`); + const provider = resolveArmResources(program); + expect(provider).toBeDefined(); + expect(provider.resources).toBeDefined(); + expect(provider.resources).toHaveLength(2); + const employee = provider.resources![0]; + ok(employee); + expect(employee).toMatchObject({ + kind: "Tracked", + providerNamespace: "Microsoft.ContosoProviderHub", + type: expect.anything(), + resourceType: { + provider: "Microsoft.ContosoProviderHub", + types: ["employees"], + }, + scope: "ResourceGroup", + parent: undefined, + }); + const building = provider.resources![1]; + ok(building); + expect(building).toMatchObject({ + kind: "Tracked", + providerNamespace: "Microsoft.ContosoProviderHub", + type: expect.anything(), + resourceType: { + provider: "Microsoft.ContosoProviderHub", + types: ["buildings"], + }, + scope: "ResourceGroup", + parent: undefined, + }); + }); }); diff --git a/packages/typespec-azure-resource-manager/test/resource.test.ts b/packages/typespec-azure-resource-manager/test/resource.test.ts index 145e503f79..eab35f9e24 100644 --- a/packages/typespec-azure-resource-manager/test/resource.test.ts +++ b/packages/typespec-azure-resource-manager/test/resource.test.ts @@ -244,7 +244,7 @@ describe("ARM resource model:", () => { model BarResourceProperties { iAmBar: string; - provisioningState: ResourceState; + provisioningState: ResourceState; } @singleton