Skip to content

Commit 95bb616

Browse files
[AzRest] added paging functionality to the invoke-azrest command (#28259)
1 parent 705ab29 commit 95bb616

File tree

8 files changed

+974
-8
lines changed

8 files changed

+974
-8
lines changed

src/Accounts/Accounts.Test/InvokeAzRestTests.cs

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,5 +31,12 @@ public void TestInvokeAzRest()
3131
{
3232
TestRunner.RunTestScript("Test-InvokeAzRest");
3333
}
34+
35+
[Fact]
36+
[Trait(Category.AcceptanceType, Category.CheckIn)]
37+
public void TestInvokeAzRestWithPagination()
38+
{
39+
TestRunner.RunTestScript("Test-InvokeAzRest-Pagination");
40+
}
3441
}
3542
}

src/Accounts/Accounts.Test/InvokeAzRestTests.ps1

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,4 +51,56 @@ function Test-InvokeAzRest
5151

5252
Assert-AreEqual 202 $response.StatusCode
5353
Assert-AreEqual $delete $response.Method
54+
}
55+
56+
<#
57+
.SYNOPSIS
58+
Tests Pagination for Invoke-AzRest
59+
.DESCRIPTION
60+
SmokeTest for Pagination
61+
#>
62+
function Test-InvokeAzRest-Pagination
63+
{
64+
$get = "GET"
65+
$subscriptionId = "111111aa-a11a-1111-1aaa-1a11aa1aaa1a"
66+
$providerName = "Microsoft.Compute"
67+
$resourceType = "virtualMachines"
68+
$apiVersion = "2023-03-01"
69+
70+
# Make the REST call using ResourceType
71+
$response = Invoke-AzRest `
72+
-SubscriptionId $subscriptionId `
73+
-ResourceProviderName $providerName `
74+
-ResourceType $resourceType `
75+
-ApiVersion $apiVersion `
76+
-Method $get `
77+
-Paginate `
78+
79+
Assert-NotNull $response
80+
Assert-NotNull $response.Content
81+
Assert-AreEqual 1 $response.Count
82+
83+
$paginatedContent = $response.Content | ConvertFrom-Json
84+
Assert-AreEqual 10 $paginatedContent.value.Count
85+
86+
#error scenarios
87+
$invalidMethods = @("PUT", "POST", "DELETE")
88+
foreach ($method in $invalidMethods) {
89+
$warnings = @()
90+
$WarningPreference = 'Continue'
91+
Invoke-AzRest `
92+
-SubscriptionId $subscriptionId `
93+
-ResourceProviderName $providerName `
94+
-ResourceType $resourceType `
95+
-ApiVersion $apiVersion `
96+
-Method $method `
97+
-Paginate `
98+
-WarningVariable warnings `
99+
100+
$expectedWarning = "The Paginate switch is set, but the Method is not GET. Pagination will not be applied."
101+
102+
if (-not ($warnings -like "*$expectedWarning*")) {
103+
throw "Expected warning not found: $expectedWarning Returned: $warnings instead"
104+
}
105+
}
54106
}

src/Accounts/Accounts.Test/SessionRecords/Microsoft.Azure.Commands.Profile.Test.InvokeAzRestTests/TestInvokeAzRestWithPagination.json

Lines changed: 323 additions & 0 deletions
Large diffs are not rendered by default.
Lines changed: 272 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,272 @@
1+
using Microsoft.Azure.Commands.Profile.Models;
2+
using Microsoft.Azure.Internal.Common;
3+
using Microsoft.Rest.Azure;
4+
using Moq;
5+
using Newtonsoft.Json.Linq;
6+
using System;
7+
using System.Collections.Generic;
8+
using System.Linq;
9+
using System.Net;
10+
using System.Threading;
11+
using System.Threading.Tasks;
12+
using Xunit;
13+
14+
namespace Microsoft.Azure.Commands.Profile.Test.UnitTest
15+
{
16+
public class PSHttpResponseExtensionsTest
17+
{
18+
[Fact]
19+
public async Task PaginateAggregatesPagedResults()
20+
{
21+
// Arrange
22+
var responses = CreateFakePagedResponses(3);
23+
24+
var mockOps = new Mock<IAzureRestOperations>();
25+
mockOps.SetupSequence(o => o.BeginHttpGetMessagesAsyncWithFullResponse(It.IsAny<string>(), It.IsAny<string>(),
26+
It.IsAny<IDictionary<string, IList<string>>>(), It.IsAny<CancellationToken>()))
27+
.ReturnsAsync(responses[1])
28+
.ReturnsAsync(responses[2]);
29+
30+
var mockClient = new Mock<IAzureRestClient>();
31+
mockClient.SetupGet(c => c.Operations).Returns(mockOps.Object);
32+
33+
// Act
34+
var result = await PSHttpResponseExtensions.PaginateAsync(responses[0], mockClient.Object, "v1");
35+
36+
// Assert
37+
var json = JObject.Parse(result.Content);
38+
var actualItems = json["value"] as JArray;
39+
Assert.NotNull(actualItems);
40+
Assert.Equal(responses.Count, actualItems.Count);
41+
42+
var expectedIds = responses
43+
.Select(r => JObject.Parse(r.Body)["value"]?[0]?["id"]?.ToString())
44+
.ToList();
45+
46+
// Assert each actual item matches expected
47+
for (int i = 0; i < expectedIds.Count; i++)
48+
{
49+
var actualId = actualItems[i]?["id"]?.ToString();
50+
Assert.Equal(expectedIds[i], actualId);
51+
}
52+
}
53+
54+
[Fact]
55+
public async Task PaginateAHandles429WithRetry()
56+
{
57+
// First page (initial), second page (rate limited), third page (success after retry)
58+
var initialResponse = CreateFakePagedResponses(2)[0];
59+
var rateLimitedResponse = CreateFakePagedResponses(1, (HttpStatusCode)429)[0];
60+
var successResponse = CreateFakePagedResponses(1, hasNext: false)[0];
61+
62+
63+
var mockOps = new Mock<IAzureRestOperations>();
64+
var callCount = 0;
65+
mockOps.Setup(o => o.BeginHttpGetMessagesAsyncWithFullResponse(It.IsAny<string>(), It.IsAny<string>(),
66+
It.IsAny<IDictionary<string, IList<string>>>(), It.IsAny<CancellationToken>()))
67+
.ReturnsAsync(() =>
68+
{
69+
callCount++;
70+
return callCount == 1 ? rateLimitedResponse : successResponse;
71+
});
72+
73+
var mockClient = new Mock<IAzureRestClient>();
74+
mockClient.SetupGet(c => c.Operations).Returns(mockOps.Object);
75+
76+
var outputMessages = new List<string>();
77+
Action<string> outputAction = msg => outputMessages.Add(msg);
78+
79+
// Act
80+
var result = await PSHttpResponseExtensions.PaginateAsync(initialResponse, mockClient.Object, "v1", "value", "nextLink");
81+
82+
// Assert
83+
var json = JObject.Parse(result.Content);
84+
Assert.Equal(2, ((JArray)json["value"]).Count);
85+
}
86+
87+
[Fact]
88+
public async Task PaginateThrowsOnNonSuccessStatusCode()
89+
{
90+
// Arrange
91+
var initialResponse = CreateFakePagedResponses(2)[0];
92+
var errorResponse = CreateFakeResponse("{}", HttpStatusCode.NotFound);
93+
94+
var mockOps = new Mock<IAzureRestOperations>();
95+
mockOps.Setup(o => o.BeginHttpGetMessagesAsyncWithFullResponse(It.IsAny<string>(), It.IsAny<string>(),
96+
It.IsAny<IDictionary<string, IList<string>>>(), It.IsAny<CancellationToken>()))
97+
.ReturnsAsync(errorResponse);
98+
99+
var mockClient = new Mock<IAzureRestClient>();
100+
mockClient.SetupGet(c => c.Operations).Returns(mockOps.Object);
101+
102+
// Act & Assert
103+
await Assert.ThrowsAsync<Commands.Common.Exceptions.AzPSException>(async () =>
104+
{
105+
await PSHttpResponseExtensions.PaginateAsync(initialResponse, mockClient.Object, "v1");
106+
});
107+
}
108+
109+
[Fact]
110+
public async Task PaginateThrowsOnInvalidJson()
111+
{
112+
// Arrange
113+
var invalidJson = "{ invalid json ";
114+
var initialResponse = CreateFakeResponse(invalidJson);
115+
116+
var mockOps = new Mock<IAzureRestOperations>();
117+
var mockClient = new Mock<IAzureRestClient>();
118+
mockClient.SetupGet(c => c.Operations).Returns(mockOps.Object);
119+
120+
// Act & Assert
121+
await Assert.ThrowsAsync<Commands.Common.Exceptions.AzPSException>(async () =>
122+
{
123+
await PSHttpResponseExtensions.PaginateAsync(initialResponse, mockClient.Object, "v1");
124+
});
125+
}
126+
127+
[Fact]
128+
public async Task PaginateHandlesMissingNextLinkProperty()
129+
{
130+
// Arrange
131+
var initialResponse = CreateFakePagedResponses(1)[0];
132+
var responses = CreateFakePagedResponses(1, hasNext: false);
133+
134+
var mockOps = new Mock<IAzureRestOperations>();
135+
var mockClient = new Mock<IAzureRestClient>();
136+
mockClient.SetupGet(c => c.Operations).Returns(mockOps.Object);
137+
138+
// Act
139+
var result = await PSHttpResponseExtensions.PaginateAsync(initialResponse, mockClient.Object, "v1");
140+
141+
// Assert
142+
var json = JObject.Parse(result.Content);
143+
Assert.Single(((JArray)json["value"]));
144+
}
145+
146+
[Fact]
147+
public async Task PaginateIgnoresUnknownItemName()
148+
{
149+
//Arrange
150+
var initialResponse = CreateFakePagedResponses(1)[0];
151+
var mockOps = new Mock<IAzureRestOperations>();
152+
var mockClient = new Mock<IAzureRestClient>();
153+
mockClient.SetupGet(c => c.Operations).Returns(mockOps.Object);
154+
155+
// Act
156+
var result = await PSHttpResponseExtensions.PaginateAsync(initialResponse, mockClient.Object, "v1", pageableItemName: "unknownProperty");
157+
158+
// Assert not paginated
159+
var json = JObject.Parse(result.Content);
160+
Assert.True(json["value"] is JArray arr && arr.Count == 1);
161+
}
162+
163+
[Fact]
164+
public async Task PaginateRespectsMaxPageSizeLimit()
165+
{
166+
// Arrange
167+
var responses = CreateFakePagedResponses(4);
168+
169+
var mockOps = new Mock<IAzureRestOperations>();
170+
mockOps.SetupSequence(o => o.BeginHttpGetMessagesAsyncWithFullResponse(It.IsAny<string>(), It.IsAny<string>(),
171+
It.IsAny<IDictionary<string, IList<string>>>(), It.IsAny<CancellationToken>()))
172+
.ReturnsAsync(responses[1])
173+
.ReturnsAsync(responses[2])
174+
.ReturnsAsync(responses[3]);
175+
176+
var mockClient = new Mock<IAzureRestClient>();
177+
mockClient.SetupGet(c => c.Operations).Returns(mockOps.Object);
178+
179+
// Act
180+
var result = await PSHttpResponseExtensions.PaginateAsync(responses[0], mockClient.Object, "v1", "value", "nextLink", maxPageSize: 2);
181+
182+
//Assert
183+
var json = JObject.Parse(result.Content);
184+
Assert.Equal(2, ((JArray)json["value"]).Count);
185+
mockOps.Verify(o => o.BeginHttpGetMessagesAsyncWithFullResponse(It.IsAny<string>(), It.IsAny<string>(),
186+
It.IsAny<IDictionary<string, IList<string>>>(), It.IsAny<CancellationToken>()), Times.Once());
187+
}
188+
189+
[Fact]
190+
public async Task PaginateIgnoresInvalidNextLink()
191+
{
192+
// Arrange
193+
var responseWithNullNextLink = CreateFakeResponse(new JObject
194+
{
195+
["value"] = new JArray(new JObject { ["id"] = Guid.NewGuid().ToString() }),
196+
["nextLink"] = "null"
197+
}.ToString());
198+
var responseWithInvalidUrl = CreateFakeResponse(new JObject
199+
{
200+
["value"] = new JArray(new JObject { ["id"] = Guid.NewGuid().ToString() }),
201+
["nextLink"] = "not a url"
202+
}.ToString());
203+
204+
var mockOps = new Mock<IAzureRestOperations>();
205+
mockOps.Setup(o => o.BeginHttpGetMessagesAsyncWithFullResponse(It.IsAny<string>(), It.IsAny<string>(),
206+
It.IsAny<IDictionary<string, IList<string>>>(), It.IsAny<CancellationToken>()))
207+
.Throws(new Exception("Should not be called"));
208+
209+
var mockClient = new Mock<IAzureRestClient>();
210+
mockClient.SetupGet(c => c.Operations).Returns(mockOps.Object);
211+
212+
// Act & Assert for "null" string
213+
var resultNull = await PSHttpResponseExtensions.PaginateAsync(responseWithNullNextLink, mockClient.Object, "v1");
214+
215+
var jsonNull = JObject.Parse(resultNull.Content);
216+
Assert.Single((JArray)jsonNull["value"]);
217+
218+
// Act & Assert for invalid URL
219+
var resultInvalid = await PSHttpResponseExtensions.PaginateAsync(responseWithInvalidUrl, mockClient.Object, "v1");
220+
221+
var jsonInvalid = JObject.Parse(resultInvalid.Content);
222+
Assert.Single((JArray)jsonInvalid["value"]);
223+
}
224+
225+
#region Test Data Helpers
226+
private static List<AzureOperationResponse<string>> CreateFakePagedResponses(
227+
int pages, HttpStatusCode statusCode = HttpStatusCode.OK, bool retry = false, bool hasNext = true,
228+
string itemName = "value")
229+
{
230+
var responses = new List<AzureOperationResponse<string>>();
231+
for (int i = 1; i <= pages; i++)
232+
{
233+
var page = new JObject
234+
{
235+
[itemName] = new JArray(new JObject { ["id"] = $"page{i}"})
236+
};
237+
238+
if (hasNext && i < pages) //don't add nextLink for the last page
239+
{
240+
page["nextLink"] = $"https://example.com/api/resource/page{i + 1}";
241+
}
242+
243+
responses.Add(CreateFakeResponse(page.ToString(), statusCode, retry));
244+
}
245+
return responses;
246+
}
247+
248+
private static AzureOperationResponse<string> CreateFakeResponse(string content,
249+
HttpStatusCode statusCode = HttpStatusCode.OK, bool retry = false)
250+
{
251+
var response = new AzureOperationResponse<string>
252+
{
253+
Body = content,
254+
Response = new System.Net.Http.HttpResponseMessage(statusCode),
255+
Request = new System.Net.Http.HttpRequestMessage
256+
{
257+
Method = new System.Net.Http.HttpMethod("GET"),
258+
RequestUri = new Uri("https://example.com/api/resource")
259+
}
260+
};
261+
response.Response.RequestMessage = response.Request;
262+
263+
if (retry)
264+
{
265+
response.Response.Headers.RetryAfter = new System.Net.Http.Headers.RetryConditionHeaderValue(TimeSpan.FromSeconds(0));
266+
}
267+
268+
return response;
269+
}
270+
#endregion
271+
}
272+
}

src/Accounts/Accounts/ChangeLog.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@
1919
-->
2020

2121
## Upcoming Release
22+
* Added Server-Side Pagination Support for `Invoke-AzRestMethod` command via `-Paginate` parameter.
2223

2324
## Version 5.2.0
2425
* Fixed an issue where `Clear-AzContext` does not clear the token cache when broker is enabled.

0 commit comments

Comments
 (0)