Skip to content

Commit c4d0bc9

Browse files
authored
Merge pull request #353 from microsoftgraph/feat/page-iterator
feat: add page iterator
1 parent 1193422 commit c4d0bc9

File tree

6 files changed

+715
-2
lines changed

6 files changed

+715
-2
lines changed

src/index.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
export * from "./adapter/index.js";
2+
export * from "./authentication/index.js";
23
export * from "./http/index.js";
34
export * from "./middleware/index.js";
4-
export * from "./authentication/index.js";
5+
export * from "./tasks/index.js";
56
export * from "./utils/Constants.js";
67
export * from "./utils/Version.js";

src/tasks/PageIterator.ts

Lines changed: 342 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,342 @@
1+
/**
2+
* -------------------------------------------------------------------------------------------
3+
* Copyright (c) Microsoft Corporation. All Rights Reserved. Licensed under the MIT License.
4+
* See License in the project root for license information.
5+
* -------------------------------------------------------------------------------------------
6+
*/
7+
8+
/**
9+
* @module PageIterator
10+
*/
11+
12+
import {
13+
Parsable,
14+
RequestAdapter,
15+
RequestOption,
16+
RequestInformation,
17+
HttpMethod,
18+
ParsableFactory,
19+
ErrorMappings,
20+
Headers,
21+
} from "@microsoft/kiota-abstractions";
22+
23+
/**
24+
* Signature representing PageCollection
25+
* @property {any[]} value - The collection value
26+
* @property {string} [@odata.nextLink] - The nextLink value
27+
* @property {string} [@odata.deltaLink] - The deltaLink value
28+
* @property {any} Additional - Any number of additional properties (This is to accept the any additional data returned by in the response to the nextLink request)
29+
*/
30+
export interface PageCollection<T> {
31+
value: T[];
32+
odataNextLink?: string;
33+
odataDeltaLink?: string;
34+
[Key: string]: any;
35+
}
36+
37+
/**
38+
* Signature representing callback for page iterator
39+
* @property {Function} callback - The callback function which should return boolean to continue the continue/stop the iteration.
40+
*/
41+
export type PageIteratorCallback<T> = (data: T) => boolean;
42+
43+
/**
44+
* Signature to define the request options to be sent during request.
45+
* The values of the GraphRequestOptions properties are passed to the Graph Request object.
46+
* @property {Headers} headers - the header options for the request
47+
* @property {RequestOption[]} options - The middleware options for the request
48+
*/
49+
export interface PagingRequestOptions {
50+
headers?: Headers;
51+
requestOption?: RequestOption[];
52+
}
53+
54+
/**
55+
* @typedef {string} PagingState
56+
* Type representing the state of the iterator
57+
*/
58+
export type PagingState = "NotStarted" | "Paused" | "IntrapageIteration" | "InterpageIteration" | "Delta" | "Complete";
59+
60+
/**
61+
* Class representing a PageIterator to iterate over paginated collections.
62+
* @template T - The type of the items in the collection.
63+
*
64+
* This class provides methods to iterate over a collection of items that are paginated.
65+
* It handles fetching the next set of items when the current page is exhausted.
66+
* The iteration can be paused and resumed, and the state of the iterator can be queried.
67+
*
68+
* The PageIterator uses a callback function to process each item in the collection.
69+
* The callback function should return a boolean indicating whether to continue the iteration.
70+
*
71+
* The PageIterator also supports error handling through error mappings and can be configured
72+
* with custom request options.
73+
*/
74+
export class PageIterator<T extends Parsable> {
75+
/**
76+
* @private
77+
* Member holding the GraphClient instance
78+
*/
79+
private readonly requestAdapter: RequestAdapter;
80+
81+
/**
82+
* @private
83+
* Member holding the current page
84+
*/
85+
private currentPage?: PageCollection<T>;
86+
87+
/**
88+
* @private
89+
* Member holding the current position on the collection
90+
*/
91+
private cursor: number;
92+
93+
/**
94+
* @private
95+
* Member holding the factory to create the parsable object
96+
*/
97+
private readonly parsableFactory: ParsableFactory<PageCollection<T>>;
98+
99+
/**
100+
* @private
101+
* Member holding the error mappings
102+
*/
103+
private readonly errorMappings: ErrorMappings;
104+
105+
/**
106+
* @private
107+
* Member holding the callback for iteration
108+
*/
109+
private readonly callback: PageIteratorCallback<T>;
110+
111+
/**
112+
* @private
113+
* Member holding the state of the iterator
114+
*/
115+
private pagingState: PagingState;
116+
117+
/**
118+
* @private
119+
* Member holding the headers for the request
120+
*/
121+
private readonly options?: PagingRequestOptions;
122+
123+
/**
124+
* @public
125+
* @constructor
126+
* Creates new instance for PageIterator
127+
* @returns An instance of a PageIterator
128+
* @param requestAdapter - The request adapter
129+
* @param pageResult - The page collection result of T
130+
* @param callback - The callback function to be called on each item
131+
* @param errorMappings - The error mappings
132+
* @param parsableFactory - The factory to create the parsable object collection
133+
* @param options - The request options to configure the request
134+
*/
135+
public constructor(
136+
requestAdapter: RequestAdapter,
137+
pageResult: PageCollection<T>,
138+
callback: PageIteratorCallback<T>,
139+
parsableFactory: ParsableFactory<PageCollection<T>>,
140+
errorMappings: ErrorMappings,
141+
options?: PagingRequestOptions,
142+
) {
143+
if (!requestAdapter) {
144+
const error = new Error("Request adapter is undefined, Please provide a valid request adapter");
145+
error.name = "Invalid Request Adapter Error";
146+
throw error;
147+
}
148+
if (!pageResult) {
149+
const error = new Error("Page result is undefined, Please provide a valid page result");
150+
error.name = "Invalid Page Result Error";
151+
throw error;
152+
}
153+
if (!callback) {
154+
const error = new Error("Callback is undefined, Please provide a valid callback");
155+
error.name = "Invalid Callback Error";
156+
throw error;
157+
}
158+
if (!parsableFactory) {
159+
const error = new Error("Parsable factory is undefined, Please provide a valid parsable factory");
160+
error.name = "Invalid Parsable Factory Error";
161+
throw error;
162+
}
163+
if (!errorMappings) {
164+
const error = new Error("Error mappings is undefined, Please provide a valid error mappings");
165+
error.name = "Invalid Error Mappings Error";
166+
throw error;
167+
}
168+
this.requestAdapter = requestAdapter;
169+
this.currentPage = pageResult;
170+
171+
this.cursor = 0;
172+
this.errorMappings = errorMappings;
173+
this.parsableFactory = parsableFactory;
174+
this.callback = callback;
175+
176+
if (!options) {
177+
options = {};
178+
}
179+
this.options = options;
180+
this.pagingState = "NotStarted";
181+
}
182+
183+
/**
184+
* @public
185+
* Getter to get the deltaLink in the current response
186+
* @returns A deltaLink which is being used to make delta requests in future
187+
*/
188+
public getOdataDeltaLink(): string | undefined {
189+
const deltaLink = this.currentPage?.["@odata.deltaLink"] as string | undefined;
190+
return this.currentPage?.odataDeltaLink ?? deltaLink;
191+
}
192+
193+
/**
194+
* @public
195+
* Getter to get the nextLink in the current response
196+
* @returns A nextLink which is being used to make requests in future
197+
*/
198+
public getOdataNextLink(): string | undefined {
199+
const nextLink = this.currentPage?.["@odata.nextLink"] as string | undefined;
200+
return this.currentPage?.odataNextLink ?? nextLink;
201+
}
202+
203+
/**
204+
* @public
205+
* @async
206+
* Iterates over the collection and kicks callback for each item on iteration. Fetches next set of data through nextLink and iterates over again
207+
* This happens until the nextLink is drained out or the user responds with a red flag to continue from callback
208+
*/
209+
public async iterate() {
210+
while (true) {
211+
if (this.pagingState === "Complete") {
212+
return;
213+
}
214+
215+
if (this.pagingState === "Delta") {
216+
const nextPage = await this.fetchNextPage();
217+
if (!nextPage) {
218+
this.pagingState = "Complete";
219+
return;
220+
}
221+
this.currentPage = nextPage;
222+
}
223+
224+
const advance = this.enumeratePage();
225+
if (!advance) {
226+
return;
227+
}
228+
229+
const nextLink = this.getOdataNextLink();
230+
const deltaLink = this.getOdataDeltaLink();
231+
const hasNextPageLink = nextLink || deltaLink;
232+
233+
const pageSize = this.currentPage?.value.length ?? 0;
234+
const isEndOfPage = !hasNextPageLink && this.cursor >= pageSize;
235+
if (isEndOfPage) {
236+
this.pagingState = "Complete";
237+
return;
238+
}
239+
240+
if (hasNextPageLink && this.cursor >= pageSize) {
241+
this.cursor = 0;
242+
if (deltaLink) {
243+
this.pagingState = "Delta";
244+
return;
245+
}
246+
const nextPage = await this.fetchNextPage();
247+
if (!nextPage) {
248+
this.pagingState = "Complete";
249+
return;
250+
}
251+
this.currentPage = nextPage;
252+
}
253+
}
254+
}
255+
256+
/**
257+
* @public
258+
* Getter to get the state of the iterator
259+
*/
260+
public getPagingState(): PagingState {
261+
return this.pagingState;
262+
}
263+
264+
/**
265+
* @private
266+
* @async
267+
* Helper to make a get request to fetch next page with nextLink url and update the page iterator instance with the returned response
268+
* @returns A promise that resolves to a response data with next page collection
269+
*/
270+
private async fetchNextPage(): Promise<PageCollection<T> | undefined> {
271+
this.pagingState = "InterpageIteration";
272+
273+
const nextLink = this.getOdataNextLink();
274+
const deltaLink = this.getOdataDeltaLink();
275+
276+
if (!nextLink && !deltaLink) {
277+
throw new Error("NextLink and DeltaLink are undefined, Please provide a valid nextLink or deltaLink");
278+
}
279+
280+
const requestInformation = new RequestInformation();
281+
requestInformation.httpMethod = HttpMethod.GET;
282+
requestInformation.urlTemplate = nextLink ?? deltaLink;
283+
if (this.options) {
284+
if (this.options.headers) {
285+
requestInformation.headers.addAll(this.options.headers);
286+
}
287+
if (this.options.requestOption) {
288+
requestInformation.addRequestOptions(this.options.requestOption);
289+
}
290+
}
291+
292+
return await this.requestAdapter.send<PageCollection<T>>(
293+
requestInformation,
294+
this.parsableFactory,
295+
this.errorMappings,
296+
);
297+
}
298+
299+
/**
300+
* @public
301+
* @async
302+
* To resume the iteration
303+
* Note: This internally calls the iterate method, It's just for more readability.
304+
*/
305+
public async resume() {
306+
return this.iterate();
307+
}
308+
309+
/**
310+
* @private
311+
* Iterates over a collection by enqueuing entries one by one and kicking the callback with the enqueued entry
312+
* @returns A boolean indicating the continue flag to process next page
313+
*/
314+
private enumeratePage(): boolean {
315+
this.pagingState = "IntrapageIteration";
316+
317+
let keepIterating = true;
318+
319+
const pageItems = this.currentPage?.value;
320+
321+
// pageItems should never be undefined at this point
322+
if (!pageItems) {
323+
throw new Error("Page items are undefined, Please provide a valid page items");
324+
}
325+
326+
if (pageItems.length === 0) {
327+
return true;
328+
}
329+
330+
// continue iterating from cursor
331+
for (let i = this.cursor; i < pageItems.length; i++) {
332+
keepIterating = this.callback(pageItems[i]);
333+
this.cursor = i + 1;
334+
if (!keepIterating) {
335+
this.pagingState = "Paused";
336+
break;
337+
}
338+
}
339+
340+
return keepIterating;
341+
}
342+
}

src/tasks/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
export * from "./PageIterator.js";

0 commit comments

Comments
 (0)