diff --git a/packages/typespec-test/test/batch_modular/generated/typespec-ts/review/batch.api.md b/packages/typespec-test/test/batch_modular/generated/typespec-ts/review/batch.api.md index c9b29c3c49..41ee2288b7 100644 --- a/packages/typespec-test/test/batch_modular/generated/typespec-ts/review/batch.api.md +++ b/packages/typespec-test/test/batch_modular/generated/typespec-ts/review/batch.api.md @@ -161,22 +161,22 @@ export class BatchClient { getTaskFile(jobId: string, taskId: string, filePath: string, options?: GetTaskFileOptions): Promise; getTaskFileProperties(jobId: string, taskId: string, filePath: string, options?: GetTaskFilePropertiesOptions): Promise; jobScheduleExists(jobScheduleId: string, options?: JobScheduleExistsOptions): Promise; - listApplications(options?: ListApplicationsOptions): Promise; - listCertificates(options?: ListCertificatesOptions): Promise; - listJobPreparationAndReleaseTaskStatus(jobId: string, options?: ListJobPreparationAndReleaseTaskStatusOptions): Promise; - listJobs(options?: ListJobsOptions): Promise; - listJobSchedules(options?: ListJobSchedulesOptions): Promise; - listJobsFromSchedule(jobScheduleId: string, options?: ListJobsFromScheduleOptions): Promise; - listNodeExtensions(poolId: string, nodeId: string, options?: ListNodeExtensionsOptions): Promise; - listNodeFiles(poolId: string, nodeId: string, options?: ListNodeFilesOptions): Promise; - listNodes(poolId: string, options?: ListNodesOptions): Promise; - listPoolNodeCounts(options?: ListPoolNodeCountsOptions): Promise; - listPools(options?: ListPoolsOptions): Promise; - listPoolUsageMetrics(options?: ListPoolUsageMetricsOptions): Promise; + listApplications(options?: ListApplicationsOptions): PagedAsyncIterableIterator; + listCertificates(options?: ListCertificatesOptions): PagedAsyncIterableIterator; + listJobPreparationAndReleaseTaskStatus(jobId: string, options?: ListJobPreparationAndReleaseTaskStatusOptions): PagedAsyncIterableIterator; + listJobs(options?: ListJobsOptions): PagedAsyncIterableIterator; + listJobSchedules(options?: ListJobSchedulesOptions): PagedAsyncIterableIterator; + listJobsFromSchedule(jobScheduleId: string, options?: ListJobsFromScheduleOptions): PagedAsyncIterableIterator; + listNodeExtensions(poolId: string, nodeId: string, options?: ListNodeExtensionsOptions): PagedAsyncIterableIterator; + listNodeFiles(poolId: string, nodeId: string, options?: ListNodeFilesOptions): PagedAsyncIterableIterator; + listNodes(poolId: string, options?: ListNodesOptions): PagedAsyncIterableIterator; + listPoolNodeCounts(options?: ListPoolNodeCountsOptions): PagedAsyncIterableIterator; + listPools(options?: ListPoolsOptions): PagedAsyncIterableIterator; + listPoolUsageMetrics(options?: ListPoolUsageMetricsOptions): PagedAsyncIterableIterator; listSubTasks(jobId: string, taskId: string, options?: ListSubTasksOptions): Promise; - listSupportedImages(options?: ListSupportedImagesOptions): Promise; - listTaskFiles(jobId: string, taskId: string, options?: ListTaskFilesOptions): Promise; - listTasks(jobId: string, options?: ListTasksOptions): Promise; + listSupportedImages(options?: ListSupportedImagesOptions): PagedAsyncIterableIterator; + listTaskFiles(jobId: string, taskId: string, options?: ListTaskFilesOptions): PagedAsyncIterableIterator; + listTasks(jobId: string, options?: ListTasksOptions): PagedAsyncIterableIterator; readonly pipeline: Pipeline; poolExists(poolId: string, options?: PoolExistsOptions): Promise; reactivateTask(jobId: string, taskId: string, options?: ReactivateTaskOptions): Promise; @@ -693,6 +693,11 @@ export type ContainerType = string; // @public export type ContainerWorkingDirectory = string; +// @public +export type ContinuablePage = TPage & { + continuationToken?: string; +}; + // @public (undocumented) export interface CreateCertificateOptions extends OperationOptions { contentType?: string; @@ -1645,6 +1650,18 @@ export interface OutputFileUploadOptions { uploadCondition: OutputFileUploadCondition; } +// @public +export interface PagedAsyncIterableIterator { + [Symbol.asyncIterator](): PagedAsyncIterableIterator; + byPage: (settings?: TPageSettings) => AsyncIterableIterator>; + next(): Promise>; +} + +// @public +export interface PageSettings { + continuationToken?: string; +} + // @public export interface PoolEndpointConfiguration { inboundNATPools: InboundNATPool[]; diff --git a/packages/typespec-test/test/batch_modular/generated/typespec-ts/src/BatchClient.ts b/packages/typespec-test/test/batch_modular/generated/typespec-ts/src/BatchClient.ts index ff17ba9756..5835c32668 100644 --- a/packages/typespec-test/test/batch_modular/generated/typespec-ts/src/BatchClient.ts +++ b/packages/typespec-test/test/batch_modular/generated/typespec-ts/src/BatchClient.ts @@ -4,11 +4,9 @@ import { TokenCredential } from "@azure/core-auth"; import { Pipeline } from "@azure/core-rest-pipeline"; import { - ApplicationListResult, BatchApplication, - PoolListUsageMetricsResult, + PoolUsageMetrics, BatchPoolCreateOptions, - BatchPoolListResult, BatchPool, AutoScaleRun, BatchPoolUpdateOptions, @@ -17,29 +15,25 @@ import { BatchPoolResizeOptions, BatchPoolReplaceOptions, NodeRemoveOptions, - AccountListSupportedImagesResult, - PoolNodeCountsListResult, + ImageInformation, + PoolNodeCounts, BatchJob, BatchJobUpdateOptions, BatchJobDisableOptions, BatchJobTerminateOptions, BatchJobCreateOptions, - BatchJobListResult, - BatchJobListPreparationAndReleaseTaskStatusResult, + JobPreparationAndReleaseTaskExecutionInformation, TaskCountsResult, BatchCertificate, - CertificateListResult, BatchJobSchedule, BatchJobScheduleUpdateOptions, BatchJobScheduleCreateOptions, - BatchJobScheduleListResult, BatchTaskCreateOptions, - BatchTaskListResult, BatchTask, BatchTaskCollection, TaskAddCollectionResult, BatchTaskListSubtasksResult, - NodeFileListResult, + NodeFile, BatchNodeUserCreateOptions, BatchNodeUserUpdateOptions, BatchNode, @@ -49,9 +43,7 @@ import { BatchNodeRemoteLoginSettingsResult, UploadBatchServiceLogsOptions, UploadBatchServiceLogsResult, - BatchNodeListResult, NodeVMExtension, - NodeVMExtensionList, } from "./models/models.js"; import { ListApplicationsOptions, @@ -131,6 +123,7 @@ import { GetNodeFilePropertiesOptions, ListNodeFilesOptions, } from "./models/options.js"; +import { PagedAsyncIterableIterator } from "./models/pagingTypes.js"; import { createBatch, BatchClientOptions, @@ -239,7 +232,7 @@ export class BatchClient { */ listApplications( options: ListApplicationsOptions = { requestOptions: {} } - ): Promise { + ): PagedAsyncIterableIterator { return listApplications(this._client, options); } @@ -267,7 +260,7 @@ export class BatchClient { */ listPoolUsageMetrics( options: ListPoolUsageMetricsOptions = { requestOptions: {} } - ): Promise { + ): PagedAsyncIterableIterator { return listPoolUsageMetrics(this._client, options); } @@ -286,7 +279,7 @@ export class BatchClient { /** Lists all of the Pools in the specified Account. */ listPools( options: ListPoolsOptions = { requestOptions: {} } - ): Promise { + ): PagedAsyncIterableIterator { return listPools(this._client, options); } @@ -439,7 +432,7 @@ export class BatchClient { /** Lists all Virtual Machine Images supported by the Azure Batch service. */ listSupportedImages( options: ListSupportedImagesOptions = { requestOptions: {} } - ): Promise { + ): PagedAsyncIterableIterator { return listSupportedImages(this._client, options); } @@ -450,7 +443,7 @@ export class BatchClient { */ listPoolNodeCounts( options: ListPoolNodeCountsOptions = { requestOptions: {} } - ): Promise { + ): PagedAsyncIterableIterator { return listPoolNodeCounts(this._client, options); } @@ -575,7 +568,7 @@ export class BatchClient { /** Lists all of the Jobs in the specified Account. */ listJobs( options: ListJobsOptions = { requestOptions: {} } - ): Promise { + ): PagedAsyncIterableIterator { return listJobs(this._client, options); } @@ -583,7 +576,7 @@ export class BatchClient { listJobsFromSchedule( jobScheduleId: string, options: ListJobsFromScheduleOptions = { requestOptions: {} } - ): Promise { + ): PagedAsyncIterableIterator { return listJobsFromSchedule(this._client, jobScheduleId, options); } @@ -600,7 +593,7 @@ export class BatchClient { options: ListJobPreparationAndReleaseTaskStatusOptions = { requestOptions: {}, } - ): Promise { + ): PagedAsyncIterableIterator { return listJobPreparationAndReleaseTaskStatus(this._client, jobId, options); } @@ -628,7 +621,7 @@ export class BatchClient { /** Lists all of the Certificates that have been added to the specified Account. */ listCertificates( options: ListCertificatesOptions = { requestOptions: {} } - ): Promise { + ): PagedAsyncIterableIterator { return listCertificates(this._client, options); } @@ -787,7 +780,7 @@ export class BatchClient { /** Lists all of the Job Schedules in the specified Account. */ listJobSchedules( options: ListJobSchedulesOptions = { requestOptions: {} } - ): Promise { + ): PagedAsyncIterableIterator { return listJobSchedules(this._client, options); } @@ -812,7 +805,7 @@ export class BatchClient { listTasks( jobId: string, options: ListTasksOptions = { requestOptions: {} } - ): Promise { + ): PagedAsyncIterableIterator { return listTasks(this._client, jobId, options); } @@ -958,7 +951,7 @@ export class BatchClient { jobId: string, taskId: string, options: ListTaskFilesOptions = { requestOptions: {} } - ): Promise { + ): PagedAsyncIterableIterator { return listTaskFiles(this._client, jobId, taskId, options); } @@ -1117,7 +1110,7 @@ export class BatchClient { listNodes( poolId: string, options: ListNodesOptions = { requestOptions: {} } - ): Promise { + ): PagedAsyncIterableIterator { return listNodes(this._client, poolId, options); } @@ -1142,7 +1135,7 @@ export class BatchClient { poolId: string, nodeId: string, options: ListNodeExtensionsOptions = { requestOptions: {} } - ): Promise { + ): PagedAsyncIterableIterator { return listNodeExtensions(this._client, poolId, nodeId, options); } @@ -1187,7 +1180,7 @@ export class BatchClient { poolId: string, nodeId: string, options: ListNodeFilesOptions = { requestOptions: {} } - ): Promise { + ): PagedAsyncIterableIterator { return listNodeFiles(this._client, poolId, nodeId, options); } } diff --git a/packages/typespec-test/test/batch_modular/generated/typespec-ts/src/api/operations.ts b/packages/typespec-test/test/batch_modular/generated/typespec-ts/src/api/operations.ts index 67318c5d29..1c094a2bfc 100644 --- a/packages/typespec-test/test/batch_modular/generated/typespec-ts/src/api/operations.ts +++ b/packages/typespec-test/test/batch_modular/generated/typespec-ts/src/api/operations.ts @@ -5,6 +5,7 @@ import { ApplicationListResult, BatchApplication, PoolListUsageMetricsResult, + PoolUsageMetrics, BatchPoolCreateOptions, BatchPoolListResult, BatchPool, @@ -16,7 +17,9 @@ import { BatchPoolReplaceOptions, NodeRemoveOptions, AccountListSupportedImagesResult, + ImageInformation, PoolNodeCountsListResult, + PoolNodeCounts, BatchJob, BatchJobUpdateOptions, BatchJobDisableOptions, @@ -24,6 +27,7 @@ import { BatchJobCreateOptions, BatchJobListResult, BatchJobListPreparationAndReleaseTaskStatusResult, + JobPreparationAndReleaseTaskExecutionInformation, TaskCountsResult, BatchCertificate, CertificateListResult, @@ -38,6 +42,7 @@ import { TaskAddCollectionResult, BatchTaskListSubtasksResult, NodeFileListResult, + NodeFile, BatchNodeUserCreateOptions, BatchNodeUserUpdateOptions, BatchNode, @@ -51,6 +56,8 @@ import { NodeVMExtension, NodeVMExtensionList, } from "../models/models.js"; +import { PagedAsyncIterableIterator } from "../models/pagingTypes.js"; +import { buildPagedAsyncIterator } from "./pagingHelpers.js"; import { isUnexpected, BatchContext as Client, @@ -336,12 +343,16 @@ export async function _listApplicationsDeserialize( * available to Compute Nodes, use the Azure portal or the Azure Resource Manager * API. */ -export async function listApplications( +export function listApplications( context: Client, options: ListApplicationsOptions = { requestOptions: {} } -): Promise { - const result = await _listApplicationsSend(context, options); - return _listApplicationsDeserialize(result); +): PagedAsyncIterableIterator { + return buildPagedAsyncIterator( + context, + () => _listApplicationsSend(context, options), + _listApplicationsDeserialize, + { itemName: "value", nextLinkName: "odata.nextLink" } + ); } export function _getApplicationSend( @@ -436,12 +447,16 @@ export async function _listPoolUsageMetricsDeserialize( * times of the last aggregation interval currently available; that is, only the * last aggregation interval is returned. */ -export async function listPoolUsageMetrics( +export function listPoolUsageMetrics( context: Client, options: ListPoolUsageMetricsOptions = { requestOptions: {} } -): Promise { - const result = await _listPoolUsageMetricsSend(context, options); - return _listPoolUsageMetricsDeserialize(result); +): PagedAsyncIterableIterator { + return buildPagedAsyncIterator( + context, + () => _listPoolUsageMetricsSend(context, options), + _listPoolUsageMetricsDeserialize, + { itemName: "value", nextLinkName: "odata.nextLink" } + ); } export function _createPoolSend( @@ -1328,12 +1343,16 @@ export async function _listPoolsDeserialize( } /** Lists all of the Pools in the specified Account. */ -export async function listPools( +export function listPools( context: Client, options: ListPoolsOptions = { requestOptions: {} } -): Promise { - const result = await _listPoolsSend(context, options); - return _listPoolsDeserialize(result); +): PagedAsyncIterableIterator { + return buildPagedAsyncIterator( + context, + () => _listPoolsSend(context, options), + _listPoolsDeserialize, + { itemName: "value", nextLinkName: "odata.nextLink" } + ); } export function _deletePoolSend( @@ -2664,12 +2683,16 @@ export async function _listSupportedImagesDeserialize( } /** Lists all Virtual Machine Images supported by the Azure Batch service. */ -export async function listSupportedImages( +export function listSupportedImages( context: Client, options: ListSupportedImagesOptions = { requestOptions: {} } -): Promise { - const result = await _listSupportedImagesSend(context, options); - return _listSupportedImagesDeserialize(result); +): PagedAsyncIterableIterator { + return buildPagedAsyncIterator( + context, + () => _listSupportedImagesSend(context, options), + _listSupportedImagesDeserialize, + { itemName: "value", nextLinkName: "odata.nextLink" } + ); } export function _listPoolNodeCountsSend( @@ -2748,12 +2771,16 @@ export async function _listPoolNodeCountsDeserialize( * numbers returned may not always be up to date. If you need exact node counts, * use a list query. */ -export async function listPoolNodeCounts( +export function listPoolNodeCounts( context: Client, options: ListPoolNodeCountsOptions = { requestOptions: {} } -): Promise { - const result = await _listPoolNodeCountsSend(context, options); - return _listPoolNodeCountsDeserialize(result); +): PagedAsyncIterableIterator { + return buildPagedAsyncIterator( + context, + () => _listPoolNodeCountsSend(context, options), + _listPoolNodeCountsDeserialize, + { itemName: "value", nextLinkName: "odata.nextLink" } + ); } export function _deleteJobSend( @@ -7592,12 +7619,16 @@ export async function _listJobsDeserialize( } /** Lists all of the Jobs in the specified Account. */ -export async function listJobs( +export function listJobs( context: Client, options: ListJobsOptions = { requestOptions: {} } -): Promise { - const result = await _listJobsSend(context, options); - return _listJobsDeserialize(result); +): PagedAsyncIterableIterator { + return buildPagedAsyncIterator( + context, + () => _listJobsSend(context, options), + _listJobsDeserialize, + { itemName: "value", nextLinkName: "odata.nextLink" } + ); } export function _listJobsFromScheduleSend( @@ -8695,17 +8726,17 @@ export async function _listJobsFromScheduleDeserialize( } /** Lists the Jobs that have been created under the specified Job Schedule. */ -export async function listJobsFromSchedule( +export function listJobsFromSchedule( context: Client, jobScheduleId: string, options: ListJobsFromScheduleOptions = { requestOptions: {} } -): Promise { - const result = await _listJobsFromScheduleSend( +): PagedAsyncIterableIterator { + return buildPagedAsyncIterator( context, - jobScheduleId, - options + () => _listJobsFromScheduleSend(context, jobScheduleId, options), + _listJobsFromScheduleDeserialize, + { itemName: "value", nextLinkName: "odata.nextLink" } ); - return _listJobsFromScheduleDeserialize(result); } export function _listJobPreparationAndReleaseTaskStatusSend( @@ -8883,19 +8914,19 @@ export async function _listJobPreparationAndReleaseTaskStatusDeserialize( * service returns HTTP status code 409 (Conflict) with an error code of * JobPreparationTaskNotSpecified. */ -export async function listJobPreparationAndReleaseTaskStatus( +export function listJobPreparationAndReleaseTaskStatus( context: Client, jobId: string, options: ListJobPreparationAndReleaseTaskStatusOptions = { requestOptions: {}, } -): Promise { - const result = await _listJobPreparationAndReleaseTaskStatusSend( +): PagedAsyncIterableIterator { + return buildPagedAsyncIterator( context, - jobId, - options + () => _listJobPreparationAndReleaseTaskStatusSend(context, jobId, options), + _listJobPreparationAndReleaseTaskStatusDeserialize, + { itemName: "value", nextLinkName: "odata.nextLink" } ); - return _listJobPreparationAndReleaseTaskStatusDeserialize(result); } export function _getJobTaskCountsSend( @@ -9069,12 +9100,16 @@ export async function _listCertificatesDeserialize( } /** Lists all of the Certificates that have been added to the specified Account. */ -export async function listCertificates( +export function listCertificates( context: Client, options: ListCertificatesOptions = { requestOptions: {} } -): Promise { - const result = await _listCertificatesSend(context, options); - return _listCertificatesDeserialize(result); +): PagedAsyncIterableIterator { + return buildPagedAsyncIterator( + context, + () => _listCertificatesSend(context, options), + _listCertificatesDeserialize, + { itemName: "value", nextLinkName: "odata.nextLink" } + ); } export function _cancelCertificateDeletionSend( @@ -15558,12 +15593,16 @@ export async function _listJobSchedulesDeserialize( } /** Lists all of the Job Schedules in the specified Account. */ -export async function listJobSchedules( +export function listJobSchedules( context: Client, options: ListJobSchedulesOptions = { requestOptions: {} } -): Promise { - const result = await _listJobSchedulesSend(context, options); - return _listJobSchedulesDeserialize(result); +): PagedAsyncIterableIterator { + return buildPagedAsyncIterator( + context, + () => _listJobSchedulesSend(context, options), + _listJobSchedulesDeserialize, + { itemName: "value", nextLinkName: "odata.nextLink" } + ); } export function _createTaskSend( @@ -16127,13 +16166,17 @@ export async function _listTasksDeserialize( * nodeInfo refer to the primary Task. Use the list subtasks API to retrieve * information about subtasks. */ -export async function listTasks( +export function listTasks( context: Client, jobId: string, options: ListTasksOptions = { requestOptions: {} } -): Promise { - const result = await _listTasksSend(context, jobId, options); - return _listTasksDeserialize(result); +): PagedAsyncIterableIterator { + return buildPagedAsyncIterator( + context, + () => _listTasksSend(context, jobId, options), + _listTasksDeserialize, + { itemName: "value", nextLinkName: "odata.nextLink" } + ); } export function _createTaskCollectionSend( @@ -17301,14 +17344,18 @@ export async function _listTaskFilesDeserialize( } /** Lists the files in a Task's directory on its Compute Node. */ -export async function listTaskFiles( +export function listTaskFiles( context: Client, jobId: string, taskId: string, options: ListTaskFilesOptions = { requestOptions: {} } -): Promise { - const result = await _listTaskFilesSend(context, jobId, taskId, options); - return _listTaskFilesDeserialize(result); +): PagedAsyncIterableIterator { + return buildPagedAsyncIterator( + context, + () => _listTaskFilesSend(context, jobId, taskId, options), + _listTaskFilesDeserialize, + { itemName: "value", nextLinkName: "odata.nextLink" } + ); } export function _createNodeUserSend( @@ -18427,13 +18474,17 @@ export async function _listNodesDeserialize( } /** Lists the Compute Nodes in the specified Pool. */ -export async function listNodes( +export function listNodes( context: Client, poolId: string, options: ListNodesOptions = { requestOptions: {} } -): Promise { - const result = await _listNodesSend(context, poolId, options); - return _listNodesDeserialize(result); +): PagedAsyncIterableIterator { + return buildPagedAsyncIterator( + context, + () => _listNodesSend(context, poolId, options), + _listNodesDeserialize, + { itemName: "value", nextLinkName: "odata.nextLink" } + ); } export function _getNodeExtensionSend( @@ -18604,19 +18655,18 @@ export async function _listNodeExtensionsDeserialize( } /** Lists the Compute Nodes Extensions in the specified Pool. */ -export async function listNodeExtensions( +export function listNodeExtensions( context: Client, poolId: string, nodeId: string, options: ListNodeExtensionsOptions = { requestOptions: {} } -): Promise { - const result = await _listNodeExtensionsSend( +): PagedAsyncIterableIterator { + return buildPagedAsyncIterator( context, - poolId, - nodeId, - options + () => _listNodeExtensionsSend(context, poolId, nodeId, options), + _listNodeExtensionsDeserialize, + { itemName: "value", nextLinkName: "odata.nextLink" } ); - return _listNodeExtensionsDeserialize(result); } export function _deleteNodeFileSend( @@ -18842,12 +18892,16 @@ export async function _listNodeFilesDeserialize( } /** Lists all of the files in Task directories on the specified Compute Node. */ -export async function listNodeFiles( +export function listNodeFiles( context: Client, poolId: string, nodeId: string, options: ListNodeFilesOptions = { requestOptions: {} } -): Promise { - const result = await _listNodeFilesSend(context, poolId, nodeId, options); - return _listNodeFilesDeserialize(result); +): PagedAsyncIterableIterator { + return buildPagedAsyncIterator( + context, + () => _listNodeFilesSend(context, poolId, nodeId, options), + _listNodeFilesDeserialize, + { itemName: "value", nextLinkName: "odata.nextLink" } + ); } diff --git a/packages/typespec-test/test/batch_modular/generated/typespec-ts/src/api/pagingHelpers.ts b/packages/typespec-test/test/batch_modular/generated/typespec-ts/src/api/pagingHelpers.ts new file mode 100644 index 0000000000..5ef48f6c4a --- /dev/null +++ b/packages/typespec-test/test/batch_modular/generated/typespec-ts/src/api/pagingHelpers.ts @@ -0,0 +1,191 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +import { + Client, + createRestError, + PathUncheckedResponse, +} from "@azure-rest/core-client"; +import { RestError } from "@azure/core-rest-pipeline"; +import { + BuildPagedAsyncIteratorOptions, + ContinuablePage, + PageSettings, + PagedAsyncIterableIterator, + PagedResult, +} from "../models/pagingTypes.js"; +import { isUnexpected } from "../rest/index.js"; + +/** + * Helper to paginate results in a generic way and return a PagedAsyncIterableIterator + */ +export function buildPagedAsyncIterator< + TElement, + TPage = TElement[], + TPageSettings extends PageSettings = PageSettings, + TResponse extends PathUncheckedResponse = PathUncheckedResponse +>( + client: Client, + getInitialResponse: () => PromiseLike, + processResponseBody: (result: TResponse) => PromiseLike, + options: BuildPagedAsyncIteratorOptions = {} +): PagedAsyncIterableIterator { + const itemName = options.itemName ?? "value"; + const nextLinkName = options.nextLinkName ?? "nextLink"; + const pagedResult: PagedResult = { + getPage: async (pageLink?: string) => { + const result = + pageLink === undefined + ? await getInitialResponse() + : await client.pathUnchecked(pageLink).get(); + checkPagingRequest(result); + const results = await processResponseBody(result as TResponse); + const nextLink = getNextLink(results, nextLinkName); + const values = getElements(results, itemName) as TPage; + return { + page: values, + nextPageLink: nextLink, + }; + }, + byPage: (settings?: TPageSettings) => { + const { continuationToken } = settings ?? {}; + return getPageAsyncIterator(pagedResult, { + pageLink: continuationToken, + }); + }, + }; + return getPagedAsyncIterator(pagedResult); +} + +/** + * returns an async iterator that iterates over results. It also has a `byPage` + * method that returns pages of items at once. + * + * @param pagedResult - an object that specifies how to get pages. + * @returns a paged async iterator that iterates over results. + */ + +function getPagedAsyncIterator< + TElement, + TPage = TElement[], + TPageSettings extends PageSettings = PageSettings +>( + pagedResult: PagedResult +): PagedAsyncIterableIterator { + const iter = getItemAsyncIterator( + pagedResult + ); + return { + next() { + return iter.next(); + }, + [Symbol.asyncIterator]() { + return this; + }, + byPage: + pagedResult?.byPage ?? + ((settings?: TPageSettings) => { + const { continuationToken } = settings ?? {}; + return getPageAsyncIterator(pagedResult, { + pageLink: continuationToken, + }); + }), + }; +} + +async function* getItemAsyncIterator< + TElement, + TPage, + TPageSettings extends PageSettings +>( + pagedResult: PagedResult +): AsyncIterableIterator { + const pages = getPageAsyncIterator(pagedResult); + for await (const page of pages) { + yield* page as unknown as TElement[]; + } +} + +async function* getPageAsyncIterator< + TElement, + TPage, + TPageSettings extends PageSettings +>( + pagedResult: PagedResult, + options: { + pageLink?: string; + } = {} +): AsyncIterableIterator> { + const { pageLink } = options; + let response = await pagedResult.getPage( + pageLink ?? pagedResult.firstPageLink + ); + if (!response) { + return; + } + let result = response.page as ContinuablePage; + result.continuationToken = response.nextPageLink; + yield result; + while (response.nextPageLink) { + response = await pagedResult.getPage(response.nextPageLink); + if (!response) { + return; + } + result = response.page as ContinuablePage; + result.continuationToken = response.nextPageLink; + yield result; + } +} + +/** + * Gets for the value of nextLink in the body + */ +function getNextLink(body: unknown, nextLinkName?: string): string | undefined { + if (!nextLinkName) { + return undefined; + } + + const nextLink = (body as Record)[nextLinkName]; + + if ( + typeof nextLink !== "string" && + typeof nextLink !== "undefined" && + nextLink !== null + ) { + throw new RestError( + `Body Property ${nextLinkName} should be a string or undefined or null but got ${typeof nextLink}` + ); + } + + if (nextLink === null) { + return undefined; + } + + return nextLink; +} + +/** + * Gets the elements of the current request in the body. + */ +function getElements(body: unknown, itemName: string): T[] { + const value = (body as Record)[itemName] as T[]; + if (!Array.isArray(value)) { + throw new RestError( + `Couldn't paginate response\n Body doesn't contain an array property with name: ${itemName}` + ); + } + + return value ?? []; +} + +/** + * Checks if a request failed + */ +function checkPagingRequest(response: PathUncheckedResponse): void { + if (isUnexpected(response)) { + throw createRestError( + `Pagination failed with unexpected statusCode ${response.status}`, + response + ); + } +} diff --git a/packages/typespec-test/test/batch_modular/generated/typespec-ts/src/index.ts b/packages/typespec-test/test/batch_modular/generated/typespec-ts/src/index.ts index 5b094b02a1..b7f832d522 100644 --- a/packages/typespec-test/test/batch_modular/generated/typespec-ts/src/index.ts +++ b/packages/typespec-test/test/batch_modular/generated/typespec-ts/src/index.ts @@ -283,4 +283,7 @@ export { GetNodeFileOptions, GetNodeFilePropertiesOptions, ListNodeFilesOptions, + PageSettings, + ContinuablePage, + PagedAsyncIterableIterator, } from "./models/index.js"; diff --git a/packages/typespec-test/test/batch_modular/generated/typespec-ts/src/models/index.ts b/packages/typespec-test/test/batch_modular/generated/typespec-ts/src/models/index.ts index f76889ad81..b9d5df8780 100644 --- a/packages/typespec-test/test/batch_modular/generated/typespec-ts/src/models/index.ts +++ b/packages/typespec-test/test/batch_modular/generated/typespec-ts/src/models/index.ts @@ -285,3 +285,8 @@ export { GetNodeFilePropertiesOptions, ListNodeFilesOptions, } from "./options.js"; +export { + PageSettings, + ContinuablePage, + PagedAsyncIterableIterator, +} from "./pagingTypes.js"; diff --git a/packages/typespec-test/test/batch_modular/generated/typespec-ts/src/models/pagingTypes.ts b/packages/typespec-test/test/batch_modular/generated/typespec-ts/src/models/pagingTypes.ts new file mode 100644 index 0000000000..5618769059 --- /dev/null +++ b/packages/typespec-test/test/batch_modular/generated/typespec-ts/src/models/pagingTypes.ts @@ -0,0 +1,89 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +/** + * Options for the byPage method + */ +export interface PageSettings { + /** + * A reference to a specific page to start iterating from. + */ + continuationToken?: string; +} + +/** + * An interface that describes a page of results. + */ +export type ContinuablePage = TPage & { + /** + * The token that keeps track of where to continue the iterator + */ + continuationToken?: string; +}; + +/** + * An interface that allows async iterable iteration both to completion and by page. + */ +export interface PagedAsyncIterableIterator< + TElement, + TPage = TElement[], + TPageSettings extends PageSettings = PageSettings +> { + /** + * The next method, part of the iteration protocol + */ + next(): Promise>; + /** + * The connection to the async iterator, part of the iteration protocol + */ + [Symbol.asyncIterator](): PagedAsyncIterableIterator< + TElement, + TPage, + TPageSettings + >; + /** + * Return an AsyncIterableIterator that works a page at a time + */ + byPage: ( + settings?: TPageSettings + ) => AsyncIterableIterator>; +} + +/** + * An interface that describes how to communicate with the service. + */ +export interface PagedResult< + TElement, + TPage = TElement[], + TPageSettings extends PageSettings = PageSettings +> { + /** + * Link to the first page of results. + */ + firstPageLink?: string; + /** + * A method that returns a page of results. + */ + getPage: ( + pageLink?: string + ) => Promise<{ page: TPage; nextPageLink?: string } | undefined>; + /** + * a function to implement the `byPage` method on the paged async iterator. + */ + byPage?: ( + settings?: TPageSettings + ) => AsyncIterableIterator>; + + /** + * A function to extract elements from a page. + */ + toElements?: (page: TPage) => TElement[]; +} + +/** + * Options for the paging helper + */ +export interface BuildPagedAsyncIteratorOptions { + itemName?: string; + nextLinkName?: string; +} diff --git a/packages/typespec-test/test/contentsafety_modular/generated/typespec-ts/review/ai-content-safety.api.md b/packages/typespec-test/test/contentsafety_modular/generated/typespec-ts/review/ai-content-safety.api.md index 4ebc4a3745..77e23bdc58 100644 --- a/packages/typespec-test/test/contentsafety_modular/generated/typespec-ts/review/ai-content-safety.api.md +++ b/packages/typespec-test/test/contentsafety_modular/generated/typespec-ts/review/ai-content-safety.api.md @@ -75,8 +75,8 @@ export class ContentSafetyClient { deleteTextBlocklist(blocklistName: string, options?: DeleteTextBlocklistOptions): Promise; getTextBlocklist(blocklistName: string, options?: GetTextBlocklistOptions): Promise; getTextBlocklistItem(blocklistName: string, blockItemId: string, options?: GetTextBlocklistItemOptions): Promise; - listTextBlocklistItems(blocklistName: string, options?: ListTextBlocklistItemsOptions): Promise; - listTextBlocklists(options?: ListTextBlocklistsOptions): Promise; + listTextBlocklistItems(blocklistName: string, options?: ListTextBlocklistItemsOptions): PagedAsyncIterableIterator; + listTextBlocklists(options?: ListTextBlocklistsOptions): PagedAsyncIterableIterator; readonly pipeline: Pipeline; removeBlockItems(blocklistName: string, body: RemoveBlockItemsOptions, options?: RemoveBlockItemsRequestOptions): Promise; } @@ -85,6 +85,11 @@ export class ContentSafetyClient { export interface ContentSafetyClientOptions extends ClientOptions { } +// @public +export type ContinuablePage = TPage & { + continuationToken?: string; +}; + // @public (undocumented) export interface CreateOrUpdateTextBlocklistOptions extends OperationOptions { contentType?: string; @@ -129,6 +134,13 @@ export interface ListTextBlocklistItemsOptions extends OperationOptions { export interface ListTextBlocklistsOptions extends OperationOptions { } +// @public +export interface PagedAsyncIterableIterator { + [Symbol.asyncIterator](): PagedAsyncIterableIterator; + byPage: (settings?: TPageSettings) => AsyncIterableIterator>; + next(): Promise>; +} + // @public export interface PagedTextBlockItem { nextLink?: string; @@ -141,6 +153,11 @@ export interface PagedTextBlocklist { value: TextBlocklist[]; } +// @public +export interface PageSettings { + continuationToken?: string; +} + // @public export interface RemoveBlockItemsOptions { blockItemIds: string[]; diff --git a/packages/typespec-test/test/contentsafety_modular/generated/typespec-ts/src/ContentSafetyClient.ts b/packages/typespec-test/test/contentsafety_modular/generated/typespec-ts/src/ContentSafetyClient.ts index b3996b33f8..facad036e5 100644 --- a/packages/typespec-test/test/contentsafety_modular/generated/typespec-ts/src/ContentSafetyClient.ts +++ b/packages/typespec-test/test/contentsafety_modular/generated/typespec-ts/src/ContentSafetyClient.ts @@ -13,8 +13,6 @@ import { AddOrUpdateBlockItemsResult, TextBlockItem, RemoveBlockItemsOptions, - PagedTextBlocklist, - PagedTextBlockItem, } from "./models/models.js"; import { AnalyzeTextRequestOptions, @@ -28,6 +26,7 @@ import { GetTextBlocklistItemOptions, ListTextBlocklistItemsOptions, } from "./models/options.js"; +import { PagedAsyncIterableIterator } from "./models/pagingTypes.js"; import { createContentSafety, ContentSafetyClientOptions, @@ -110,7 +109,7 @@ export class ContentSafetyClient { /** Get all text blocklists details. */ listTextBlocklists( options: ListTextBlocklistsOptions = { requestOptions: {} } - ): Promise { + ): PagedAsyncIterableIterator { return listTextBlocklists(this._client, options); } @@ -150,7 +149,7 @@ export class ContentSafetyClient { listTextBlocklistItems( blocklistName: string, options: ListTextBlocklistItemsOptions = { requestOptions: {} } - ): Promise { + ): PagedAsyncIterableIterator { return listTextBlocklistItems(this._client, blocklistName, options); } } diff --git a/packages/typespec-test/test/contentsafety_modular/generated/typespec-ts/src/api/operations.ts b/packages/typespec-test/test/contentsafety_modular/generated/typespec-ts/src/api/operations.ts index 9b33645fee..49838dc8d4 100644 --- a/packages/typespec-test/test/contentsafety_modular/generated/typespec-ts/src/api/operations.ts +++ b/packages/typespec-test/test/contentsafety_modular/generated/typespec-ts/src/api/operations.ts @@ -14,6 +14,8 @@ import { PagedTextBlocklist, PagedTextBlockItem, } from "../models/models.js"; +import { PagedAsyncIterableIterator } from "../models/pagingTypes.js"; +import { buildPagedAsyncIterator } from "./pagingHelpers.js"; import { isUnexpected, ContentSafetyContext as Client, @@ -307,12 +309,16 @@ export async function _listTextBlocklistsDeserialize( } /** Get all text blocklists details. */ -export async function listTextBlocklists( +export function listTextBlocklists( context: Client, options: ListTextBlocklistsOptions = { requestOptions: {} } -): Promise { - const result = await _listTextBlocklistsSend(context, options); - return _listTextBlocklistsDeserialize(result); +): PagedAsyncIterableIterator { + return buildPagedAsyncIterator( + context, + () => _listTextBlocklistsSend(context, options), + _listTextBlocklistsDeserialize, + { itemName: "value", nextLinkName: "nextLink" } + ); } export function _addOrUpdateBlockItemsSend( @@ -503,15 +509,15 @@ export async function _listTextBlocklistItemsDeserialize( } /** Get all blockItems in a text blocklist */ -export async function listTextBlocklistItems( +export function listTextBlocklistItems( context: Client, blocklistName: string, options: ListTextBlocklistItemsOptions = { requestOptions: {} } -): Promise { - const result = await _listTextBlocklistItemsSend( +): PagedAsyncIterableIterator { + return buildPagedAsyncIterator( context, - blocklistName, - options + () => _listTextBlocklistItemsSend(context, blocklistName, options), + _listTextBlocklistItemsDeserialize, + { itemName: "value", nextLinkName: "nextLink" } ); - return _listTextBlocklistItemsDeserialize(result); } diff --git a/packages/typespec-test/test/contentsafety_modular/generated/typespec-ts/src/api/pagingHelpers.ts b/packages/typespec-test/test/contentsafety_modular/generated/typespec-ts/src/api/pagingHelpers.ts new file mode 100644 index 0000000000..5ef48f6c4a --- /dev/null +++ b/packages/typespec-test/test/contentsafety_modular/generated/typespec-ts/src/api/pagingHelpers.ts @@ -0,0 +1,191 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +import { + Client, + createRestError, + PathUncheckedResponse, +} from "@azure-rest/core-client"; +import { RestError } from "@azure/core-rest-pipeline"; +import { + BuildPagedAsyncIteratorOptions, + ContinuablePage, + PageSettings, + PagedAsyncIterableIterator, + PagedResult, +} from "../models/pagingTypes.js"; +import { isUnexpected } from "../rest/index.js"; + +/** + * Helper to paginate results in a generic way and return a PagedAsyncIterableIterator + */ +export function buildPagedAsyncIterator< + TElement, + TPage = TElement[], + TPageSettings extends PageSettings = PageSettings, + TResponse extends PathUncheckedResponse = PathUncheckedResponse +>( + client: Client, + getInitialResponse: () => PromiseLike, + processResponseBody: (result: TResponse) => PromiseLike, + options: BuildPagedAsyncIteratorOptions = {} +): PagedAsyncIterableIterator { + const itemName = options.itemName ?? "value"; + const nextLinkName = options.nextLinkName ?? "nextLink"; + const pagedResult: PagedResult = { + getPage: async (pageLink?: string) => { + const result = + pageLink === undefined + ? await getInitialResponse() + : await client.pathUnchecked(pageLink).get(); + checkPagingRequest(result); + const results = await processResponseBody(result as TResponse); + const nextLink = getNextLink(results, nextLinkName); + const values = getElements(results, itemName) as TPage; + return { + page: values, + nextPageLink: nextLink, + }; + }, + byPage: (settings?: TPageSettings) => { + const { continuationToken } = settings ?? {}; + return getPageAsyncIterator(pagedResult, { + pageLink: continuationToken, + }); + }, + }; + return getPagedAsyncIterator(pagedResult); +} + +/** + * returns an async iterator that iterates over results. It also has a `byPage` + * method that returns pages of items at once. + * + * @param pagedResult - an object that specifies how to get pages. + * @returns a paged async iterator that iterates over results. + */ + +function getPagedAsyncIterator< + TElement, + TPage = TElement[], + TPageSettings extends PageSettings = PageSettings +>( + pagedResult: PagedResult +): PagedAsyncIterableIterator { + const iter = getItemAsyncIterator( + pagedResult + ); + return { + next() { + return iter.next(); + }, + [Symbol.asyncIterator]() { + return this; + }, + byPage: + pagedResult?.byPage ?? + ((settings?: TPageSettings) => { + const { continuationToken } = settings ?? {}; + return getPageAsyncIterator(pagedResult, { + pageLink: continuationToken, + }); + }), + }; +} + +async function* getItemAsyncIterator< + TElement, + TPage, + TPageSettings extends PageSettings +>( + pagedResult: PagedResult +): AsyncIterableIterator { + const pages = getPageAsyncIterator(pagedResult); + for await (const page of pages) { + yield* page as unknown as TElement[]; + } +} + +async function* getPageAsyncIterator< + TElement, + TPage, + TPageSettings extends PageSettings +>( + pagedResult: PagedResult, + options: { + pageLink?: string; + } = {} +): AsyncIterableIterator> { + const { pageLink } = options; + let response = await pagedResult.getPage( + pageLink ?? pagedResult.firstPageLink + ); + if (!response) { + return; + } + let result = response.page as ContinuablePage; + result.continuationToken = response.nextPageLink; + yield result; + while (response.nextPageLink) { + response = await pagedResult.getPage(response.nextPageLink); + if (!response) { + return; + } + result = response.page as ContinuablePage; + result.continuationToken = response.nextPageLink; + yield result; + } +} + +/** + * Gets for the value of nextLink in the body + */ +function getNextLink(body: unknown, nextLinkName?: string): string | undefined { + if (!nextLinkName) { + return undefined; + } + + const nextLink = (body as Record)[nextLinkName]; + + if ( + typeof nextLink !== "string" && + typeof nextLink !== "undefined" && + nextLink !== null + ) { + throw new RestError( + `Body Property ${nextLinkName} should be a string or undefined or null but got ${typeof nextLink}` + ); + } + + if (nextLink === null) { + return undefined; + } + + return nextLink; +} + +/** + * Gets the elements of the current request in the body. + */ +function getElements(body: unknown, itemName: string): T[] { + const value = (body as Record)[itemName] as T[]; + if (!Array.isArray(value)) { + throw new RestError( + `Couldn't paginate response\n Body doesn't contain an array property with name: ${itemName}` + ); + } + + return value ?? []; +} + +/** + * Checks if a request failed + */ +function checkPagingRequest(response: PathUncheckedResponse): void { + if (isUnexpected(response)) { + throw createRestError( + `Pagination failed with unexpected statusCode ${response.status}`, + response + ); + } +} diff --git a/packages/typespec-test/test/contentsafety_modular/generated/typespec-ts/src/index.ts b/packages/typespec-test/test/contentsafety_modular/generated/typespec-ts/src/index.ts index 08fdd88800..83fb4b3902 100644 --- a/packages/typespec-test/test/contentsafety_modular/generated/typespec-ts/src/index.ts +++ b/packages/typespec-test/test/contentsafety_modular/generated/typespec-ts/src/index.ts @@ -36,4 +36,7 @@ export { RemoveBlockItemsRequestOptions, GetTextBlocklistItemOptions, ListTextBlocklistItemsOptions, + PageSettings, + ContinuablePage, + PagedAsyncIterableIterator, } from "./models/index.js"; diff --git a/packages/typespec-test/test/contentsafety_modular/generated/typespec-ts/src/models/index.ts b/packages/typespec-test/test/contentsafety_modular/generated/typespec-ts/src/models/index.ts index 280a85b61f..7e794eecae 100644 --- a/packages/typespec-test/test/contentsafety_modular/generated/typespec-ts/src/models/index.ts +++ b/packages/typespec-test/test/contentsafety_modular/generated/typespec-ts/src/models/index.ts @@ -35,3 +35,8 @@ export { GetTextBlocklistItemOptions, ListTextBlocklistItemsOptions, } from "./options.js"; +export { + PageSettings, + ContinuablePage, + PagedAsyncIterableIterator, +} from "./pagingTypes.js"; diff --git a/packages/typespec-test/test/contentsafety_modular/generated/typespec-ts/src/models/pagingTypes.ts b/packages/typespec-test/test/contentsafety_modular/generated/typespec-ts/src/models/pagingTypes.ts new file mode 100644 index 0000000000..5618769059 --- /dev/null +++ b/packages/typespec-test/test/contentsafety_modular/generated/typespec-ts/src/models/pagingTypes.ts @@ -0,0 +1,89 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +/** + * Options for the byPage method + */ +export interface PageSettings { + /** + * A reference to a specific page to start iterating from. + */ + continuationToken?: string; +} + +/** + * An interface that describes a page of results. + */ +export type ContinuablePage = TPage & { + /** + * The token that keeps track of where to continue the iterator + */ + continuationToken?: string; +}; + +/** + * An interface that allows async iterable iteration both to completion and by page. + */ +export interface PagedAsyncIterableIterator< + TElement, + TPage = TElement[], + TPageSettings extends PageSettings = PageSettings +> { + /** + * The next method, part of the iteration protocol + */ + next(): Promise>; + /** + * The connection to the async iterator, part of the iteration protocol + */ + [Symbol.asyncIterator](): PagedAsyncIterableIterator< + TElement, + TPage, + TPageSettings + >; + /** + * Return an AsyncIterableIterator that works a page at a time + */ + byPage: ( + settings?: TPageSettings + ) => AsyncIterableIterator>; +} + +/** + * An interface that describes how to communicate with the service. + */ +export interface PagedResult< + TElement, + TPage = TElement[], + TPageSettings extends PageSettings = PageSettings +> { + /** + * Link to the first page of results. + */ + firstPageLink?: string; + /** + * A method that returns a page of results. + */ + getPage: ( + pageLink?: string + ) => Promise<{ page: TPage; nextPageLink?: string } | undefined>; + /** + * a function to implement the `byPage` method on the paged async iterator. + */ + byPage?: ( + settings?: TPageSettings + ) => AsyncIterableIterator>; + + /** + * A function to extract elements from a page. + */ + toElements?: (page: TPage) => TElement[]; +} + +/** + * Options for the paging helper + */ +export interface BuildPagedAsyncIteratorOptions { + itemName?: string; + nextLinkName?: string; +} diff --git a/packages/typespec-test/test/loadtesting_modular/generated/typespec-ts/review/load-testing.api.md b/packages/typespec-test/test/loadtesting_modular/generated/typespec-ts/review/load-testing.api.md index e55ce16367..f5707a7f5c 100644 --- a/packages/typespec-test/test/loadtesting_modular/generated/typespec-ts/review/load-testing.api.md +++ b/packages/typespec-test/test/loadtesting_modular/generated/typespec-ts/review/load-testing.api.md @@ -33,6 +33,11 @@ export interface CertificateMetadata { // @public export type CertificateType = string; +// @public +export type ContinuablePage = TPage & { + continuationToken?: string; +}; + // @public (undocumented) export interface CreateOrUpdateAppComponentsOptions extends OperationOptions { // (undocumented) @@ -190,8 +195,8 @@ export class LoadTestAdministrationClient { getServerMetricsConfig(testId: string, options?: GetServerMetricsConfigOptions): Promise; getTest(testId: string, options?: GetTestOptions): Promise; getTestFile(testId: string, fileName: string, options?: GetTestFileOptions): Promise; - listTestFiles(testId: string, options?: ListTestFilesOptions): Promise; - listTests(options?: ListTestsOptions): Promise; + listTestFiles(testId: string, options?: ListTestFilesOptions): PagedAsyncIterableIterator; + listTests(options?: ListTestsOptions): PagedAsyncIterableIterator; readonly pipeline: Pipeline; uploadTestFile(testId: string, fileName: string, body: Uint8Array, options?: UploadTestFileOptions): Promise; } @@ -222,15 +227,12 @@ export class LoadTestRunClient { getTestRunFile(testRunId: string, fileName: string, options?: GetTestRunFileOptions): Promise; // Warning: (ae-forgotten-export) The symbol "MetricDefinitionCollection" needs to be exported by the entry point index.d.ts listMetricDefinitions(testRunId: string, options?: ListMetricDefinitionsOptions): Promise; - // Warning: (ae-forgotten-export) The symbol "PagedDimensionValueList" needs to be exported by the entry point index.d.ts - listMetricDimensionValues(testRunId: string, name: string, metricNamespace: string, options?: ListMetricDimensionValuesOptions): Promise; + listMetricDimensionValues(testRunId: string, name: string, metricNamespace: string, options?: ListMetricDimensionValuesOptions): LoadTestRunClientPagedAsyncIterableIterator; // Warning: (ae-forgotten-export) The symbol "MetricNamespaceCollection" needs to be exported by the entry point index.d.ts listMetricNamespaces(testRunId: string, options?: ListMetricNamespacesOptions): Promise; // Warning: (ae-forgotten-export) The symbol "MetricRequestPayload" needs to be exported by the entry point index.d.ts - // Warning: (ae-forgotten-export) The symbol "PagedTimeSeriesElement" needs to be exported by the entry point index.d.ts - listMetrics(testRunId: string, body: MetricRequestPayload, options?: ListMetricsOptions): Promise; - // Warning: (ae-forgotten-export) The symbol "PagedTestRun" needs to be exported by the entry point index.d.ts - listTestRuns(options?: ListTestRunsOptions): Promise; + listMetrics(testRunId: string, body: MetricRequestPayload, options?: ListMetricsOptions): LoadTestRunClientPagedAsyncIterableIterator; + listTestRuns(options?: ListTestRunsOptions): LoadTestRunClientPagedAsyncIterableIterator; readonly pipeline: Pipeline; stopTestRun(testRunId: string, options?: StopTestRunOptions): Promise; testRun(testRunId: string, resource: LoadTestRunClientTestRun, options?: TestRunOptions): Promise; @@ -260,6 +262,11 @@ export interface LoadTestRunClientCertificateMetadata { // @public export type LoadTestRunClientCertificateType = string; +// @public +export type LoadTestRunClientContinuablePage = TPage & { + continuationToken?: string; +}; + // @public (undocumented) export interface LoadTestRunClientCreateOrUpdateAppComponentsOptions extends OperationOptions { // (undocumented) @@ -380,6 +387,18 @@ export interface LoadTestRunClientOptionalLoadTestConfig { export interface LoadTestRunClientOptions extends ClientOptions { } +// @public +export interface LoadTestRunClientPagedAsyncIterableIterator { + [Symbol.asyncIterator](): LoadTestRunClientPagedAsyncIterableIterator; + byPage: (settings?: TPageSettings) => AsyncIterableIterator>; + next(): Promise>; +} + +// @public +export interface LoadTestRunClientPageSettings { + continuationToken?: string; +} + // @public export interface LoadTestRunClientPassFailCriteria { passFailMetrics?: Record; @@ -588,6 +607,13 @@ export interface OptionalLoadTestConfig { virtualUsers?: number; } +// @public +export interface PagedAsyncIterableIterator { + [Symbol.asyncIterator](): PagedAsyncIterableIterator; + byPage: (settings?: TPageSettings) => AsyncIterableIterator>; + next(): Promise>; +} + // @public export interface PagedFileInfo { nextLink?: string; @@ -600,6 +626,11 @@ export interface PagedTest { value: Test[]; } +// @public +export interface PageSettings { + continuationToken?: string; +} + // @public export interface PassFailCriteria { passFailMetrics?: Record; diff --git a/packages/typespec-test/test/loadtesting_modular/generated/typespec-ts/src/index.ts b/packages/typespec-test/test/loadtesting_modular/generated/typespec-ts/src/index.ts index cd91d5d41b..e9dc46c6f2 100644 --- a/packages/typespec-test/test/loadtesting_modular/generated/typespec-ts/src/index.ts +++ b/packages/typespec-test/test/loadtesting_modular/generated/typespec-ts/src/index.ts @@ -62,6 +62,9 @@ export { UploadTestFileOptions, DeleteTestFileOptions, DeleteTestOptions, + PageSettings, + ContinuablePage, + PagedAsyncIterableIterator, } from "./loadTestAdministration/models/index.js"; export { LoadTestRunClient, @@ -122,4 +125,7 @@ export { ListMetricsOptions, ListTestRunsOptions, StopTestRunOptions, + PageSettings as LoadTestRunClientPageSettings, + ContinuablePage as LoadTestRunClientContinuablePage, + PagedAsyncIterableIterator as LoadTestRunClientPagedAsyncIterableIterator, } from "./loadTestRun/models/index.js"; diff --git a/packages/typespec-test/test/loadtesting_modular/generated/typespec-ts/src/loadTestAdministration/LoadTestAdministrationClient.ts b/packages/typespec-test/test/loadtesting_modular/generated/typespec-ts/src/loadTestAdministration/LoadTestAdministrationClient.ts index 2f5aa7190b..aabca806c9 100644 --- a/packages/typespec-test/test/loadtesting_modular/generated/typespec-ts/src/loadTestAdministration/LoadTestAdministrationClient.ts +++ b/packages/typespec-test/test/loadtesting_modular/generated/typespec-ts/src/loadTestAdministration/LoadTestAdministrationClient.ts @@ -8,8 +8,6 @@ import { FileInfo, TestAppComponents, TestServerMetricConfig, - PagedFileInfo, - PagedTest, } from "./models/models.js"; import { CreateOrUpdateTestOptions, @@ -25,6 +23,7 @@ import { DeleteTestFileOptions, DeleteTestOptions, } from "./models/options.js"; +import { PagedAsyncIterableIterator } from "./models/pagingTypes.js"; import { createLoadTestAdministration, LoadTestAdministrationClientOptions, @@ -128,7 +127,7 @@ export class LoadTestAdministrationClient { listTestFiles( testId: string, options: ListTestFilesOptions = { requestOptions: {} } - ): Promise { + ): PagedAsyncIterableIterator { return listTestFiles(this._client, testId, options); } @@ -138,7 +137,7 @@ export class LoadTestAdministrationClient { */ listTests( options: ListTestsOptions = { requestOptions: {} } - ): Promise { + ): PagedAsyncIterableIterator { return listTests(this._client, options); } diff --git a/packages/typespec-test/test/loadtesting_modular/generated/typespec-ts/src/loadTestAdministration/api/operations.ts b/packages/typespec-test/test/loadtesting_modular/generated/typespec-ts/src/loadTestAdministration/api/operations.ts index 0b05088ed1..9aa0405dc2 100644 --- a/packages/typespec-test/test/loadtesting_modular/generated/typespec-ts/src/loadTestAdministration/api/operations.ts +++ b/packages/typespec-test/test/loadtesting_modular/generated/typespec-ts/src/loadTestAdministration/api/operations.ts @@ -9,6 +9,8 @@ import { PagedFileInfo, PagedTest, } from "../models/models.js"; +import { PagedAsyncIterableIterator } from "../models/pagingTypes.js"; +import { buildPagedAsyncIterator } from "./pagingHelpers.js"; import { isUnexpected, AzureLoadTestingContext as Client, @@ -778,13 +780,17 @@ export async function _listTestFilesDeserialize( } /** Get all test files. */ -export async function listTestFiles( +export function listTestFiles( context: Client, testId: string, options: ListTestFilesOptions = { requestOptions: {} } -): Promise { - const result = await _listTestFilesSend(context, testId, options); - return _listTestFilesDeserialize(result); +): PagedAsyncIterableIterator { + return buildPagedAsyncIterator( + context, + () => _listTestFilesSend(context, testId, options), + _listTestFilesDeserialize, + { itemName: "value", nextLinkName: "nextLink" } + ); } export function _listTestsSend( @@ -959,12 +965,16 @@ export async function _listTestsDeserialize( * Get all load tests by the fully qualified resource Id e.g * subscriptions/{subId}/resourceGroups/{rg}/providers/Microsoft.LoadTestService/loadtests/{resName}. */ -export async function listTests( +export function listTests( context: Client, options: ListTestsOptions = { requestOptions: {} } -): Promise { - const result = await _listTestsSend(context, options); - return _listTestsDeserialize(result); +): PagedAsyncIterableIterator { + return buildPagedAsyncIterator( + context, + () => _listTestsSend(context, options), + _listTestsDeserialize, + { itemName: "value", nextLinkName: "nextLink" } + ); } export function _uploadTestFileSend( diff --git a/packages/typespec-test/test/loadtesting_modular/generated/typespec-ts/src/loadTestAdministration/api/pagingHelpers.ts b/packages/typespec-test/test/loadtesting_modular/generated/typespec-ts/src/loadTestAdministration/api/pagingHelpers.ts new file mode 100644 index 0000000000..8a2730cfda --- /dev/null +++ b/packages/typespec-test/test/loadtesting_modular/generated/typespec-ts/src/loadTestAdministration/api/pagingHelpers.ts @@ -0,0 +1,191 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +import { + Client, + createRestError, + PathUncheckedResponse, +} from "@azure-rest/core-client"; +import { RestError } from "@azure/core-rest-pipeline"; +import { + BuildPagedAsyncIteratorOptions, + ContinuablePage, + PageSettings, + PagedAsyncIterableIterator, + PagedResult, +} from "../models/pagingTypes.js"; +import { isUnexpected } from "../../rest/index.js"; + +/** + * Helper to paginate results in a generic way and return a PagedAsyncIterableIterator + */ +export function buildPagedAsyncIterator< + TElement, + TPage = TElement[], + TPageSettings extends PageSettings = PageSettings, + TResponse extends PathUncheckedResponse = PathUncheckedResponse +>( + client: Client, + getInitialResponse: () => PromiseLike, + processResponseBody: (result: TResponse) => PromiseLike, + options: BuildPagedAsyncIteratorOptions = {} +): PagedAsyncIterableIterator { + const itemName = options.itemName ?? "value"; + const nextLinkName = options.nextLinkName ?? "nextLink"; + const pagedResult: PagedResult = { + getPage: async (pageLink?: string) => { + const result = + pageLink === undefined + ? await getInitialResponse() + : await client.pathUnchecked(pageLink).get(); + checkPagingRequest(result); + const results = await processResponseBody(result as TResponse); + const nextLink = getNextLink(results, nextLinkName); + const values = getElements(results, itemName) as TPage; + return { + page: values, + nextPageLink: nextLink, + }; + }, + byPage: (settings?: TPageSettings) => { + const { continuationToken } = settings ?? {}; + return getPageAsyncIterator(pagedResult, { + pageLink: continuationToken, + }); + }, + }; + return getPagedAsyncIterator(pagedResult); +} + +/** + * returns an async iterator that iterates over results. It also has a `byPage` + * method that returns pages of items at once. + * + * @param pagedResult - an object that specifies how to get pages. + * @returns a paged async iterator that iterates over results. + */ + +function getPagedAsyncIterator< + TElement, + TPage = TElement[], + TPageSettings extends PageSettings = PageSettings +>( + pagedResult: PagedResult +): PagedAsyncIterableIterator { + const iter = getItemAsyncIterator( + pagedResult + ); + return { + next() { + return iter.next(); + }, + [Symbol.asyncIterator]() { + return this; + }, + byPage: + pagedResult?.byPage ?? + ((settings?: TPageSettings) => { + const { continuationToken } = settings ?? {}; + return getPageAsyncIterator(pagedResult, { + pageLink: continuationToken, + }); + }), + }; +} + +async function* getItemAsyncIterator< + TElement, + TPage, + TPageSettings extends PageSettings +>( + pagedResult: PagedResult +): AsyncIterableIterator { + const pages = getPageAsyncIterator(pagedResult); + for await (const page of pages) { + yield* page as unknown as TElement[]; + } +} + +async function* getPageAsyncIterator< + TElement, + TPage, + TPageSettings extends PageSettings +>( + pagedResult: PagedResult, + options: { + pageLink?: string; + } = {} +): AsyncIterableIterator> { + const { pageLink } = options; + let response = await pagedResult.getPage( + pageLink ?? pagedResult.firstPageLink + ); + if (!response) { + return; + } + let result = response.page as ContinuablePage; + result.continuationToken = response.nextPageLink; + yield result; + while (response.nextPageLink) { + response = await pagedResult.getPage(response.nextPageLink); + if (!response) { + return; + } + result = response.page as ContinuablePage; + result.continuationToken = response.nextPageLink; + yield result; + } +} + +/** + * Gets for the value of nextLink in the body + */ +function getNextLink(body: unknown, nextLinkName?: string): string | undefined { + if (!nextLinkName) { + return undefined; + } + + const nextLink = (body as Record)[nextLinkName]; + + if ( + typeof nextLink !== "string" && + typeof nextLink !== "undefined" && + nextLink !== null + ) { + throw new RestError( + `Body Property ${nextLinkName} should be a string or undefined or null but got ${typeof nextLink}` + ); + } + + if (nextLink === null) { + return undefined; + } + + return nextLink; +} + +/** + * Gets the elements of the current request in the body. + */ +function getElements(body: unknown, itemName: string): T[] { + const value = (body as Record)[itemName] as T[]; + if (!Array.isArray(value)) { + throw new RestError( + `Couldn't paginate response\n Body doesn't contain an array property with name: ${itemName}` + ); + } + + return value ?? []; +} + +/** + * Checks if a request failed + */ +function checkPagingRequest(response: PathUncheckedResponse): void { + if (isUnexpected(response)) { + throw createRestError( + `Pagination failed with unexpected statusCode ${response.status}`, + response + ); + } +} diff --git a/packages/typespec-test/test/loadtesting_modular/generated/typespec-ts/src/loadTestAdministration/index.ts b/packages/typespec-test/test/loadtesting_modular/generated/typespec-ts/src/loadTestAdministration/index.ts index 34faaccfef..fcd4fdb5f3 100644 --- a/packages/typespec-test/test/loadtesting_modular/generated/typespec-ts/src/loadTestAdministration/index.ts +++ b/packages/typespec-test/test/loadtesting_modular/generated/typespec-ts/src/loadTestAdministration/index.ts @@ -62,4 +62,7 @@ export { UploadTestFileOptions, DeleteTestFileOptions, DeleteTestOptions, + PageSettings, + ContinuablePage, + PagedAsyncIterableIterator, } from "./models/index.js"; diff --git a/packages/typespec-test/test/loadtesting_modular/generated/typespec-ts/src/loadTestAdministration/models/index.ts b/packages/typespec-test/test/loadtesting_modular/generated/typespec-ts/src/loadTestAdministration/models/index.ts index 76273f1843..aacefc44c6 100644 --- a/packages/typespec-test/test/loadtesting_modular/generated/typespec-ts/src/loadTestAdministration/models/index.ts +++ b/packages/typespec-test/test/loadtesting_modular/generated/typespec-ts/src/loadTestAdministration/models/index.ts @@ -61,3 +61,8 @@ export { DeleteTestFileOptions, DeleteTestOptions, } from "./options.js"; +export { + PageSettings, + ContinuablePage, + PagedAsyncIterableIterator, +} from "./pagingTypes.js"; diff --git a/packages/typespec-test/test/loadtesting_modular/generated/typespec-ts/src/loadTestAdministration/models/pagingTypes.ts b/packages/typespec-test/test/loadtesting_modular/generated/typespec-ts/src/loadTestAdministration/models/pagingTypes.ts new file mode 100644 index 0000000000..5618769059 --- /dev/null +++ b/packages/typespec-test/test/loadtesting_modular/generated/typespec-ts/src/loadTestAdministration/models/pagingTypes.ts @@ -0,0 +1,89 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +/** + * Options for the byPage method + */ +export interface PageSettings { + /** + * A reference to a specific page to start iterating from. + */ + continuationToken?: string; +} + +/** + * An interface that describes a page of results. + */ +export type ContinuablePage = TPage & { + /** + * The token that keeps track of where to continue the iterator + */ + continuationToken?: string; +}; + +/** + * An interface that allows async iterable iteration both to completion and by page. + */ +export interface PagedAsyncIterableIterator< + TElement, + TPage = TElement[], + TPageSettings extends PageSettings = PageSettings +> { + /** + * The next method, part of the iteration protocol + */ + next(): Promise>; + /** + * The connection to the async iterator, part of the iteration protocol + */ + [Symbol.asyncIterator](): PagedAsyncIterableIterator< + TElement, + TPage, + TPageSettings + >; + /** + * Return an AsyncIterableIterator that works a page at a time + */ + byPage: ( + settings?: TPageSettings + ) => AsyncIterableIterator>; +} + +/** + * An interface that describes how to communicate with the service. + */ +export interface PagedResult< + TElement, + TPage = TElement[], + TPageSettings extends PageSettings = PageSettings +> { + /** + * Link to the first page of results. + */ + firstPageLink?: string; + /** + * A method that returns a page of results. + */ + getPage: ( + pageLink?: string + ) => Promise<{ page: TPage; nextPageLink?: string } | undefined>; + /** + * a function to implement the `byPage` method on the paged async iterator. + */ + byPage?: ( + settings?: TPageSettings + ) => AsyncIterableIterator>; + + /** + * A function to extract elements from a page. + */ + toElements?: (page: TPage) => TElement[]; +} + +/** + * Options for the paging helper + */ +export interface BuildPagedAsyncIteratorOptions { + itemName?: string; + nextLinkName?: string; +} diff --git a/packages/typespec-test/test/loadtesting_modular/generated/typespec-ts/src/loadTestRun/LoadTestRunClient.ts b/packages/typespec-test/test/loadtesting_modular/generated/typespec-ts/src/loadTestRun/LoadTestRunClient.ts index 6c7279aa2b..814e820eaa 100644 --- a/packages/typespec-test/test/loadtesting_modular/generated/typespec-ts/src/loadTestRun/LoadTestRunClient.ts +++ b/packages/typespec-test/test/loadtesting_modular/generated/typespec-ts/src/loadTestRun/LoadTestRunClient.ts @@ -11,9 +11,8 @@ import { MetricDefinitionCollection, MetricNamespaceCollection, MetricRequestPayload, - PagedTimeSeriesElement, - PagedTestRun, - PagedDimensionValueList, + TimeSeriesElement, + DimensionValueList, } from "./models/models.js"; import { TestRunOptions, @@ -31,6 +30,7 @@ import { ListTestRunsOptions, StopTestRunOptions, } from "./models/options.js"; +import { PagedAsyncIterableIterator } from "./models/pagingTypes.js"; import { createLoadTestRun, LoadTestRunClientOptions, @@ -149,7 +149,7 @@ export class LoadTestRunClient { name: string, metricNamespace: string, options: ListMetricDimensionValuesOptions = { requestOptions: {} } - ): Promise { + ): PagedAsyncIterableIterator { return listMetricDimensionValues( this._client, testRunId, @@ -180,14 +180,14 @@ export class LoadTestRunClient { testRunId: string, body: MetricRequestPayload, options: ListMetricsOptions = { requestOptions: {} } - ): Promise { + ): PagedAsyncIterableIterator { return listMetrics(this._client, testRunId, body, options); } /** Get all test runs with given filters */ listTestRuns( options: ListTestRunsOptions = { requestOptions: {} } - ): Promise { + ): PagedAsyncIterableIterator { return listTestRuns(this._client, options); } diff --git a/packages/typespec-test/test/loadtesting_modular/generated/typespec-ts/src/loadTestRun/api/operations.ts b/packages/typespec-test/test/loadtesting_modular/generated/typespec-ts/src/loadTestRun/api/operations.ts index 3db2280208..f6bea18bcf 100644 --- a/packages/typespec-test/test/loadtesting_modular/generated/typespec-ts/src/loadTestRun/api/operations.ts +++ b/packages/typespec-test/test/loadtesting_modular/generated/typespec-ts/src/loadTestRun/api/operations.ts @@ -10,9 +10,13 @@ import { MetricNamespaceCollection, MetricRequestPayload, PagedTimeSeriesElement, + TimeSeriesElement, PagedTestRun, PagedDimensionValueList, + DimensionValueList, } from "../models/models.js"; +import { PagedAsyncIterableIterator } from "../models/pagingTypes.js"; +import { buildPagedAsyncIterator } from "./pagingHelpers.js"; import { isUnexpected, AzureLoadTestingContext as Client, @@ -970,21 +974,26 @@ export async function _listMetricDimensionValuesDeserialize( } /** List the dimension values for the given metric dimension name. */ -export async function listMetricDimensionValues( +export function listMetricDimensionValues( context: Client, testRunId: string, name: string, metricNamespace: string, options: ListMetricDimensionValuesOptions = { requestOptions: {} } -): Promise { - const result = await _listMetricDimensionValuesSend( +): PagedAsyncIterableIterator { + return buildPagedAsyncIterator( context, - testRunId, - name, - metricNamespace, - options + () => + _listMetricDimensionValuesSend( + context, + testRunId, + name, + metricNamespace, + options + ), + _listMetricDimensionValuesDeserialize, + { itemName: "value", nextLinkName: "nextLink" } ); - return _listMetricDimensionValuesDeserialize(result); } export function _listMetricDefinitionsSend( @@ -1142,14 +1151,18 @@ export async function _listMetricsDeserialize( } /** List the metric values for a load test run. */ -export async function listMetrics( +export function listMetrics( context: Client, testRunId: string, body: MetricRequestPayload, options: ListMetricsOptions = { requestOptions: {} } -): Promise { - const result = await _listMetricsSend(context, testRunId, body, options); - return _listMetricsDeserialize(result); +): PagedAsyncIterableIterator { + return buildPagedAsyncIterator( + context, + () => _listMetricsSend(context, testRunId, body, options), + _listMetricsDeserialize, + { itemName: "value", nextLinkName: "nextLink" } + ); } export function _listTestRunsSend( @@ -1439,12 +1452,16 @@ export async function _listTestRunsDeserialize( } /** Get all test runs with given filters */ -export async function listTestRuns( +export function listTestRuns( context: Client, options: ListTestRunsOptions = { requestOptions: {} } -): Promise { - const result = await _listTestRunsSend(context, options); - return _listTestRunsDeserialize(result); +): PagedAsyncIterableIterator { + return buildPagedAsyncIterator( + context, + () => _listTestRunsSend(context, options), + _listTestRunsDeserialize, + { itemName: "value", nextLinkName: "nextLink" } + ); } export function _stopTestRunSend( diff --git a/packages/typespec-test/test/loadtesting_modular/generated/typespec-ts/src/loadTestRun/api/pagingHelpers.ts b/packages/typespec-test/test/loadtesting_modular/generated/typespec-ts/src/loadTestRun/api/pagingHelpers.ts new file mode 100644 index 0000000000..8a2730cfda --- /dev/null +++ b/packages/typespec-test/test/loadtesting_modular/generated/typespec-ts/src/loadTestRun/api/pagingHelpers.ts @@ -0,0 +1,191 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +import { + Client, + createRestError, + PathUncheckedResponse, +} from "@azure-rest/core-client"; +import { RestError } from "@azure/core-rest-pipeline"; +import { + BuildPagedAsyncIteratorOptions, + ContinuablePage, + PageSettings, + PagedAsyncIterableIterator, + PagedResult, +} from "../models/pagingTypes.js"; +import { isUnexpected } from "../../rest/index.js"; + +/** + * Helper to paginate results in a generic way and return a PagedAsyncIterableIterator + */ +export function buildPagedAsyncIterator< + TElement, + TPage = TElement[], + TPageSettings extends PageSettings = PageSettings, + TResponse extends PathUncheckedResponse = PathUncheckedResponse +>( + client: Client, + getInitialResponse: () => PromiseLike, + processResponseBody: (result: TResponse) => PromiseLike, + options: BuildPagedAsyncIteratorOptions = {} +): PagedAsyncIterableIterator { + const itemName = options.itemName ?? "value"; + const nextLinkName = options.nextLinkName ?? "nextLink"; + const pagedResult: PagedResult = { + getPage: async (pageLink?: string) => { + const result = + pageLink === undefined + ? await getInitialResponse() + : await client.pathUnchecked(pageLink).get(); + checkPagingRequest(result); + const results = await processResponseBody(result as TResponse); + const nextLink = getNextLink(results, nextLinkName); + const values = getElements(results, itemName) as TPage; + return { + page: values, + nextPageLink: nextLink, + }; + }, + byPage: (settings?: TPageSettings) => { + const { continuationToken } = settings ?? {}; + return getPageAsyncIterator(pagedResult, { + pageLink: continuationToken, + }); + }, + }; + return getPagedAsyncIterator(pagedResult); +} + +/** + * returns an async iterator that iterates over results. It also has a `byPage` + * method that returns pages of items at once. + * + * @param pagedResult - an object that specifies how to get pages. + * @returns a paged async iterator that iterates over results. + */ + +function getPagedAsyncIterator< + TElement, + TPage = TElement[], + TPageSettings extends PageSettings = PageSettings +>( + pagedResult: PagedResult +): PagedAsyncIterableIterator { + const iter = getItemAsyncIterator( + pagedResult + ); + return { + next() { + return iter.next(); + }, + [Symbol.asyncIterator]() { + return this; + }, + byPage: + pagedResult?.byPage ?? + ((settings?: TPageSettings) => { + const { continuationToken } = settings ?? {}; + return getPageAsyncIterator(pagedResult, { + pageLink: continuationToken, + }); + }), + }; +} + +async function* getItemAsyncIterator< + TElement, + TPage, + TPageSettings extends PageSettings +>( + pagedResult: PagedResult +): AsyncIterableIterator { + const pages = getPageAsyncIterator(pagedResult); + for await (const page of pages) { + yield* page as unknown as TElement[]; + } +} + +async function* getPageAsyncIterator< + TElement, + TPage, + TPageSettings extends PageSettings +>( + pagedResult: PagedResult, + options: { + pageLink?: string; + } = {} +): AsyncIterableIterator> { + const { pageLink } = options; + let response = await pagedResult.getPage( + pageLink ?? pagedResult.firstPageLink + ); + if (!response) { + return; + } + let result = response.page as ContinuablePage; + result.continuationToken = response.nextPageLink; + yield result; + while (response.nextPageLink) { + response = await pagedResult.getPage(response.nextPageLink); + if (!response) { + return; + } + result = response.page as ContinuablePage; + result.continuationToken = response.nextPageLink; + yield result; + } +} + +/** + * Gets for the value of nextLink in the body + */ +function getNextLink(body: unknown, nextLinkName?: string): string | undefined { + if (!nextLinkName) { + return undefined; + } + + const nextLink = (body as Record)[nextLinkName]; + + if ( + typeof nextLink !== "string" && + typeof nextLink !== "undefined" && + nextLink !== null + ) { + throw new RestError( + `Body Property ${nextLinkName} should be a string or undefined or null but got ${typeof nextLink}` + ); + } + + if (nextLink === null) { + return undefined; + } + + return nextLink; +} + +/** + * Gets the elements of the current request in the body. + */ +function getElements(body: unknown, itemName: string): T[] { + const value = (body as Record)[itemName] as T[]; + if (!Array.isArray(value)) { + throw new RestError( + `Couldn't paginate response\n Body doesn't contain an array property with name: ${itemName}` + ); + } + + return value ?? []; +} + +/** + * Checks if a request failed + */ +function checkPagingRequest(response: PathUncheckedResponse): void { + if (isUnexpected(response)) { + throw createRestError( + `Pagination failed with unexpected statusCode ${response.status}`, + response + ); + } +} diff --git a/packages/typespec-test/test/loadtesting_modular/generated/typespec-ts/src/loadTestRun/index.ts b/packages/typespec-test/test/loadtesting_modular/generated/typespec-ts/src/loadTestRun/index.ts index 10f2cd0239..cf465ed8cf 100644 --- a/packages/typespec-test/test/loadtesting_modular/generated/typespec-ts/src/loadTestRun/index.ts +++ b/packages/typespec-test/test/loadtesting_modular/generated/typespec-ts/src/loadTestRun/index.ts @@ -68,4 +68,7 @@ export { ListMetricsOptions, ListTestRunsOptions, StopTestRunOptions, + PageSettings, + ContinuablePage, + PagedAsyncIterableIterator, } from "./models/index.js"; diff --git a/packages/typespec-test/test/loadtesting_modular/generated/typespec-ts/src/loadTestRun/models/index.ts b/packages/typespec-test/test/loadtesting_modular/generated/typespec-ts/src/loadTestRun/models/index.ts index ee4a125d67..a9153d686d 100644 --- a/packages/typespec-test/test/loadtesting_modular/generated/typespec-ts/src/loadTestRun/models/index.ts +++ b/packages/typespec-test/test/loadtesting_modular/generated/typespec-ts/src/loadTestRun/models/index.ts @@ -67,3 +67,8 @@ export { ListTestRunsOptions, StopTestRunOptions, } from "./options.js"; +export { + PageSettings, + ContinuablePage, + PagedAsyncIterableIterator, +} from "./pagingTypes.js"; diff --git a/packages/typespec-test/test/loadtesting_modular/generated/typespec-ts/src/loadTestRun/models/pagingTypes.ts b/packages/typespec-test/test/loadtesting_modular/generated/typespec-ts/src/loadTestRun/models/pagingTypes.ts new file mode 100644 index 0000000000..5618769059 --- /dev/null +++ b/packages/typespec-test/test/loadtesting_modular/generated/typespec-ts/src/loadTestRun/models/pagingTypes.ts @@ -0,0 +1,89 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +/** + * Options for the byPage method + */ +export interface PageSettings { + /** + * A reference to a specific page to start iterating from. + */ + continuationToken?: string; +} + +/** + * An interface that describes a page of results. + */ +export type ContinuablePage = TPage & { + /** + * The token that keeps track of where to continue the iterator + */ + continuationToken?: string; +}; + +/** + * An interface that allows async iterable iteration both to completion and by page. + */ +export interface PagedAsyncIterableIterator< + TElement, + TPage = TElement[], + TPageSettings extends PageSettings = PageSettings +> { + /** + * The next method, part of the iteration protocol + */ + next(): Promise>; + /** + * The connection to the async iterator, part of the iteration protocol + */ + [Symbol.asyncIterator](): PagedAsyncIterableIterator< + TElement, + TPage, + TPageSettings + >; + /** + * Return an AsyncIterableIterator that works a page at a time + */ + byPage: ( + settings?: TPageSettings + ) => AsyncIterableIterator>; +} + +/** + * An interface that describes how to communicate with the service. + */ +export interface PagedResult< + TElement, + TPage = TElement[], + TPageSettings extends PageSettings = PageSettings +> { + /** + * Link to the first page of results. + */ + firstPageLink?: string; + /** + * A method that returns a page of results. + */ + getPage: ( + pageLink?: string + ) => Promise<{ page: TPage; nextPageLink?: string } | undefined>; + /** + * a function to implement the `byPage` method on the paged async iterator. + */ + byPage?: ( + settings?: TPageSettings + ) => AsyncIterableIterator>; + + /** + * A function to extract elements from a page. + */ + toElements?: (page: TPage) => TElement[]; +} + +/** + * Options for the paging helper + */ +export interface BuildPagedAsyncIteratorOptions { + itemName?: string; + nextLinkName?: string; +} diff --git a/packages/typespec-test/test/widget_dpg/generated/typespec-ts/package.json b/packages/typespec-test/test/widget_dpg/generated/typespec-ts/package.json index 97da0a213b..3eb566dba3 100644 --- a/packages/typespec-test/test/widget_dpg/generated/typespec-ts/package.json +++ b/packages/typespec-test/test/widget_dpg/generated/typespec-ts/package.json @@ -76,6 +76,7 @@ "@azure/core-rest-pipeline": "^1.12.0", "@azure/logger": "^1.0.0", "tslib": "^2.2.0", + "@azure/core-paging": "^1.5.0", "@azure/core-util": "^1.4.0" }, "devDependencies": { diff --git a/packages/typespec-test/test/widget_dpg/generated/typespec-ts/review/widget_dpg.api.md b/packages/typespec-test/test/widget_dpg/generated/typespec-ts/review/widget_dpg.api.md index 59b244add6..76c2d51f8f 100644 --- a/packages/typespec-test/test/widget_dpg/generated/typespec-ts/review/widget_dpg.api.md +++ b/packages/typespec-test/test/widget_dpg/generated/typespec-ts/review/widget_dpg.api.md @@ -14,12 +14,35 @@ export interface AnalyzeResult { summary: string; } +// @public +export type ContinuablePage = TPage & { + continuationToken?: string; +}; + // @public (undocumented) export interface CreateWidget { color: "red" | "blue"; weight: number; } +// @public (undocumented) +export interface ListWidgetsPagesResults { + "odata.nextLink"?: string; + results: Widget[]; +} + +// @public +export interface PagedAsyncIterableIterator { + [Symbol.asyncIterator](): PagedAsyncIterableIterator; + byPage: (settings?: TPageSettings) => AsyncIterableIterator>; + next(): Promise>; +} + +// @public +export interface PageSettings { + continuationToken?: string; +} + // @public (undocumented) export interface UpdateWidget { color?: "red" | "blue"; @@ -72,6 +95,10 @@ export interface WidgetsListWidgetsOptions extends OperationOptions { optionalHeader?: string; } +// @public (undocumented) +export interface WidgetsListWidgetsPagesOptions extends OperationOptions { +} + // @public (undocumented) export interface WidgetsOperations { // (undocumented) @@ -85,9 +112,17 @@ export interface WidgetsOperations { // (undocumented) listWidgets: (requiredHeader: string, bytesHeader: Uint8Array, value: Uint8Array, csvArrayHeader: Uint8Array[], utcDateHeader: Date, options?: WidgetsListWidgetsOptions) => Promise; // (undocumented) + listWidgetsPages: (page: number, pageSize: number, options?: WidgetsListWidgetsPagesOptions) => PagedAsyncIterableIterator; + // (undocumented) + queryWidgetsPages: (page: number, pageSize: number, options?: WidgetsQueryWidgetsPagesOptions) => PagedAsyncIterableIterator; + // (undocumented) updateWidget: (id: string, body: UpdateWidget, options?: WidgetsUpdateWidgetOptions) => Promise; } +// @public (undocumented) +export interface WidgetsQueryWidgetsPagesOptions extends OperationOptions { +} + // @public (undocumented) export interface WidgetsUpdateWidgetOptions extends OperationOptions { } diff --git a/packages/typespec-test/test/widget_dpg/generated/typespec-ts/sources/generated/src/api/pagingHelpers.ts b/packages/typespec-test/test/widget_dpg/generated/typespec-ts/sources/generated/src/api/pagingHelpers.ts new file mode 100644 index 0000000000..5ef48f6c4a --- /dev/null +++ b/packages/typespec-test/test/widget_dpg/generated/typespec-ts/sources/generated/src/api/pagingHelpers.ts @@ -0,0 +1,191 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +import { + Client, + createRestError, + PathUncheckedResponse, +} from "@azure-rest/core-client"; +import { RestError } from "@azure/core-rest-pipeline"; +import { + BuildPagedAsyncIteratorOptions, + ContinuablePage, + PageSettings, + PagedAsyncIterableIterator, + PagedResult, +} from "../models/pagingTypes.js"; +import { isUnexpected } from "../rest/index.js"; + +/** + * Helper to paginate results in a generic way and return a PagedAsyncIterableIterator + */ +export function buildPagedAsyncIterator< + TElement, + TPage = TElement[], + TPageSettings extends PageSettings = PageSettings, + TResponse extends PathUncheckedResponse = PathUncheckedResponse +>( + client: Client, + getInitialResponse: () => PromiseLike, + processResponseBody: (result: TResponse) => PromiseLike, + options: BuildPagedAsyncIteratorOptions = {} +): PagedAsyncIterableIterator { + const itemName = options.itemName ?? "value"; + const nextLinkName = options.nextLinkName ?? "nextLink"; + const pagedResult: PagedResult = { + getPage: async (pageLink?: string) => { + const result = + pageLink === undefined + ? await getInitialResponse() + : await client.pathUnchecked(pageLink).get(); + checkPagingRequest(result); + const results = await processResponseBody(result as TResponse); + const nextLink = getNextLink(results, nextLinkName); + const values = getElements(results, itemName) as TPage; + return { + page: values, + nextPageLink: nextLink, + }; + }, + byPage: (settings?: TPageSettings) => { + const { continuationToken } = settings ?? {}; + return getPageAsyncIterator(pagedResult, { + pageLink: continuationToken, + }); + }, + }; + return getPagedAsyncIterator(pagedResult); +} + +/** + * returns an async iterator that iterates over results. It also has a `byPage` + * method that returns pages of items at once. + * + * @param pagedResult - an object that specifies how to get pages. + * @returns a paged async iterator that iterates over results. + */ + +function getPagedAsyncIterator< + TElement, + TPage = TElement[], + TPageSettings extends PageSettings = PageSettings +>( + pagedResult: PagedResult +): PagedAsyncIterableIterator { + const iter = getItemAsyncIterator( + pagedResult + ); + return { + next() { + return iter.next(); + }, + [Symbol.asyncIterator]() { + return this; + }, + byPage: + pagedResult?.byPage ?? + ((settings?: TPageSettings) => { + const { continuationToken } = settings ?? {}; + return getPageAsyncIterator(pagedResult, { + pageLink: continuationToken, + }); + }), + }; +} + +async function* getItemAsyncIterator< + TElement, + TPage, + TPageSettings extends PageSettings +>( + pagedResult: PagedResult +): AsyncIterableIterator { + const pages = getPageAsyncIterator(pagedResult); + for await (const page of pages) { + yield* page as unknown as TElement[]; + } +} + +async function* getPageAsyncIterator< + TElement, + TPage, + TPageSettings extends PageSettings +>( + pagedResult: PagedResult, + options: { + pageLink?: string; + } = {} +): AsyncIterableIterator> { + const { pageLink } = options; + let response = await pagedResult.getPage( + pageLink ?? pagedResult.firstPageLink + ); + if (!response) { + return; + } + let result = response.page as ContinuablePage; + result.continuationToken = response.nextPageLink; + yield result; + while (response.nextPageLink) { + response = await pagedResult.getPage(response.nextPageLink); + if (!response) { + return; + } + result = response.page as ContinuablePage; + result.continuationToken = response.nextPageLink; + yield result; + } +} + +/** + * Gets for the value of nextLink in the body + */ +function getNextLink(body: unknown, nextLinkName?: string): string | undefined { + if (!nextLinkName) { + return undefined; + } + + const nextLink = (body as Record)[nextLinkName]; + + if ( + typeof nextLink !== "string" && + typeof nextLink !== "undefined" && + nextLink !== null + ) { + throw new RestError( + `Body Property ${nextLinkName} should be a string or undefined or null but got ${typeof nextLink}` + ); + } + + if (nextLink === null) { + return undefined; + } + + return nextLink; +} + +/** + * Gets the elements of the current request in the body. + */ +function getElements(body: unknown, itemName: string): T[] { + const value = (body as Record)[itemName] as T[]; + if (!Array.isArray(value)) { + throw new RestError( + `Couldn't paginate response\n Body doesn't contain an array property with name: ${itemName}` + ); + } + + return value ?? []; +} + +/** + * Checks if a request failed + */ +function checkPagingRequest(response: PathUncheckedResponse): void { + if (isUnexpected(response)) { + throw createRestError( + `Pagination failed with unexpected statusCode ${response.status}`, + response + ); + } +} diff --git a/packages/typespec-test/test/widget_dpg/generated/typespec-ts/sources/generated/src/api/widgets/index.ts b/packages/typespec-test/test/widget_dpg/generated/typespec-ts/sources/generated/src/api/widgets/index.ts index 31355e0f5f..3cfb9ba82b 100644 --- a/packages/typespec-test/test/widget_dpg/generated/typespec-ts/sources/generated/src/api/widgets/index.ts +++ b/packages/typespec-test/test/widget_dpg/generated/typespec-ts/sources/generated/src/api/widgets/index.ts @@ -3,10 +3,13 @@ import { Widget, + ListWidgetsPagesResults, CreateWidget, UpdateWidget, AnalyzeResult, } from "../../models/models.js"; +import { PagedAsyncIterableIterator } from "../../models/pagingTypes.js"; +import { buildPagedAsyncIterator } from "../pagingHelpers.js"; import { AnalyzeWidget200Response, AnalyzeWidgetDefaultResponse, @@ -20,6 +23,10 @@ import { isUnexpected, ListWidgets200Response, ListWidgetsDefaultResponse, + ListWidgetsPages200Response, + ListWidgetsPagesDefaultResponse, + QueryWidgetsPages200Response, + QueryWidgetsPagesDefaultResponse, UpdateWidget200Response, UpdateWidgetDefaultResponse, WidgetServiceContext as Client, @@ -31,6 +38,8 @@ import { import { uint8ArrayToString } from "@azure/core-util"; import { WidgetsListWidgetsOptions, + WidgetsListWidgetsPagesOptions, + WidgetsQueryWidgetsPagesOptions, WidgetsGetWidgetOptions, WidgetsCreateWidgetOptions, WidgetsUpdateWidgetOptions, @@ -125,6 +134,100 @@ export async function listWidgets( return _listWidgetsDeserialize(result); } +export function _listWidgetsPagesSend( + context: Client, + page: number, + pageSize: number, + options: WidgetsListWidgetsPagesOptions = { requestOptions: {} } +): StreamableMethod< + ListWidgetsPages200Response | ListWidgetsPagesDefaultResponse +> { + return context + .path("/widgets/widgets/pages") + .get({ + ...operationOptionsToRequestParameters(options), + queryParameters: { page: page, pageSize: pageSize }, + }); +} + +export async function _listWidgetsPagesDeserialize( + result: ListWidgetsPages200Response | ListWidgetsPagesDefaultResponse +): Promise { + if (isUnexpected(result)) { + throw result.body; + } + + return { + results: result.body["results"].map((p) => ({ + id: p["id"], + weight: p["weight"], + color: p["color"] as any, + })), + "odata.nextLink": result.body["odata.nextLink"], + }; +} + +export function listWidgetsPages( + context: Client, + page: number, + pageSize: number, + options: WidgetsListWidgetsPagesOptions = { requestOptions: {} } +): PagedAsyncIterableIterator { + return buildPagedAsyncIterator( + context, + () => _listWidgetsPagesSend(context, page, pageSize, options), + _listWidgetsPagesDeserialize, + { itemName: "results", nextLinkName: "odata.nextLink" } + ); +} + +export function _queryWidgetsPagesSend( + context: Client, + page: number, + pageSize: number, + options: WidgetsQueryWidgetsPagesOptions = { requestOptions: {} } +): StreamableMethod< + QueryWidgetsPages200Response | QueryWidgetsPagesDefaultResponse +> { + return context + .path("/widgets/widgets/pages") + .post({ + ...operationOptionsToRequestParameters(options), + queryParameters: { page: page, pageSize: pageSize }, + }); +} + +export async function _queryWidgetsPagesDeserialize( + result: QueryWidgetsPages200Response | QueryWidgetsPagesDefaultResponse +): Promise { + if (isUnexpected(result)) { + throw result.body; + } + + return { + results: result.body["results"].map((p) => ({ + id: p["id"], + weight: p["weight"], + color: p["color"] as any, + })), + "odata.nextLink": result.body["odata.nextLink"], + }; +} + +export function queryWidgetsPages( + context: Client, + page: number, + pageSize: number, + options: WidgetsQueryWidgetsPagesOptions = { requestOptions: {} } +): PagedAsyncIterableIterator { + return buildPagedAsyncIterator( + context, + () => _queryWidgetsPagesSend(context, page, pageSize, options), + _queryWidgetsPagesDeserialize, + { itemName: "results", nextLinkName: "odata.nextLink" } + ); +} + export function _getWidgetSend( context: Client, id: string, diff --git a/packages/typespec-test/test/widget_dpg/generated/typespec-ts/sources/generated/src/classic/widgets/index.ts b/packages/typespec-test/test/widget_dpg/generated/typespec-ts/sources/generated/src/classic/widgets/index.ts index 25cc81248f..32365f9427 100644 --- a/packages/typespec-test/test/widget_dpg/generated/typespec-ts/sources/generated/src/classic/widgets/index.ts +++ b/packages/typespec-test/test/widget_dpg/generated/typespec-ts/sources/generated/src/classic/widgets/index.ts @@ -10,14 +10,19 @@ import { } from "../../models/models.js"; import { listWidgets, + listWidgetsPages, + queryWidgetsPages, getWidget, createWidget, updateWidget, deleteWidget, analyzeWidget, } from "../../api/widgets/index.js"; +import { PagedAsyncIterableIterator } from "../../models/pagingTypes.js"; import { WidgetsListWidgetsOptions, + WidgetsListWidgetsPagesOptions, + WidgetsQueryWidgetsPagesOptions, WidgetsGetWidgetOptions, WidgetsCreateWidgetOptions, WidgetsUpdateWidgetOptions, @@ -34,6 +39,16 @@ export interface WidgetsOperations { utcDateHeader: Date, options?: WidgetsListWidgetsOptions ) => Promise; + listWidgetsPages: ( + page: number, + pageSize: number, + options?: WidgetsListWidgetsPagesOptions + ) => PagedAsyncIterableIterator; + queryWidgetsPages: ( + page: number, + pageSize: number, + options?: WidgetsQueryWidgetsPagesOptions + ) => PagedAsyncIterableIterator; getWidget: (id: string, options?: WidgetsGetWidgetOptions) => Promise; createWidget: ( body: CreateWidget, @@ -73,6 +88,16 @@ export function getWidgets(context: WidgetServiceContext) { utcDateHeader, options ), + listWidgetsPages: ( + page: number, + pageSize: number, + options?: WidgetsListWidgetsPagesOptions + ) => listWidgetsPages(context, page, pageSize, options), + queryWidgetsPages: ( + page: number, + pageSize: number, + options?: WidgetsQueryWidgetsPagesOptions + ) => queryWidgetsPages(context, page, pageSize, options), getWidget: (id: string, options?: WidgetsGetWidgetOptions) => getWidget(context, id, options), createWidget: (body: CreateWidget, options?: WidgetsCreateWidgetOptions) => diff --git a/packages/typespec-test/test/widget_dpg/generated/typespec-ts/sources/generated/src/index.ts b/packages/typespec-test/test/widget_dpg/generated/typespec-ts/sources/generated/src/index.ts index 373189b51d..f2bcec7681 100644 --- a/packages/typespec-test/test/widget_dpg/generated/typespec-ts/sources/generated/src/index.ts +++ b/packages/typespec-test/test/widget_dpg/generated/typespec-ts/sources/generated/src/index.ts @@ -7,14 +7,20 @@ export { } from "./WidgetServiceClient.js"; export { Widget, + ListWidgetsPagesResults, CreateWidget, UpdateWidget, AnalyzeResult, WidgetsListWidgetsOptions, + WidgetsListWidgetsPagesOptions, + WidgetsQueryWidgetsPagesOptions, WidgetsGetWidgetOptions, WidgetsCreateWidgetOptions, WidgetsUpdateWidgetOptions, WidgetsDeleteWidgetOptions, WidgetsAnalyzeWidgetOptions, + PageSettings, + ContinuablePage, + PagedAsyncIterableIterator, } from "./models/index.js"; export { WidgetsOperations } from "./classic/index.js"; diff --git a/packages/typespec-test/test/widget_dpg/generated/typespec-ts/sources/generated/src/models/index.ts b/packages/typespec-test/test/widget_dpg/generated/typespec-ts/sources/generated/src/models/index.ts index 10a2c0c468..01aebc04be 100644 --- a/packages/typespec-test/test/widget_dpg/generated/typespec-ts/sources/generated/src/models/index.ts +++ b/packages/typespec-test/test/widget_dpg/generated/typespec-ts/sources/generated/src/models/index.ts @@ -1,12 +1,25 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT license. -export { Widget, CreateWidget, UpdateWidget, AnalyzeResult } from "./models.js"; +export { + Widget, + ListWidgetsPagesResults, + CreateWidget, + UpdateWidget, + AnalyzeResult, +} from "./models.js"; export { WidgetsListWidgetsOptions, + WidgetsListWidgetsPagesOptions, + WidgetsQueryWidgetsPagesOptions, WidgetsGetWidgetOptions, WidgetsCreateWidgetOptions, WidgetsUpdateWidgetOptions, WidgetsDeleteWidgetOptions, WidgetsAnalyzeWidgetOptions, } from "./options.js"; +export { + PageSettings, + ContinuablePage, + PagedAsyncIterableIterator, +} from "./pagingTypes.js"; diff --git a/packages/typespec-test/test/widget_dpg/generated/typespec-ts/sources/generated/src/models/models.ts b/packages/typespec-test/test/widget_dpg/generated/typespec-ts/sources/generated/src/models/models.ts index 77cc3a2a8c..3a9d7d54db 100644 --- a/packages/typespec-test/test/widget_dpg/generated/typespec-ts/sources/generated/src/models/models.ts +++ b/packages/typespec-test/test/widget_dpg/generated/typespec-ts/sources/generated/src/models/models.ts @@ -10,6 +10,13 @@ export interface Widget { color: "red" | "blue"; } +export interface ListWidgetsPagesResults { + /** The current page of results. */ + results: Widget[]; + /** The URL to get the next set of results. */ + "odata.nextLink"?: string; +} + export interface CreateWidget { /** The weight of the widget. This is an int32, but must be greater than zero. */ weight: number; diff --git a/packages/typespec-test/test/widget_dpg/generated/typespec-ts/sources/generated/src/models/options.ts b/packages/typespec-test/test/widget_dpg/generated/typespec-ts/sources/generated/src/models/options.ts index 6268816de1..b372ec8bed 100644 --- a/packages/typespec-test/test/widget_dpg/generated/typespec-ts/sources/generated/src/models/options.ts +++ b/packages/typespec-test/test/widget_dpg/generated/typespec-ts/sources/generated/src/models/options.ts @@ -10,6 +10,10 @@ export interface WidgetsListWidgetsOptions extends OperationOptions { nullableDateHeader?: Date | null; } +export interface WidgetsListWidgetsPagesOptions extends OperationOptions {} + +export interface WidgetsQueryWidgetsPagesOptions extends OperationOptions {} + export interface WidgetsGetWidgetOptions extends OperationOptions {} export interface WidgetsCreateWidgetOptions extends OperationOptions {} diff --git a/packages/typespec-test/test/widget_dpg/generated/typespec-ts/sources/generated/src/models/pagingTypes.ts b/packages/typespec-test/test/widget_dpg/generated/typespec-ts/sources/generated/src/models/pagingTypes.ts new file mode 100644 index 0000000000..5618769059 --- /dev/null +++ b/packages/typespec-test/test/widget_dpg/generated/typespec-ts/sources/generated/src/models/pagingTypes.ts @@ -0,0 +1,89 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +/** + * Options for the byPage method + */ +export interface PageSettings { + /** + * A reference to a specific page to start iterating from. + */ + continuationToken?: string; +} + +/** + * An interface that describes a page of results. + */ +export type ContinuablePage = TPage & { + /** + * The token that keeps track of where to continue the iterator + */ + continuationToken?: string; +}; + +/** + * An interface that allows async iterable iteration both to completion and by page. + */ +export interface PagedAsyncIterableIterator< + TElement, + TPage = TElement[], + TPageSettings extends PageSettings = PageSettings +> { + /** + * The next method, part of the iteration protocol + */ + next(): Promise>; + /** + * The connection to the async iterator, part of the iteration protocol + */ + [Symbol.asyncIterator](): PagedAsyncIterableIterator< + TElement, + TPage, + TPageSettings + >; + /** + * Return an AsyncIterableIterator that works a page at a time + */ + byPage: ( + settings?: TPageSettings + ) => AsyncIterableIterator>; +} + +/** + * An interface that describes how to communicate with the service. + */ +export interface PagedResult< + TElement, + TPage = TElement[], + TPageSettings extends PageSettings = PageSettings +> { + /** + * Link to the first page of results. + */ + firstPageLink?: string; + /** + * A method that returns a page of results. + */ + getPage: ( + pageLink?: string + ) => Promise<{ page: TPage; nextPageLink?: string } | undefined>; + /** + * a function to implement the `byPage` method on the paged async iterator. + */ + byPage?: ( + settings?: TPageSettings + ) => AsyncIterableIterator>; + + /** + * A function to extract elements from a page. + */ + toElements?: (page: TPage) => TElement[]; +} + +/** + * Options for the paging helper + */ +export interface BuildPagedAsyncIteratorOptions { + itemName?: string; + nextLinkName?: string; +} diff --git a/packages/typespec-test/test/widget_dpg/generated/typespec-ts/sources/generated/src/rest/clientDefinitions.ts b/packages/typespec-test/test/widget_dpg/generated/typespec-ts/sources/generated/src/rest/clientDefinitions.ts index 4ae0b111ed..dca0f6358c 100644 --- a/packages/typespec-test/test/widget_dpg/generated/typespec-ts/sources/generated/src/rest/clientDefinitions.ts +++ b/packages/typespec-test/test/widget_dpg/generated/typespec-ts/sources/generated/src/rest/clientDefinitions.ts @@ -4,6 +4,8 @@ import { ListWidgetsParameters, CreateWidgetParameters, + ListWidgetsPagesParameters, + QueryWidgetsPagesParameters, GetWidgetParameters, UpdateWidgetParameters, DeleteWidgetParameters, @@ -14,6 +16,10 @@ import { ListWidgetsDefaultResponse, CreateWidget201Response, CreateWidgetDefaultResponse, + ListWidgetsPages200Response, + ListWidgetsPagesDefaultResponse, + QueryWidgetsPages200Response, + QueryWidgetsPagesDefaultResponse, GetWidget200Response, GetWidgetDefaultResponse, UpdateWidget200Response, @@ -45,6 +51,19 @@ export interface ListWidgets { ): StreamableMethod; } +export interface ListWidgetsPages { + get( + options: ListWidgetsPagesParameters + ): StreamableMethod< + ListWidgetsPages200Response | ListWidgetsPagesDefaultResponse + >; + post( + options: QueryWidgetsPagesParameters + ): StreamableMethod< + QueryWidgetsPages200Response | QueryWidgetsPagesDefaultResponse + >; +} + export interface GetWidget { /** Get a widget by ID. */ get( @@ -73,6 +92,8 @@ export interface AnalyzeWidget { export interface Routes { /** Resource for '/widgets' has methods for the following verbs: get, post */ (path: "/widgets"): ListWidgets; + /** Resource for '/widgets/widgets/pages' has methods for the following verbs: get, post */ + (path: "/widgets/widgets/pages"): ListWidgetsPages; /** Resource for '/widgets/\{id\}' has methods for the following verbs: get, patch, delete */ (path: "/widgets/{id}", id: string): GetWidget; /** Resource for '/widgets/\{id\}/analyze' has methods for the following verbs: post */ diff --git a/packages/typespec-test/test/widget_dpg/generated/typespec-ts/sources/generated/src/rest/index.ts b/packages/typespec-test/test/widget_dpg/generated/typespec-ts/sources/generated/src/rest/index.ts index ae9a8b1539..6a101accd4 100644 --- a/packages/typespec-test/test/widget_dpg/generated/typespec-ts/sources/generated/src/rest/index.ts +++ b/packages/typespec-test/test/widget_dpg/generated/typespec-ts/sources/generated/src/rest/index.ts @@ -10,6 +10,7 @@ export * from "./clientDefinitions.js"; export * from "./isUnexpected.js"; export * from "./models.js"; export * from "./outputModels.js"; +export * from "./paginateHelper.js"; export * from "./serializeHelper.js"; export default WidgetServiceClient; diff --git a/packages/typespec-test/test/widget_dpg/generated/typespec-ts/sources/generated/src/rest/isUnexpected.ts b/packages/typespec-test/test/widget_dpg/generated/typespec-ts/sources/generated/src/rest/isUnexpected.ts index 5707c6381d..91f5d9c532 100644 --- a/packages/typespec-test/test/widget_dpg/generated/typespec-ts/sources/generated/src/rest/isUnexpected.ts +++ b/packages/typespec-test/test/widget_dpg/generated/typespec-ts/sources/generated/src/rest/isUnexpected.ts @@ -6,6 +6,10 @@ import { ListWidgetsDefaultResponse, CreateWidget201Response, CreateWidgetDefaultResponse, + ListWidgetsPages200Response, + ListWidgetsPagesDefaultResponse, + QueryWidgetsPages200Response, + QueryWidgetsPagesDefaultResponse, GetWidget200Response, GetWidgetDefaultResponse, UpdateWidget200Response, @@ -19,6 +23,8 @@ import { const responseMap: Record = { "GET /widgets": ["200"], "POST /widgets": ["201"], + "GET /widgets/widgets/pages": ["200"], + "POST /widgets/widgets/pages": ["200"], "GET /widgets/{id}": ["200"], "PATCH /widgets/{id}": ["200"], "DELETE /widgets/{id}": ["204"], @@ -31,6 +37,12 @@ export function isUnexpected( export function isUnexpected( response: CreateWidget201Response | CreateWidgetDefaultResponse ): response is CreateWidgetDefaultResponse; +export function isUnexpected( + response: ListWidgetsPages200Response | ListWidgetsPagesDefaultResponse +): response is ListWidgetsPagesDefaultResponse; +export function isUnexpected( + response: QueryWidgetsPages200Response | QueryWidgetsPagesDefaultResponse +): response is QueryWidgetsPagesDefaultResponse; export function isUnexpected( response: GetWidget200Response | GetWidgetDefaultResponse ): response is GetWidgetDefaultResponse; @@ -49,6 +61,10 @@ export function isUnexpected( | ListWidgetsDefaultResponse | CreateWidget201Response | CreateWidgetDefaultResponse + | ListWidgetsPages200Response + | ListWidgetsPagesDefaultResponse + | QueryWidgetsPages200Response + | QueryWidgetsPagesDefaultResponse | GetWidget200Response | GetWidgetDefaultResponse | UpdateWidget200Response @@ -60,6 +76,8 @@ export function isUnexpected( ): response is | ListWidgetsDefaultResponse | CreateWidgetDefaultResponse + | ListWidgetsPagesDefaultResponse + | QueryWidgetsPagesDefaultResponse | GetWidgetDefaultResponse | UpdateWidgetDefaultResponse | DeleteWidgetDefaultResponse diff --git a/packages/typespec-test/test/widget_dpg/generated/typespec-ts/sources/generated/src/rest/outputModels.ts b/packages/typespec-test/test/widget_dpg/generated/typespec-ts/sources/generated/src/rest/outputModels.ts index 9df9238a04..43e9648f37 100644 --- a/packages/typespec-test/test/widget_dpg/generated/typespec-ts/sources/generated/src/rest/outputModels.ts +++ b/packages/typespec-test/test/widget_dpg/generated/typespec-ts/sources/generated/src/rest/outputModels.ts @@ -17,6 +17,13 @@ export interface WidgetErrorOutput { message: string; } +export interface ListWidgetsPagesResultsOutput { + /** The current page of results. */ + results: Array; + /** The URL to get the next set of results. */ + "odata.nextLink"?: string; +} + export interface AnalyzeResultOutput { summary: string; } diff --git a/packages/typespec-test/test/widget_dpg/generated/typespec-ts/sources/generated/src/rest/paginateHelper.ts b/packages/typespec-test/test/widget_dpg/generated/typespec-ts/sources/generated/src/rest/paginateHelper.ts new file mode 100644 index 0000000000..bea7a77a07 --- /dev/null +++ b/packages/typespec-test/test/widget_dpg/generated/typespec-ts/sources/generated/src/rest/paginateHelper.ts @@ -0,0 +1,204 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +import { + getPagedAsyncIterator, + PagedAsyncIterableIterator, + PagedResult, +} from "@azure/core-paging"; +import { + Client, + createRestError, + PathUncheckedResponse, +} from "@azure-rest/core-client"; + +/** + * Helper type to extract the type of an array + */ +export type GetArrayType = T extends Array ? TData : never; + +/** + * The type of a custom function that defines how to get a page and a link to the next one if any. + */ +export type GetPage = ( + pageLink: string, + maxPageSize?: number +) => Promise<{ + page: TPage; + nextPageLink?: string; +}>; + +/** + * Options for the paging helper + */ +export interface PagingOptions { + /** + * Custom function to extract pagination details for crating the PagedAsyncIterableIterator + */ + customGetPage?: GetPage[]>; +} + +/** + * Helper type to infer the Type of the paged elements from the response type + * This type is generated based on the swagger information for x-ms-pageable + * specifically on the itemName property which indicates the property of the response + * where the page items are found. The default value is `value`. + * This type will allow us to provide strongly typed Iterator based on the response we get as second parameter + */ +export type PaginateReturn = TResult extends + | { + body: { value?: infer TPage }; + } + | { + body: { results?: infer TPage }; + } + ? GetArrayType + : Array; + +/** + * Helper to paginate results from an initial response that follows the specification of Autorest `x-ms-pageable` extension + * @param client - Client to use for sending the next page requests + * @param initialResponse - Initial response containing the nextLink and current page of elements + * @param customGetPage - Optional - Function to define how to extract the page and next link to be used to paginate the results + * @returns - PagedAsyncIterableIterator to iterate the elements + */ +export function paginate( + client: Client, + initialResponse: TResponse, + options: PagingOptions = {} +): PagedAsyncIterableIterator> { + // Extract element type from initial response + type TElement = PaginateReturn; + let firstRun = true; + // We need to check the response for success before trying to inspect it looking for + // the properties to use for nextLink and itemName + checkPagingRequest(initialResponse); + const { itemName, nextLinkName } = getPaginationProperties(initialResponse); + const { customGetPage } = options; + const pagedResult: PagedResult = { + firstPageLink: "", + getPage: + typeof customGetPage === "function" + ? customGetPage + : async (pageLink: string) => { + const result = firstRun + ? initialResponse + : await client.pathUnchecked(pageLink).get(); + firstRun = false; + checkPagingRequest(result); + const nextLink = getNextLink(result.body, nextLinkName); + const values = getElements(result.body, itemName); + return { + page: values, + nextPageLink: nextLink, + }; + }, + }; + + return getPagedAsyncIterator(pagedResult); +} + +/** + * Gets for the value of nextLink in the body + */ +function getNextLink(body: unknown, nextLinkName?: string): string | undefined { + if (!nextLinkName) { + return undefined; + } + + const nextLink = (body as Record)[nextLinkName]; + + if (typeof nextLink !== "string" && typeof nextLink !== "undefined") { + throw new Error( + `Body Property ${nextLinkName} should be a string or undefined` + ); + } + + return nextLink; +} + +/** + * Gets the elements of the current request in the body. + */ +function getElements(body: unknown, itemName: string): T[] { + const value = (body as Record)[itemName] as T[]; + + // value has to be an array according to the x-ms-pageable extension. + // The fact that this must be an array is used above to calculate the + // type of elements in the page in PaginateReturn + if (!Array.isArray(value)) { + throw new Error( + `Couldn't paginate response\n Body doesn't contain an array property with name: ${itemName}` + ); + } + + return value ?? []; +} + +/** + * Checks if a request failed + */ +function checkPagingRequest(response: PathUncheckedResponse): void { + const Http2xxStatusCodes = [ + "200", + "201", + "202", + "203", + "204", + "205", + "206", + "207", + "208", + "226", + ]; + if (!Http2xxStatusCodes.includes(response.status)) { + throw createRestError( + `Pagination failed with unexpected statusCode ${response.status}`, + response + ); + } +} + +/** + * Extracts the itemName and nextLinkName from the initial response to use them for pagination + */ +function getPaginationProperties(initialResponse: PathUncheckedResponse) { + // Build a set with the passed custom nextLinkNames + const nextLinkNames = new Set(["nextLink", "odata.nextLink"]); + + // Build a set with the passed custom set of itemNames + const itemNames = new Set(["value", "results"]); + + let nextLinkName: string | undefined; + let itemName: string | undefined; + + for (const name of nextLinkNames) { + const nextLink = (initialResponse.body as Record)[ + name + ] as string; + if (nextLink) { + nextLinkName = name; + break; + } + } + + for (const name of itemNames) { + const item = (initialResponse.body as Record)[ + name + ] as string; + if (item) { + itemName = name; + break; + } + } + + if (!itemName) { + throw new Error( + `Couldn't paginate response\n Body doesn't contain an array property with name: ${[ + ...itemNames, + ].join(" OR ")}` + ); + } + + return { itemName, nextLinkName }; +} diff --git a/packages/typespec-test/test/widget_dpg/generated/typespec-ts/sources/generated/src/rest/parameters.ts b/packages/typespec-test/test/widget_dpg/generated/typespec-ts/sources/generated/src/rest/parameters.ts index 7db3c5c9a8..74ea7dfc40 100644 --- a/packages/typespec-test/test/widget_dpg/generated/typespec-ts/sources/generated/src/rest/parameters.ts +++ b/packages/typespec-test/test/widget_dpg/generated/typespec-ts/sources/generated/src/rest/parameters.ts @@ -23,6 +23,30 @@ export interface ListWidgetsHeaderParam { } export type ListWidgetsParameters = ListWidgetsHeaderParam & RequestParameters; + +export interface ListWidgetsPagesQueryParamProperties { + page: number; + pageSize: number; +} + +export interface ListWidgetsPagesQueryParam { + queryParameters: ListWidgetsPagesQueryParamProperties; +} + +export type ListWidgetsPagesParameters = ListWidgetsPagesQueryParam & + RequestParameters; + +export interface QueryWidgetsPagesQueryParamProperties { + page: number; + pageSize: number; +} + +export interface QueryWidgetsPagesQueryParam { + queryParameters: QueryWidgetsPagesQueryParamProperties; +} + +export type QueryWidgetsPagesParameters = QueryWidgetsPagesQueryParam & + RequestParameters; export type GetWidgetParameters = RequestParameters; export interface CreateWidgetBodyParam { diff --git a/packages/typespec-test/test/widget_dpg/generated/typespec-ts/sources/generated/src/rest/responses.ts b/packages/typespec-test/test/widget_dpg/generated/typespec-ts/sources/generated/src/rest/responses.ts index d7da1e8ace..77a8954ac9 100644 --- a/packages/typespec-test/test/widget_dpg/generated/typespec-ts/sources/generated/src/rest/responses.ts +++ b/packages/typespec-test/test/widget_dpg/generated/typespec-ts/sources/generated/src/rest/responses.ts @@ -5,6 +5,7 @@ import { HttpResponse } from "@azure-rest/core-client"; import { WidgetOutput, WidgetErrorOutput, + ListWidgetsPagesResultsOutput, AnalyzeResultOutput, } from "./outputModels.js"; @@ -19,6 +20,28 @@ export interface ListWidgetsDefaultResponse extends HttpResponse { body: WidgetErrorOutput; } +/** The request has succeeded. */ +export interface ListWidgetsPages200Response extends HttpResponse { + status: "200"; + body: ListWidgetsPagesResultsOutput; +} + +export interface ListWidgetsPagesDefaultResponse extends HttpResponse { + status: string; + body: WidgetErrorOutput; +} + +/** The request has succeeded. */ +export interface QueryWidgetsPages200Response extends HttpResponse { + status: "200"; + body: ListWidgetsPagesResultsOutput; +} + +export interface QueryWidgetsPagesDefaultResponse extends HttpResponse { + status: string; + body: WidgetErrorOutput; +} + /** The request has succeeded. */ export interface GetWidget200Response extends HttpResponse { status: "200"; diff --git a/packages/typespec-test/test/widget_dpg/generated/typespec-ts/src/api/pagingHelpers.ts b/packages/typespec-test/test/widget_dpg/generated/typespec-ts/src/api/pagingHelpers.ts new file mode 100644 index 0000000000..5ef48f6c4a --- /dev/null +++ b/packages/typespec-test/test/widget_dpg/generated/typespec-ts/src/api/pagingHelpers.ts @@ -0,0 +1,191 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +import { + Client, + createRestError, + PathUncheckedResponse, +} from "@azure-rest/core-client"; +import { RestError } from "@azure/core-rest-pipeline"; +import { + BuildPagedAsyncIteratorOptions, + ContinuablePage, + PageSettings, + PagedAsyncIterableIterator, + PagedResult, +} from "../models/pagingTypes.js"; +import { isUnexpected } from "../rest/index.js"; + +/** + * Helper to paginate results in a generic way and return a PagedAsyncIterableIterator + */ +export function buildPagedAsyncIterator< + TElement, + TPage = TElement[], + TPageSettings extends PageSettings = PageSettings, + TResponse extends PathUncheckedResponse = PathUncheckedResponse +>( + client: Client, + getInitialResponse: () => PromiseLike, + processResponseBody: (result: TResponse) => PromiseLike, + options: BuildPagedAsyncIteratorOptions = {} +): PagedAsyncIterableIterator { + const itemName = options.itemName ?? "value"; + const nextLinkName = options.nextLinkName ?? "nextLink"; + const pagedResult: PagedResult = { + getPage: async (pageLink?: string) => { + const result = + pageLink === undefined + ? await getInitialResponse() + : await client.pathUnchecked(pageLink).get(); + checkPagingRequest(result); + const results = await processResponseBody(result as TResponse); + const nextLink = getNextLink(results, nextLinkName); + const values = getElements(results, itemName) as TPage; + return { + page: values, + nextPageLink: nextLink, + }; + }, + byPage: (settings?: TPageSettings) => { + const { continuationToken } = settings ?? {}; + return getPageAsyncIterator(pagedResult, { + pageLink: continuationToken, + }); + }, + }; + return getPagedAsyncIterator(pagedResult); +} + +/** + * returns an async iterator that iterates over results. It also has a `byPage` + * method that returns pages of items at once. + * + * @param pagedResult - an object that specifies how to get pages. + * @returns a paged async iterator that iterates over results. + */ + +function getPagedAsyncIterator< + TElement, + TPage = TElement[], + TPageSettings extends PageSettings = PageSettings +>( + pagedResult: PagedResult +): PagedAsyncIterableIterator { + const iter = getItemAsyncIterator( + pagedResult + ); + return { + next() { + return iter.next(); + }, + [Symbol.asyncIterator]() { + return this; + }, + byPage: + pagedResult?.byPage ?? + ((settings?: TPageSettings) => { + const { continuationToken } = settings ?? {}; + return getPageAsyncIterator(pagedResult, { + pageLink: continuationToken, + }); + }), + }; +} + +async function* getItemAsyncIterator< + TElement, + TPage, + TPageSettings extends PageSettings +>( + pagedResult: PagedResult +): AsyncIterableIterator { + const pages = getPageAsyncIterator(pagedResult); + for await (const page of pages) { + yield* page as unknown as TElement[]; + } +} + +async function* getPageAsyncIterator< + TElement, + TPage, + TPageSettings extends PageSettings +>( + pagedResult: PagedResult, + options: { + pageLink?: string; + } = {} +): AsyncIterableIterator> { + const { pageLink } = options; + let response = await pagedResult.getPage( + pageLink ?? pagedResult.firstPageLink + ); + if (!response) { + return; + } + let result = response.page as ContinuablePage; + result.continuationToken = response.nextPageLink; + yield result; + while (response.nextPageLink) { + response = await pagedResult.getPage(response.nextPageLink); + if (!response) { + return; + } + result = response.page as ContinuablePage; + result.continuationToken = response.nextPageLink; + yield result; + } +} + +/** + * Gets for the value of nextLink in the body + */ +function getNextLink(body: unknown, nextLinkName?: string): string | undefined { + if (!nextLinkName) { + return undefined; + } + + const nextLink = (body as Record)[nextLinkName]; + + if ( + typeof nextLink !== "string" && + typeof nextLink !== "undefined" && + nextLink !== null + ) { + throw new RestError( + `Body Property ${nextLinkName} should be a string or undefined or null but got ${typeof nextLink}` + ); + } + + if (nextLink === null) { + return undefined; + } + + return nextLink; +} + +/** + * Gets the elements of the current request in the body. + */ +function getElements(body: unknown, itemName: string): T[] { + const value = (body as Record)[itemName] as T[]; + if (!Array.isArray(value)) { + throw new RestError( + `Couldn't paginate response\n Body doesn't contain an array property with name: ${itemName}` + ); + } + + return value ?? []; +} + +/** + * Checks if a request failed + */ +function checkPagingRequest(response: PathUncheckedResponse): void { + if (isUnexpected(response)) { + throw createRestError( + `Pagination failed with unexpected statusCode ${response.status}`, + response + ); + } +} diff --git a/packages/typespec-test/test/widget_dpg/generated/typespec-ts/src/api/widgets/index.ts b/packages/typespec-test/test/widget_dpg/generated/typespec-ts/src/api/widgets/index.ts index 31355e0f5f..3cfb9ba82b 100644 --- a/packages/typespec-test/test/widget_dpg/generated/typespec-ts/src/api/widgets/index.ts +++ b/packages/typespec-test/test/widget_dpg/generated/typespec-ts/src/api/widgets/index.ts @@ -3,10 +3,13 @@ import { Widget, + ListWidgetsPagesResults, CreateWidget, UpdateWidget, AnalyzeResult, } from "../../models/models.js"; +import { PagedAsyncIterableIterator } from "../../models/pagingTypes.js"; +import { buildPagedAsyncIterator } from "../pagingHelpers.js"; import { AnalyzeWidget200Response, AnalyzeWidgetDefaultResponse, @@ -20,6 +23,10 @@ import { isUnexpected, ListWidgets200Response, ListWidgetsDefaultResponse, + ListWidgetsPages200Response, + ListWidgetsPagesDefaultResponse, + QueryWidgetsPages200Response, + QueryWidgetsPagesDefaultResponse, UpdateWidget200Response, UpdateWidgetDefaultResponse, WidgetServiceContext as Client, @@ -31,6 +38,8 @@ import { import { uint8ArrayToString } from "@azure/core-util"; import { WidgetsListWidgetsOptions, + WidgetsListWidgetsPagesOptions, + WidgetsQueryWidgetsPagesOptions, WidgetsGetWidgetOptions, WidgetsCreateWidgetOptions, WidgetsUpdateWidgetOptions, @@ -125,6 +134,100 @@ export async function listWidgets( return _listWidgetsDeserialize(result); } +export function _listWidgetsPagesSend( + context: Client, + page: number, + pageSize: number, + options: WidgetsListWidgetsPagesOptions = { requestOptions: {} } +): StreamableMethod< + ListWidgetsPages200Response | ListWidgetsPagesDefaultResponse +> { + return context + .path("/widgets/widgets/pages") + .get({ + ...operationOptionsToRequestParameters(options), + queryParameters: { page: page, pageSize: pageSize }, + }); +} + +export async function _listWidgetsPagesDeserialize( + result: ListWidgetsPages200Response | ListWidgetsPagesDefaultResponse +): Promise { + if (isUnexpected(result)) { + throw result.body; + } + + return { + results: result.body["results"].map((p) => ({ + id: p["id"], + weight: p["weight"], + color: p["color"] as any, + })), + "odata.nextLink": result.body["odata.nextLink"], + }; +} + +export function listWidgetsPages( + context: Client, + page: number, + pageSize: number, + options: WidgetsListWidgetsPagesOptions = { requestOptions: {} } +): PagedAsyncIterableIterator { + return buildPagedAsyncIterator( + context, + () => _listWidgetsPagesSend(context, page, pageSize, options), + _listWidgetsPagesDeserialize, + { itemName: "results", nextLinkName: "odata.nextLink" } + ); +} + +export function _queryWidgetsPagesSend( + context: Client, + page: number, + pageSize: number, + options: WidgetsQueryWidgetsPagesOptions = { requestOptions: {} } +): StreamableMethod< + QueryWidgetsPages200Response | QueryWidgetsPagesDefaultResponse +> { + return context + .path("/widgets/widgets/pages") + .post({ + ...operationOptionsToRequestParameters(options), + queryParameters: { page: page, pageSize: pageSize }, + }); +} + +export async function _queryWidgetsPagesDeserialize( + result: QueryWidgetsPages200Response | QueryWidgetsPagesDefaultResponse +): Promise { + if (isUnexpected(result)) { + throw result.body; + } + + return { + results: result.body["results"].map((p) => ({ + id: p["id"], + weight: p["weight"], + color: p["color"] as any, + })), + "odata.nextLink": result.body["odata.nextLink"], + }; +} + +export function queryWidgetsPages( + context: Client, + page: number, + pageSize: number, + options: WidgetsQueryWidgetsPagesOptions = { requestOptions: {} } +): PagedAsyncIterableIterator { + return buildPagedAsyncIterator( + context, + () => _queryWidgetsPagesSend(context, page, pageSize, options), + _queryWidgetsPagesDeserialize, + { itemName: "results", nextLinkName: "odata.nextLink" } + ); +} + export function _getWidgetSend( context: Client, id: string, diff --git a/packages/typespec-test/test/widget_dpg/generated/typespec-ts/src/classic/widgets/index.ts b/packages/typespec-test/test/widget_dpg/generated/typespec-ts/src/classic/widgets/index.ts index 25cc81248f..32365f9427 100644 --- a/packages/typespec-test/test/widget_dpg/generated/typespec-ts/src/classic/widgets/index.ts +++ b/packages/typespec-test/test/widget_dpg/generated/typespec-ts/src/classic/widgets/index.ts @@ -10,14 +10,19 @@ import { } from "../../models/models.js"; import { listWidgets, + listWidgetsPages, + queryWidgetsPages, getWidget, createWidget, updateWidget, deleteWidget, analyzeWidget, } from "../../api/widgets/index.js"; +import { PagedAsyncIterableIterator } from "../../models/pagingTypes.js"; import { WidgetsListWidgetsOptions, + WidgetsListWidgetsPagesOptions, + WidgetsQueryWidgetsPagesOptions, WidgetsGetWidgetOptions, WidgetsCreateWidgetOptions, WidgetsUpdateWidgetOptions, @@ -34,6 +39,16 @@ export interface WidgetsOperations { utcDateHeader: Date, options?: WidgetsListWidgetsOptions ) => Promise; + listWidgetsPages: ( + page: number, + pageSize: number, + options?: WidgetsListWidgetsPagesOptions + ) => PagedAsyncIterableIterator; + queryWidgetsPages: ( + page: number, + pageSize: number, + options?: WidgetsQueryWidgetsPagesOptions + ) => PagedAsyncIterableIterator; getWidget: (id: string, options?: WidgetsGetWidgetOptions) => Promise; createWidget: ( body: CreateWidget, @@ -73,6 +88,16 @@ export function getWidgets(context: WidgetServiceContext) { utcDateHeader, options ), + listWidgetsPages: ( + page: number, + pageSize: number, + options?: WidgetsListWidgetsPagesOptions + ) => listWidgetsPages(context, page, pageSize, options), + queryWidgetsPages: ( + page: number, + pageSize: number, + options?: WidgetsQueryWidgetsPagesOptions + ) => queryWidgetsPages(context, page, pageSize, options), getWidget: (id: string, options?: WidgetsGetWidgetOptions) => getWidget(context, id, options), createWidget: (body: CreateWidget, options?: WidgetsCreateWidgetOptions) => diff --git a/packages/typespec-test/test/widget_dpg/generated/typespec-ts/src/index.ts b/packages/typespec-test/test/widget_dpg/generated/typespec-ts/src/index.ts index 373189b51d..f2bcec7681 100644 --- a/packages/typespec-test/test/widget_dpg/generated/typespec-ts/src/index.ts +++ b/packages/typespec-test/test/widget_dpg/generated/typespec-ts/src/index.ts @@ -7,14 +7,20 @@ export { } from "./WidgetServiceClient.js"; export { Widget, + ListWidgetsPagesResults, CreateWidget, UpdateWidget, AnalyzeResult, WidgetsListWidgetsOptions, + WidgetsListWidgetsPagesOptions, + WidgetsQueryWidgetsPagesOptions, WidgetsGetWidgetOptions, WidgetsCreateWidgetOptions, WidgetsUpdateWidgetOptions, WidgetsDeleteWidgetOptions, WidgetsAnalyzeWidgetOptions, + PageSettings, + ContinuablePage, + PagedAsyncIterableIterator, } from "./models/index.js"; export { WidgetsOperations } from "./classic/index.js"; diff --git a/packages/typespec-test/test/widget_dpg/generated/typespec-ts/src/models/index.ts b/packages/typespec-test/test/widget_dpg/generated/typespec-ts/src/models/index.ts index 10a2c0c468..01aebc04be 100644 --- a/packages/typespec-test/test/widget_dpg/generated/typespec-ts/src/models/index.ts +++ b/packages/typespec-test/test/widget_dpg/generated/typespec-ts/src/models/index.ts @@ -1,12 +1,25 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT license. -export { Widget, CreateWidget, UpdateWidget, AnalyzeResult } from "./models.js"; +export { + Widget, + ListWidgetsPagesResults, + CreateWidget, + UpdateWidget, + AnalyzeResult, +} from "./models.js"; export { WidgetsListWidgetsOptions, + WidgetsListWidgetsPagesOptions, + WidgetsQueryWidgetsPagesOptions, WidgetsGetWidgetOptions, WidgetsCreateWidgetOptions, WidgetsUpdateWidgetOptions, WidgetsDeleteWidgetOptions, WidgetsAnalyzeWidgetOptions, } from "./options.js"; +export { + PageSettings, + ContinuablePage, + PagedAsyncIterableIterator, +} from "./pagingTypes.js"; diff --git a/packages/typespec-test/test/widget_dpg/generated/typespec-ts/src/models/models.ts b/packages/typespec-test/test/widget_dpg/generated/typespec-ts/src/models/models.ts index 77cc3a2a8c..3a9d7d54db 100644 --- a/packages/typespec-test/test/widget_dpg/generated/typespec-ts/src/models/models.ts +++ b/packages/typespec-test/test/widget_dpg/generated/typespec-ts/src/models/models.ts @@ -10,6 +10,13 @@ export interface Widget { color: "red" | "blue"; } +export interface ListWidgetsPagesResults { + /** The current page of results. */ + results: Widget[]; + /** The URL to get the next set of results. */ + "odata.nextLink"?: string; +} + export interface CreateWidget { /** The weight of the widget. This is an int32, but must be greater than zero. */ weight: number; diff --git a/packages/typespec-test/test/widget_dpg/generated/typespec-ts/src/models/options.ts b/packages/typespec-test/test/widget_dpg/generated/typespec-ts/src/models/options.ts index 6268816de1..b372ec8bed 100644 --- a/packages/typespec-test/test/widget_dpg/generated/typespec-ts/src/models/options.ts +++ b/packages/typespec-test/test/widget_dpg/generated/typespec-ts/src/models/options.ts @@ -10,6 +10,10 @@ export interface WidgetsListWidgetsOptions extends OperationOptions { nullableDateHeader?: Date | null; } +export interface WidgetsListWidgetsPagesOptions extends OperationOptions {} + +export interface WidgetsQueryWidgetsPagesOptions extends OperationOptions {} + export interface WidgetsGetWidgetOptions extends OperationOptions {} export interface WidgetsCreateWidgetOptions extends OperationOptions {} diff --git a/packages/typespec-test/test/widget_dpg/generated/typespec-ts/src/models/pagingTypes.ts b/packages/typespec-test/test/widget_dpg/generated/typespec-ts/src/models/pagingTypes.ts new file mode 100644 index 0000000000..5618769059 --- /dev/null +++ b/packages/typespec-test/test/widget_dpg/generated/typespec-ts/src/models/pagingTypes.ts @@ -0,0 +1,89 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +/** + * Options for the byPage method + */ +export interface PageSettings { + /** + * A reference to a specific page to start iterating from. + */ + continuationToken?: string; +} + +/** + * An interface that describes a page of results. + */ +export type ContinuablePage = TPage & { + /** + * The token that keeps track of where to continue the iterator + */ + continuationToken?: string; +}; + +/** + * An interface that allows async iterable iteration both to completion and by page. + */ +export interface PagedAsyncIterableIterator< + TElement, + TPage = TElement[], + TPageSettings extends PageSettings = PageSettings +> { + /** + * The next method, part of the iteration protocol + */ + next(): Promise>; + /** + * The connection to the async iterator, part of the iteration protocol + */ + [Symbol.asyncIterator](): PagedAsyncIterableIterator< + TElement, + TPage, + TPageSettings + >; + /** + * Return an AsyncIterableIterator that works a page at a time + */ + byPage: ( + settings?: TPageSettings + ) => AsyncIterableIterator>; +} + +/** + * An interface that describes how to communicate with the service. + */ +export interface PagedResult< + TElement, + TPage = TElement[], + TPageSettings extends PageSettings = PageSettings +> { + /** + * Link to the first page of results. + */ + firstPageLink?: string; + /** + * A method that returns a page of results. + */ + getPage: ( + pageLink?: string + ) => Promise<{ page: TPage; nextPageLink?: string } | undefined>; + /** + * a function to implement the `byPage` method on the paged async iterator. + */ + byPage?: ( + settings?: TPageSettings + ) => AsyncIterableIterator>; + + /** + * A function to extract elements from a page. + */ + toElements?: (page: TPage) => TElement[]; +} + +/** + * Options for the paging helper + */ +export interface BuildPagedAsyncIteratorOptions { + itemName?: string; + nextLinkName?: string; +} diff --git a/packages/typespec-test/test/widget_dpg/generated/typespec-ts/src/rest/clientDefinitions.ts b/packages/typespec-test/test/widget_dpg/generated/typespec-ts/src/rest/clientDefinitions.ts index 4ae0b111ed..dca0f6358c 100644 --- a/packages/typespec-test/test/widget_dpg/generated/typespec-ts/src/rest/clientDefinitions.ts +++ b/packages/typespec-test/test/widget_dpg/generated/typespec-ts/src/rest/clientDefinitions.ts @@ -4,6 +4,8 @@ import { ListWidgetsParameters, CreateWidgetParameters, + ListWidgetsPagesParameters, + QueryWidgetsPagesParameters, GetWidgetParameters, UpdateWidgetParameters, DeleteWidgetParameters, @@ -14,6 +16,10 @@ import { ListWidgetsDefaultResponse, CreateWidget201Response, CreateWidgetDefaultResponse, + ListWidgetsPages200Response, + ListWidgetsPagesDefaultResponse, + QueryWidgetsPages200Response, + QueryWidgetsPagesDefaultResponse, GetWidget200Response, GetWidgetDefaultResponse, UpdateWidget200Response, @@ -45,6 +51,19 @@ export interface ListWidgets { ): StreamableMethod; } +export interface ListWidgetsPages { + get( + options: ListWidgetsPagesParameters + ): StreamableMethod< + ListWidgetsPages200Response | ListWidgetsPagesDefaultResponse + >; + post( + options: QueryWidgetsPagesParameters + ): StreamableMethod< + QueryWidgetsPages200Response | QueryWidgetsPagesDefaultResponse + >; +} + export interface GetWidget { /** Get a widget by ID. */ get( @@ -73,6 +92,8 @@ export interface AnalyzeWidget { export interface Routes { /** Resource for '/widgets' has methods for the following verbs: get, post */ (path: "/widgets"): ListWidgets; + /** Resource for '/widgets/widgets/pages' has methods for the following verbs: get, post */ + (path: "/widgets/widgets/pages"): ListWidgetsPages; /** Resource for '/widgets/\{id\}' has methods for the following verbs: get, patch, delete */ (path: "/widgets/{id}", id: string): GetWidget; /** Resource for '/widgets/\{id\}/analyze' has methods for the following verbs: post */ diff --git a/packages/typespec-test/test/widget_dpg/generated/typespec-ts/src/rest/index.ts b/packages/typespec-test/test/widget_dpg/generated/typespec-ts/src/rest/index.ts index ae9a8b1539..6a101accd4 100644 --- a/packages/typespec-test/test/widget_dpg/generated/typespec-ts/src/rest/index.ts +++ b/packages/typespec-test/test/widget_dpg/generated/typespec-ts/src/rest/index.ts @@ -10,6 +10,7 @@ export * from "./clientDefinitions.js"; export * from "./isUnexpected.js"; export * from "./models.js"; export * from "./outputModels.js"; +export * from "./paginateHelper.js"; export * from "./serializeHelper.js"; export default WidgetServiceClient; diff --git a/packages/typespec-test/test/widget_dpg/generated/typespec-ts/src/rest/isUnexpected.ts b/packages/typespec-test/test/widget_dpg/generated/typespec-ts/src/rest/isUnexpected.ts index 5707c6381d..91f5d9c532 100644 --- a/packages/typespec-test/test/widget_dpg/generated/typespec-ts/src/rest/isUnexpected.ts +++ b/packages/typespec-test/test/widget_dpg/generated/typespec-ts/src/rest/isUnexpected.ts @@ -6,6 +6,10 @@ import { ListWidgetsDefaultResponse, CreateWidget201Response, CreateWidgetDefaultResponse, + ListWidgetsPages200Response, + ListWidgetsPagesDefaultResponse, + QueryWidgetsPages200Response, + QueryWidgetsPagesDefaultResponse, GetWidget200Response, GetWidgetDefaultResponse, UpdateWidget200Response, @@ -19,6 +23,8 @@ import { const responseMap: Record = { "GET /widgets": ["200"], "POST /widgets": ["201"], + "GET /widgets/widgets/pages": ["200"], + "POST /widgets/widgets/pages": ["200"], "GET /widgets/{id}": ["200"], "PATCH /widgets/{id}": ["200"], "DELETE /widgets/{id}": ["204"], @@ -31,6 +37,12 @@ export function isUnexpected( export function isUnexpected( response: CreateWidget201Response | CreateWidgetDefaultResponse ): response is CreateWidgetDefaultResponse; +export function isUnexpected( + response: ListWidgetsPages200Response | ListWidgetsPagesDefaultResponse +): response is ListWidgetsPagesDefaultResponse; +export function isUnexpected( + response: QueryWidgetsPages200Response | QueryWidgetsPagesDefaultResponse +): response is QueryWidgetsPagesDefaultResponse; export function isUnexpected( response: GetWidget200Response | GetWidgetDefaultResponse ): response is GetWidgetDefaultResponse; @@ -49,6 +61,10 @@ export function isUnexpected( | ListWidgetsDefaultResponse | CreateWidget201Response | CreateWidgetDefaultResponse + | ListWidgetsPages200Response + | ListWidgetsPagesDefaultResponse + | QueryWidgetsPages200Response + | QueryWidgetsPagesDefaultResponse | GetWidget200Response | GetWidgetDefaultResponse | UpdateWidget200Response @@ -60,6 +76,8 @@ export function isUnexpected( ): response is | ListWidgetsDefaultResponse | CreateWidgetDefaultResponse + | ListWidgetsPagesDefaultResponse + | QueryWidgetsPagesDefaultResponse | GetWidgetDefaultResponse | UpdateWidgetDefaultResponse | DeleteWidgetDefaultResponse diff --git a/packages/typespec-test/test/widget_dpg/generated/typespec-ts/src/rest/outputModels.ts b/packages/typespec-test/test/widget_dpg/generated/typespec-ts/src/rest/outputModels.ts index 9df9238a04..43e9648f37 100644 --- a/packages/typespec-test/test/widget_dpg/generated/typespec-ts/src/rest/outputModels.ts +++ b/packages/typespec-test/test/widget_dpg/generated/typespec-ts/src/rest/outputModels.ts @@ -17,6 +17,13 @@ export interface WidgetErrorOutput { message: string; } +export interface ListWidgetsPagesResultsOutput { + /** The current page of results. */ + results: Array; + /** The URL to get the next set of results. */ + "odata.nextLink"?: string; +} + export interface AnalyzeResultOutput { summary: string; } diff --git a/packages/typespec-test/test/widget_dpg/generated/typespec-ts/src/rest/paginateHelper.ts b/packages/typespec-test/test/widget_dpg/generated/typespec-ts/src/rest/paginateHelper.ts new file mode 100644 index 0000000000..bea7a77a07 --- /dev/null +++ b/packages/typespec-test/test/widget_dpg/generated/typespec-ts/src/rest/paginateHelper.ts @@ -0,0 +1,204 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +import { + getPagedAsyncIterator, + PagedAsyncIterableIterator, + PagedResult, +} from "@azure/core-paging"; +import { + Client, + createRestError, + PathUncheckedResponse, +} from "@azure-rest/core-client"; + +/** + * Helper type to extract the type of an array + */ +export type GetArrayType = T extends Array ? TData : never; + +/** + * The type of a custom function that defines how to get a page and a link to the next one if any. + */ +export type GetPage = ( + pageLink: string, + maxPageSize?: number +) => Promise<{ + page: TPage; + nextPageLink?: string; +}>; + +/** + * Options for the paging helper + */ +export interface PagingOptions { + /** + * Custom function to extract pagination details for crating the PagedAsyncIterableIterator + */ + customGetPage?: GetPage[]>; +} + +/** + * Helper type to infer the Type of the paged elements from the response type + * This type is generated based on the swagger information for x-ms-pageable + * specifically on the itemName property which indicates the property of the response + * where the page items are found. The default value is `value`. + * This type will allow us to provide strongly typed Iterator based on the response we get as second parameter + */ +export type PaginateReturn = TResult extends + | { + body: { value?: infer TPage }; + } + | { + body: { results?: infer TPage }; + } + ? GetArrayType + : Array; + +/** + * Helper to paginate results from an initial response that follows the specification of Autorest `x-ms-pageable` extension + * @param client - Client to use for sending the next page requests + * @param initialResponse - Initial response containing the nextLink and current page of elements + * @param customGetPage - Optional - Function to define how to extract the page and next link to be used to paginate the results + * @returns - PagedAsyncIterableIterator to iterate the elements + */ +export function paginate( + client: Client, + initialResponse: TResponse, + options: PagingOptions = {} +): PagedAsyncIterableIterator> { + // Extract element type from initial response + type TElement = PaginateReturn; + let firstRun = true; + // We need to check the response for success before trying to inspect it looking for + // the properties to use for nextLink and itemName + checkPagingRequest(initialResponse); + const { itemName, nextLinkName } = getPaginationProperties(initialResponse); + const { customGetPage } = options; + const pagedResult: PagedResult = { + firstPageLink: "", + getPage: + typeof customGetPage === "function" + ? customGetPage + : async (pageLink: string) => { + const result = firstRun + ? initialResponse + : await client.pathUnchecked(pageLink).get(); + firstRun = false; + checkPagingRequest(result); + const nextLink = getNextLink(result.body, nextLinkName); + const values = getElements(result.body, itemName); + return { + page: values, + nextPageLink: nextLink, + }; + }, + }; + + return getPagedAsyncIterator(pagedResult); +} + +/** + * Gets for the value of nextLink in the body + */ +function getNextLink(body: unknown, nextLinkName?: string): string | undefined { + if (!nextLinkName) { + return undefined; + } + + const nextLink = (body as Record)[nextLinkName]; + + if (typeof nextLink !== "string" && typeof nextLink !== "undefined") { + throw new Error( + `Body Property ${nextLinkName} should be a string or undefined` + ); + } + + return nextLink; +} + +/** + * Gets the elements of the current request in the body. + */ +function getElements(body: unknown, itemName: string): T[] { + const value = (body as Record)[itemName] as T[]; + + // value has to be an array according to the x-ms-pageable extension. + // The fact that this must be an array is used above to calculate the + // type of elements in the page in PaginateReturn + if (!Array.isArray(value)) { + throw new Error( + `Couldn't paginate response\n Body doesn't contain an array property with name: ${itemName}` + ); + } + + return value ?? []; +} + +/** + * Checks if a request failed + */ +function checkPagingRequest(response: PathUncheckedResponse): void { + const Http2xxStatusCodes = [ + "200", + "201", + "202", + "203", + "204", + "205", + "206", + "207", + "208", + "226", + ]; + if (!Http2xxStatusCodes.includes(response.status)) { + throw createRestError( + `Pagination failed with unexpected statusCode ${response.status}`, + response + ); + } +} + +/** + * Extracts the itemName and nextLinkName from the initial response to use them for pagination + */ +function getPaginationProperties(initialResponse: PathUncheckedResponse) { + // Build a set with the passed custom nextLinkNames + const nextLinkNames = new Set(["nextLink", "odata.nextLink"]); + + // Build a set with the passed custom set of itemNames + const itemNames = new Set(["value", "results"]); + + let nextLinkName: string | undefined; + let itemName: string | undefined; + + for (const name of nextLinkNames) { + const nextLink = (initialResponse.body as Record)[ + name + ] as string; + if (nextLink) { + nextLinkName = name; + break; + } + } + + for (const name of itemNames) { + const item = (initialResponse.body as Record)[ + name + ] as string; + if (item) { + itemName = name; + break; + } + } + + if (!itemName) { + throw new Error( + `Couldn't paginate response\n Body doesn't contain an array property with name: ${[ + ...itemNames, + ].join(" OR ")}` + ); + } + + return { itemName, nextLinkName }; +} diff --git a/packages/typespec-test/test/widget_dpg/generated/typespec-ts/src/rest/parameters.ts b/packages/typespec-test/test/widget_dpg/generated/typespec-ts/src/rest/parameters.ts index 7db3c5c9a8..74ea7dfc40 100644 --- a/packages/typespec-test/test/widget_dpg/generated/typespec-ts/src/rest/parameters.ts +++ b/packages/typespec-test/test/widget_dpg/generated/typespec-ts/src/rest/parameters.ts @@ -23,6 +23,30 @@ export interface ListWidgetsHeaderParam { } export type ListWidgetsParameters = ListWidgetsHeaderParam & RequestParameters; + +export interface ListWidgetsPagesQueryParamProperties { + page: number; + pageSize: number; +} + +export interface ListWidgetsPagesQueryParam { + queryParameters: ListWidgetsPagesQueryParamProperties; +} + +export type ListWidgetsPagesParameters = ListWidgetsPagesQueryParam & + RequestParameters; + +export interface QueryWidgetsPagesQueryParamProperties { + page: number; + pageSize: number; +} + +export interface QueryWidgetsPagesQueryParam { + queryParameters: QueryWidgetsPagesQueryParamProperties; +} + +export type QueryWidgetsPagesParameters = QueryWidgetsPagesQueryParam & + RequestParameters; export type GetWidgetParameters = RequestParameters; export interface CreateWidgetBodyParam { diff --git a/packages/typespec-test/test/widget_dpg/generated/typespec-ts/src/rest/responses.ts b/packages/typespec-test/test/widget_dpg/generated/typespec-ts/src/rest/responses.ts index d7da1e8ace..77a8954ac9 100644 --- a/packages/typespec-test/test/widget_dpg/generated/typespec-ts/src/rest/responses.ts +++ b/packages/typespec-test/test/widget_dpg/generated/typespec-ts/src/rest/responses.ts @@ -5,6 +5,7 @@ import { HttpResponse } from "@azure-rest/core-client"; import { WidgetOutput, WidgetErrorOutput, + ListWidgetsPagesResultsOutput, AnalyzeResultOutput, } from "./outputModels.js"; @@ -19,6 +20,28 @@ export interface ListWidgetsDefaultResponse extends HttpResponse { body: WidgetErrorOutput; } +/** The request has succeeded. */ +export interface ListWidgetsPages200Response extends HttpResponse { + status: "200"; + body: ListWidgetsPagesResultsOutput; +} + +export interface ListWidgetsPagesDefaultResponse extends HttpResponse { + status: string; + body: WidgetErrorOutput; +} + +/** The request has succeeded. */ +export interface QueryWidgetsPages200Response extends HttpResponse { + status: "200"; + body: ListWidgetsPagesResultsOutput; +} + +export interface QueryWidgetsPagesDefaultResponse extends HttpResponse { + status: string; + body: WidgetErrorOutput; +} + /** The request has succeeded. */ export interface GetWidget200Response extends HttpResponse { status: "200"; diff --git a/packages/typespec-test/test/widget_dpg/spec/main.tsp b/packages/typespec-test/test/widget_dpg/spec/main.tsp index 118d2ca661..8aabf397b9 100644 --- a/packages/typespec-test/test/widget_dpg/spec/main.tsp +++ b/packages/typespec-test/test/widget_dpg/spec/main.tsp @@ -1,6 +1,8 @@ import "@typespec/http"; +import "@azure-tools/typespec-azure-core"; using TypeSpec.Http; +using Azure.Core; @service({ title: "Widget Service", version: "1.0.0", @@ -48,6 +50,16 @@ model WidgetError { message: string; } +@pagedResult +model ListWidgetsPagesResults { + @doc("The current page of results.") + @items + results: Widget[]; + @doc("The URL to get the next set of results.") + @nextLink + `odata.nextLink`?: string; +} + @route("/widgets") @tag("Widgets") interface Widgets { @@ -72,6 +84,20 @@ It does not accept any options or parameters. @header nullableDateHeader?: utcDateTime | null, ): Widget[] | WidgetError; + @get + @route("/widgets/pages") + listWidgetsPages( + @query page: int32, + @query pageSize: int32, + ): ListWidgetsPagesResults | WidgetError; + + @post + @route("/widgets/pages") + queryWidgetsPages( + @query page: int32, + @query pageSize: int32, + ): ListWidgetsPagesResults | WidgetError; + @doc("Get a widget by ID.") @get getWidget(@path id: string): Widget | WidgetError; diff --git a/packages/typespec-ts/src/index.ts b/packages/typespec-ts/src/index.ts index 264754354f..84556a3701 100644 --- a/packages/typespec-ts/src/index.ts +++ b/packages/typespec-ts/src/index.ts @@ -17,7 +17,7 @@ import { buildApiExtractorConfig, buildPackageFile, buildPollingHelper, - buildPaginateHelper, + buildPaginateHelper as buildRLCPaginateHelper, buildEsLintConfig, buildKarmaConfigFile, buildEnvFile, @@ -54,6 +54,10 @@ import { GenerationDirDetail, SdkContext } from "./utils/interfaces.js"; import { transformRLCOptions } from "./transform/transfromRLCOptions.js"; import { ModularCodeModel } from "./modular/modularCodeModel.js"; import { getClientName } from "@azure-tools/rlc-common"; +import { + buildPagingTypes, + buildPagingHelpers as buildModularPagingHelpers +} from "./modular/buildPagingFiles.js"; export * from "./lib.js"; @@ -136,7 +140,7 @@ export async function $onEmit(context: EmitContext) { await emitContentByBuilder(program, buildIndexFile, rlcModels); await emitContentByBuilder(program, buildLogger, rlcModels); await emitContentByBuilder(program, buildTopLevelIndex, rlcModels); - await emitContentByBuilder(program, buildPaginateHelper, rlcModels); + await emitContentByBuilder(program, buildRLCPaginateHelper, rlcModels); await emitContentByBuilder(program, buildPollingHelper, rlcModels); await emitContentByBuilder(program, buildSerializeHelper, rlcModels); await emitContentByBuilder( @@ -170,11 +174,20 @@ export async function $onEmit(context: EmitContext) { overwrite: true } ); + + const isMultiClients = modularCodeModel.clients.length > 1; for (const subClient of modularCodeModel.clients) { buildModels(modularCodeModel, subClient); buildModelsOptions(modularCodeModel, subClient); const hasClientUnexpectedHelper = needUnexpectedHelper.get(subClient.rlcClientName) ?? false; + buildPagingTypes(modularCodeModel, subClient); + buildModularPagingHelpers( + modularCodeModel, + subClient, + hasClientUnexpectedHelper, + isMultiClients + ); buildOperationFiles( dpgContext, modularCodeModel, @@ -197,7 +210,7 @@ export async function $onEmit(context: EmitContext) { exportIndex: true, interfaceOnly: true }); - if (modularCodeModel.clients.length > 1) { + if (isMultiClients) { buildSubClientIndexFile(modularCodeModel, subClient); } buildRootIndex(modularCodeModel, subClient, rootIndexFile); diff --git a/packages/typespec-ts/src/lib.ts b/packages/typespec-ts/src/lib.ts index 1a073ad168..6708c5b46f 100644 --- a/packages/typespec-ts/src/lib.ts +++ b/packages/typespec-ts/src/lib.ts @@ -192,6 +192,12 @@ const libDef = { default: "Required header cannot be nullable. Please remove the nullable modifier." } + }, + "no-paging-items-defined": { + severity: "warning", + messages: { + default: paramMessage`Please specify @items property for the paging operation - ${"operationName"}.` + } } }, emitter: { diff --git a/packages/typespec-ts/src/modular/buildClassicalClient.ts b/packages/typespec-ts/src/modular/buildClassicalClient.ts index 6a0cb8b168..2d5b1e94a1 100644 --- a/packages/typespec-ts/src/modular/buildClassicalClient.ts +++ b/packages/typespec-ts/src/modular/buildClassicalClient.ts @@ -155,6 +155,21 @@ function importAllModels( moduleSpecifier: `./models/options.js`, namedImports: exportedOptions }); + + const pagingTypes = project.getSourceFile( + `${srcPath}/${subfolder !== "" ? subfolder + "/" : ""}models/pagingTypes.ts` + ); + + if (!pagingTypes) { + return; + } + + const exportedPaingTypes = [...pagingTypes.getExportedDeclarations().keys()]; + + clientFile.addImportDeclaration({ + moduleSpecifier: `./models/pagingTypes.js`, + namedImports: exportedPaingTypes + }); } function importPipeline( diff --git a/packages/typespec-ts/src/modular/buildClassicalOperationGroups.ts b/packages/typespec-ts/src/modular/buildClassicalOperationGroups.ts index 24cb5575b9..0a1dc6633d 100644 --- a/packages/typespec-ts/src/modular/buildClassicalOperationGroups.ts +++ b/packages/typespec-ts/src/modular/buildClassicalOperationGroups.ts @@ -7,7 +7,7 @@ import { NameType } from "@azure-tools/rlc-common"; import { getClassicalOperation } from "./helpers/classicalOperationHelpers.js"; import { getClassicalLayerPrefix } from "./helpers/namingHelpers.js"; import { SourceFile } from "ts-morph"; -import { importModels } from "./buildOperations.js"; +import { importModels, importPagingDependencies } from "./buildOperations.js"; export function buildClassicOperationFiles( codeModel: ModularCodeModel, @@ -52,6 +52,14 @@ export function buildClassicOperationFiles( operationGroup.namespaceHierarchies.length ); importApis(classicFile, client, codeModel, operationGroup); + // We need to import the paging helpers and types explicitly because ts-morph may not be able to find them. + importPagingDependencies( + srcPath, + classicFile, + codeModel.project, + subfolder, + operationGroup.namespaceHierarchies.length + ); classicFile.fixMissingImports(); classicFile.fixUnusedIdentifiers(); classicOperationFiles.set(classicOperationFileName, classicFile); @@ -91,6 +99,14 @@ export function buildClassicOperationFiles( // We SHOULD keep this because otherwise ts-morph will "helpfully" try to import models from the rest layer when we call fixMissingImports(). importModels(srcPath, classicFile, codeModel.project, subfolder, layer); importApis(classicFile, client, codeModel, operationGroup, layer); + // We need to import the paging helpers and types explicitly because ts-morph may not be able to find them. + importPagingDependencies( + srcPath, + classicFile, + codeModel.project, + subfolder, + operationGroup.namespaceHierarchies.length + ); classicFile.fixMissingImports(); classicFile.fixUnusedIdentifiers(); classicOperationFiles.set(classicOperationFileName, classicFile); diff --git a/packages/typespec-ts/src/modular/buildCodeModel.ts b/packages/typespec-ts/src/modular/buildCodeModel.ts index dcfe9b978f..2e3988c970 100644 --- a/packages/typespec-ts/src/modular/buildCodeModel.ts +++ b/packages/typespec-ts/src/modular/buildCodeModel.ts @@ -96,7 +96,9 @@ import { getOperationName, isBinaryPayload, isIgnoredHeaderParam, - isLongRunningOperation + isLongRunningOperation, + parseItemName, + parseNextLinkName } from "../utils/operationUtil.js"; import { SdkContext } from "../utils/interfaces.js"; import { Project } from "ts-morph"; @@ -105,6 +107,7 @@ import { getModelNamespaceName, getOperationNamespaceInterfaceName } from "../utils/namespaceUtils.js"; +import { reportDiagnostic } from "../lib.js"; interface HttpServerParameter { type: "endpointPath"; @@ -639,11 +642,39 @@ function emitOperation( operationGroupName: string, rlcModels: RLCModel ): HrlcOperation { + const isBranded = rlcModels.options?.branded ?? true; + // Skip to extract paging and lro information for non-branded clients. + if (!isBranded) { + return emitBasicOperation( + context, + operation, + operationGroupName, + rlcModels + ); + } const lro = isLongRunningOperation( context.program, ignoreDiagnostics(getHttpOperation(context.program, operation)) ); - const paging = getPagedResult(context.program, operation); + const pagingMetadata = getPagedResult(context.program, operation); + // Disable the paging feature if no itemsSegments is found. + const paging = + pagingMetadata && + pagingMetadata.itemsSegments && + pagingMetadata.itemsSegments.length > 0; + if ( + pagingMetadata && + (!pagingMetadata.itemsSegments || pagingMetadata.itemsSegments.length === 0) + ) { + reportDiagnostic(context.program, { + code: "no-paging-items-defined", + format: { + operationName: operation.name + }, + target: operation + }); + } + if (lro && paging) { return emitLroPagingOperation( context, @@ -680,8 +711,8 @@ function addPagingInformation( "Trying to add paging information, but not paging metadata for this operation" ); } - emittedOperation["itemName"] = pagedResult.itemsPath; - emittedOperation["continuationTokenName"] = pagedResult.nextLinkPath; + emittedOperation["itemName"] = parseItemName(pagedResult); + emittedOperation["continuationTokenName"] = parseNextLinkName(pagedResult); } function emitLroPagingOperation( diff --git a/packages/typespec-ts/src/modular/buildOperations.ts b/packages/typespec-ts/src/modular/buildOperations.ts index 280c9e4b8e..f3e748d583 100644 --- a/packages/typespec-ts/src/modular/buildOperations.ts +++ b/packages/typespec-ts/src/modular/buildOperations.ts @@ -56,6 +56,15 @@ export function buildOperationFiles( operationGroup.namespaceHierarchies.length ); + // We need to import the paging helpers and types explicitly because ts-morph may not be able to find them. + importPagingDependencies( + srcPath, + operationGroupFile, + codeModel.project, + subfolder, + operationGroup.namespaceHierarchies.length + ); + const namedImports: string[] = []; let clientType = "Client"; if (isRLCMultiEndpoint(dpgContext)) { @@ -175,6 +184,48 @@ export function importModels( // sourceFile.fixUnusedIdentifiers(); } +export function importPagingDependencies( + srcPath: string, + sourceFile: SourceFile, + project: Project, + subfolder: string = "", + importLayer: number = 0 +) { + const pagingTypes = project.getSourceFile( + `${srcPath}/${subfolder !== "" ? subfolder + "/" : ""}models/pagingTypes.ts` + ); + + if (!pagingTypes) { + return; + } + + const exportedPaingTypes = [...pagingTypes.getExportedDeclarations().keys()]; + + sourceFile.addImportDeclaration({ + moduleSpecifier: `${"../".repeat(importLayer + 1)}models/pagingTypes.js`, + namedImports: exportedPaingTypes + }); + + const pagingHelper = project.getSourceFile( + `${srcPath}/${subfolder !== "" ? subfolder + "/" : ""}api/pagingHelpers.ts` + ); + + if (!pagingHelper) { + return; + } + + const exportedPaingHelpers = [ + ...pagingHelper.getExportedDeclarations().keys() + ]; + + sourceFile.addImportDeclaration({ + moduleSpecifier: `${ + importLayer === 0 ? "./" : "../".repeat(importLayer) + }pagingHelpers.js`, + namedImports: exportedPaingHelpers + }); +} + /** * This function generates the interfaces for each operation options */ diff --git a/packages/typespec-ts/src/modular/buildPagingFiles.ts b/packages/typespec-ts/src/modular/buildPagingFiles.ts new file mode 100644 index 0000000000..a72fd7333d --- /dev/null +++ b/packages/typespec-ts/src/modular/buildPagingFiles.ts @@ -0,0 +1,356 @@ +import path from "path"; +import { Client, ModularCodeModel } from "./modularCodeModel.js"; +import { + hasPagingOperation, + isPagingOperation +} from "./helpers/operationHelpers.js"; + +export function buildPagingTypes(codeModel: ModularCodeModel, client: Client) { + if (!hasPagingOperation(client)) { + return; + } + const filePath = path.join( + codeModel.modularOptions.sourceRoot, + client.subfolder ?? "", + `models/pagingTypes.ts` + ); + const fileContent = codeModel.project.createSourceFile(filePath, undefined, { + overwrite: true + }); + fileContent.addStatements([ + ` + /** + * Options for the byPage method + */ + export interface PageSettings { + /** + * A reference to a specific page to start iterating from. + */ + continuationToken?: string; + } + + /** + * An interface that describes a page of results. + */ + export type ContinuablePage = TPage & { + /** + * The token that keeps track of where to continue the iterator + */ + continuationToken?: string; + }; + + /** + * An interface that allows async iterable iteration both to completion and by page. + */ + export interface PagedAsyncIterableIterator< + TElement, + TPage = TElement[], + TPageSettings extends PageSettings = PageSettings + > { + /** + * The next method, part of the iteration protocol + */ + next(): Promise>; + /** + * The connection to the async iterator, part of the iteration protocol + */ + [Symbol.asyncIterator](): PagedAsyncIterableIterator< + TElement, + TPage, + TPageSettings + >; + /** + * Return an AsyncIterableIterator that works a page at a time + */ + byPage: ( + settings?: TPageSettings + ) => AsyncIterableIterator>; + } + + /** + * An interface that describes how to communicate with the service. + */ + export interface PagedResult< + TElement, + TPage = TElement[], + TPageSettings extends PageSettings = PageSettings + > { + /** + * Link to the first page of results. + */ + firstPageLink?: string; + /** + * A method that returns a page of results. + */ + getPage: ( + pageLink?: string + ) => Promise<{ page: TPage; nextPageLink?: string } | undefined>; + /** + * a function to implement the \`byPage\` method on the paged async iterator. + */ + byPage?: ( + settings?: TPageSettings + ) => AsyncIterableIterator>; + + /** + * A function to extract elements from a page. + */ + toElements?: (page: TPage) => TElement[]; + } + + /** + * Options for the paging helper + */ + export interface BuildPagedAsyncIteratorOptions { + itemName?: string; + nextLinkName?: string; + } + ` + ]); + + return fileContent; +} + +export function buildPagingHelpers( + codeModel: ModularCodeModel, + client: Client, + needUnexpectedHelper: boolean = true, + isMultiClients: boolean = false +) { + const pagingOperstions = client.operationGroups + .flatMap((op) => op.operations) + .filter(isPagingOperation); + if (!pagingOperstions || pagingOperstions.length === 0) { + return; + } + + const checkingPagingRequestContent = needUnexpectedHelper + ? `if (isUnexpected(response)) { + throw createRestError( + \`Pagination failed with unexpected statusCode \${response.status}\`, + response + ); + }` + : `const Http2xxStatusCodes = [ + "200", + "201", + "202", + "203", + "204", + "205", + "206", + "207", + "208", + "226", + ]; + if (!Http2xxStatusCodes.includes(response.status)) { + throw createRestError( + \`Pagination failed with unexpected statusCode \${response.status}\`, + response + ); + }`; + + const unexpectedHelperImport = needUnexpectedHelper + ? `import { isUnexpected } from "${ + isMultiClients ? "../" : "" + }../rest/index.js";` + : ""; + const pagingTypesPath = `../models/pagingTypes.js`; + const filePath = path.join( + codeModel.modularOptions.sourceRoot, + client.subfolder ?? "", + `api/pagingHelpers.ts` + ); + + const fileContent = codeModel.project.createSourceFile(filePath, undefined, { + overwrite: true + }); + + fileContent.addStatements([ + ` + import { + Client, + createRestError, + PathUncheckedResponse + } from "@azure-rest/core-client"; + import { RestError } from "@azure/core-rest-pipeline"; + import { + BuildPagedAsyncIteratorOptions, + ContinuablePage, + PageSettings, + PagedAsyncIterableIterator, + PagedResult, + } from "${pagingTypesPath}"; + ${unexpectedHelperImport} + + /** + * Helper to paginate results in a generic way and return a PagedAsyncIterableIterator + */ + export function buildPagedAsyncIterator< + TElement, + TPage = TElement[], + TPageSettings extends PageSettings = PageSettings, + TResponse extends PathUncheckedResponse = PathUncheckedResponse + >( + client: Client, + getInitialResponse: () => PromiseLike, + processResponseBody: (result: TResponse) => PromiseLike, + options: BuildPagedAsyncIteratorOptions = {} + ): PagedAsyncIterableIterator { + const itemName = options.itemName ?? "value"; + const nextLinkName = options.nextLinkName ?? "nextLink"; + const pagedResult: PagedResult = { + getPage: async (pageLink?: string) => { + const result = + pageLink === undefined + ? await getInitialResponse() + : await client.pathUnchecked(pageLink).get(); + checkPagingRequest(result); + const results = await processResponseBody(result as TResponse); + const nextLink = getNextLink(results, nextLinkName); + const values = getElements(results, itemName) as TPage; + return { + page: values, + nextPageLink: nextLink, + }; + }, + byPage: (settings?: TPageSettings) => { + const { continuationToken } = settings ?? {}; + return getPageAsyncIterator(pagedResult, { + pageLink: continuationToken, + }); + }, + }; + return getPagedAsyncIterator(pagedResult); + } + + /** + * returns an async iterator that iterates over results. It also has a \`byPage\` + * method that returns pages of items at once. + * + * @param pagedResult - an object that specifies how to get pages. + * @returns a paged async iterator that iterates over results. + */ + + function getPagedAsyncIterator< + TElement, + TPage = TElement[], + TPageSettings extends PageSettings = PageSettings + >( + pagedResult: PagedResult + ): PagedAsyncIterableIterator { + const iter = getItemAsyncIterator( + pagedResult + ); + return { + next() { + return iter.next(); + }, + [Symbol.asyncIterator]() { + return this; + }, + byPage: + pagedResult?.byPage ?? + ((settings?: TPageSettings) => { + const { continuationToken } = settings ?? {}; + return getPageAsyncIterator(pagedResult, { + pageLink: continuationToken, + }); + }), + }; + } + + async function* getItemAsyncIterator< + TElement, + TPage, + TPageSettings extends PageSettings + >( + pagedResult: PagedResult + ): AsyncIterableIterator { + const pages = getPageAsyncIterator(pagedResult); + for await (const page of pages) { + yield* page as unknown as TElement[]; + } + } + + async function* getPageAsyncIterator< + TElement, + TPage, + TPageSettings extends PageSettings + >( + pagedResult: PagedResult, + options: { + pageLink?: string; + } = {} + ): AsyncIterableIterator> { + const { pageLink } = options; + let response = await pagedResult.getPage( + pageLink ?? pagedResult.firstPageLink + ); + if (!response) { + return; + } + let result = response.page as ContinuablePage; + result.continuationToken = response.nextPageLink; + yield result; + while (response.nextPageLink) { + response = await pagedResult.getPage(response.nextPageLink); + if (!response) { + return; + } + result = response.page as ContinuablePage; + result.continuationToken = response.nextPageLink; + yield result; + } + } + + /** + * Gets for the value of nextLink in the body + */ + function getNextLink(body: unknown, nextLinkName?: string): string | undefined { + if (!nextLinkName) { + return undefined; + } + + const nextLink = (body as Record)[nextLinkName]; + + if ( + typeof nextLink !== "string" && + typeof nextLink !== "undefined" && + nextLink !== null + ) { + throw new RestError( + \`Body Property \${nextLinkName} should be a string or undefined or null but got \${typeof nextLink}\` + ); + } + + if (nextLink === null) { + return undefined; + } + + return nextLink; + } + + /** + * Gets the elements of the current request in the body. + */ + function getElements(body: unknown, itemName: string): T[] { + const value = (body as Record)[itemName] as T[]; + if (!Array.isArray(value)) { + throw new RestError( + \`Couldn't paginate response\\n Body doesn't contain an array property with name: \${itemName}\` + ); + } + + return value ?? []; + } + + /** + * Checks if a request failed + */ + function checkPagingRequest(response: PathUncheckedResponse): void { + ${checkingPagingRequestContent} + } + ` + ]); +} diff --git a/packages/typespec-ts/src/modular/buildSubpathIndex.ts b/packages/typespec-ts/src/modular/buildSubpathIndex.ts index dc85ac0d62..2dfbc67867 100644 --- a/packages/typespec-ts/src/modular/buildSubpathIndex.ts +++ b/packages/typespec-ts/src/modular/buildSubpathIndex.ts @@ -32,11 +32,16 @@ export function buildSubpathIndexFile( if (!options.exportIndex && file.getFilePath().endsWith("index.ts")) { continue; } + // Skip to export pagingHelpers.ts + // pagingHelpers.ts is a file that is used internally and is not exported. + if (file.getFilePath().endsWith("pagingHelpers.ts")) { + continue; + } if (file.getFilePath() === indexFile.getFilePath()) { continue; } - const namedExports: string[] = [...file.getExportedDeclarations().entries()] + let namedExports: string[] = [...file.getExportedDeclarations().entries()] .filter((exDeclaration) => { if (exDeclaration[0].startsWith("_")) { return false; @@ -54,6 +59,12 @@ export function buildSubpathIndexFile( .map((exDeclaration) => { return exDeclaration[0]; }); + // Skip to export PagedResult and BuildPagedAsyncIteratorOptions + if (file.getFilePath().endsWith("pagingTypes.ts")) { + namedExports = namedExports.filter( + (ex) => !["PagedResult", "BuildPagedAsyncIteratorOptions"].includes(ex) + ); + } indexFile.addExportDeclaration({ moduleSpecifier: `.${file .getFilePath() diff --git a/packages/typespec-ts/src/modular/helpers/operationHelpers.ts b/packages/typespec-ts/src/modular/helpers/operationHelpers.ts index 85f4d2a51d..c9eba25fb4 100644 --- a/packages/typespec-ts/src/modular/helpers/operationHelpers.ts +++ b/packages/typespec-ts/src/modular/helpers/operationHelpers.ts @@ -6,6 +6,7 @@ import { import { toPascalCase } from "../../utils/casingUtils.js"; import { BodyParameter, + Client, ModularCodeModel, Operation, Parameter, @@ -248,38 +249,79 @@ export function getOperationFunction( // Extract required parameters const parameters: OptionalKind[] = getOperationSignatureParameters(operation, clientType); + const isPaging = isPagingOperation(operation); // TODO: Support operation overloads const response = operation.responses[0]!; - const returnType = response?.type?.type - ? buildType(response.type.name, response.type, response.type.format) - : { name: "", type: "void" }; - + let returnType = { name: "", type: "void" }; + if (response.type?.type) { + let type = response.type; + if (isPaging) { + type = extractPagingType(type, operation.itemName) ?? type; + } + returnType = buildType(type.name, type, type.format); + } const { name, fixme = [] } = getOperationName(operation); const functionStatement: OptionalKind = { docs: [ ...getDocsFromDescription(operation.description), ...getFixmeForMultilineDocs(fixme) ], - isAsync: true, + isAsync: !isPaging, isExported: true, name: normalizeName(operation.name, NameType.Operation, true), parameters, - returnType: `Promise<${returnType.type}>` + returnType: isPaging + ? `PagedAsyncIterableIterator<${returnType.type}>` + : `Promise<${returnType.type}>` }; const statements: string[] = []; - statements.push( - `const result = await _${name}Send(${parameters - .map((p) => p.name) - .join(", ")});` - ); - statements.push(`return _${name}Deserialize(result);`); + if (isPaging) { + const options = []; + if (operation.itemName) { + options.push(`itemName: "${operation.itemName}"`); + } + if (operation.continuationTokenName) { + options.push(`nextLinkName: "${operation.continuationTokenName}"`); + } + statements.push( + `return buildPagedAsyncIterator( + context, + () => _${name}Send(${parameters.map((p) => p.name).join(", ")}), + _${name}Deserialize, + ${options.length > 0 ? `{${options.join(", ")}}` : ``} + );` + ); + } else { + statements.push( + `const result = await _${name}Send(${parameters + .map((p) => p.name) + .join(", ")});` + ); + statements.push(`return _${name}Deserialize(result);`); + } + return { ...functionStatement, statements }; } + +function extractPagingType(type: Type, itemName?: string): Type | undefined { + if (!itemName) { + return undefined; + } + const prop = (type.properties ?? []) + ?.filter((prop) => prop.restApiName === itemName) + .map((prop) => prop.type); + if (prop.length === 0) { + return undefined; + } + return prop[0]?.type === "list" && prop[0].elementType + ? prop[0].elementType + : undefined; +} export function getOperationOptionsName( operation: Operation, includeGroupName = false @@ -1041,24 +1083,37 @@ function needsDeserialize(type?: Type) { export function hasLROOperation(codeModel: ModularCodeModel) { return (codeModel.clients ?? []).some((c) => (c.operationGroups ?? []).some((og) => - (og.operations ?? []).some( - (op) => op.discriminator === "lro" || op.discriminator === "lropaging" - ) + (og.operations ?? []).some(isLROOperation) ) ); } -export function hasPagingOperation(codeModel: ModularCodeModel) { - return (codeModel.clients ?? []).some((c) => +export function isLROOperation(op: Operation): boolean { + return op.discriminator === "lro" || op.discriminator === "lropaging"; +} + +export function hasPagingOperation(client: Client): boolean; +export function hasPagingOperation(codeModel: ModularCodeModel): boolean; +export function hasPagingOperation( + clientOrCodeModel: Client | ModularCodeModel +): boolean { + let clients: Client[] = []; + if ((clientOrCodeModel as any)?.operationGroups) { + clients = [clientOrCodeModel as Client]; + } else if ((clientOrCodeModel as any)?.clients) { + clients = (clientOrCodeModel as ModularCodeModel).clients; + } + return clients.some((c) => (c.operationGroups ?? []).some((og) => - (og.operations ?? []).some( - (op) => - op.discriminator === "paging" || op.discriminator === "lropaging" - ) + (og.operations ?? []).some(isPagingOperation) ) ); } +export function isPagingOperation(op: Operation): boolean { + return op.discriminator === "paging" || op.discriminator === "lropaging"; +} + function getAllProperties(type: Type): Property[] { const propertiesMap: Map = new Map(); if (!type) { diff --git a/packages/typespec-ts/src/transform/transformHelperFunctionDetails.ts b/packages/typespec-ts/src/transform/transformHelperFunctionDetails.ts index e5eb6f9421..5895c11503 100644 --- a/packages/typespec-ts/src/transform/transformHelperFunctionDetails.ts +++ b/packages/typespec-ts/src/transform/transformHelperFunctionDetails.ts @@ -1,4 +1,3 @@ -import { PagedResultMetadata } from "@azure-tools/typespec-azure-core"; import { SdkClient, listOperationGroups, @@ -11,25 +10,37 @@ import { hasPagingOperations, extractPagedMetadataNested, hasPollingOperations, - getSpecialSerializeInfo + getSpecialSerializeInfo, + parseNextLinkName, + parseItemName } from "../utils/operationUtil.js"; import { SdkContext } from "../utils/interfaces.js"; export function transformHelperFunctionDetails( client: SdkClient, - dpgContext: SdkContext + dpgContext: SdkContext, + isBranded: boolean = true ): HelperFunctionDetails { const program = dpgContext.program; - // Extract paged metadata from Azure.Core.Page - const annotationDetails = { - hasLongRunning: hasPollingOperations(program, client, dpgContext) - }; - const details = extractPageDetailFromCore(program, client, dpgContext); const serializeInfo = extractSpecialSerializeInfo( program, client, dpgContext ); + // Disbale paging and long running for non-branded clients. + if (!isBranded) { + return { + hasLongRunning: false, + hasPaging: false, + ...serializeInfo + }; + } + + // Extract paged metadata from Azure.Core.Page + const annotationDetails = { + hasLongRunning: hasPollingOperations(program, client, dpgContext) + }; + const details = extractPageDetailFromCore(program, client, dpgContext); if (details) { return { ...details, @@ -167,19 +178,6 @@ function extractPageDetailFromCore( }; } -function parseNextLinkName(paged: PagedResultMetadata): string | undefined { - return paged.nextLinkProperty?.name; -} - -function parseItemName(paged: PagedResultMetadata): string | undefined { - const pathComponents = paged.itemsPath?.split("."); - if (pathComponents) { - // TODO: This logic breaks down if there actually is a dotted path. - return pathComponents[pathComponents.length - 1]; - } - return undefined; -} - function extractSpecialSerializeInfo( program: Program, client: SdkClient, diff --git a/packages/typespec-ts/src/utils/operationUtil.ts b/packages/typespec-ts/src/utils/operationUtil.ts index 9f4f7bed26..5e110f5e8b 100644 --- a/packages/typespec-ts/src/utils/operationUtil.ts +++ b/packages/typespec-ts/src/utils/operationUtil.ts @@ -530,3 +530,14 @@ export function isIgnoredHeaderParam(param: HttpOperationParameter) { )) ); } + +export function parseNextLinkName( + paged: PagedResultMetadata +): string | undefined { + return paged.nextLinkProperty?.name; +} + +export function parseItemName(paged: PagedResultMetadata): string | undefined { + // TODO: support the nested item names + return (paged.itemsSegments ?? [])[0]; +} diff --git a/packages/typespec-ts/test/commands/cadl-ranch-list.ts b/packages/typespec-ts/test/commands/cadl-ranch-list.ts index 0e515a9f2f..b14afd52a4 100644 --- a/packages/typespec-ts/test/commands/cadl-ranch-list.ts +++ b/packages/typespec-ts/test/commands/cadl-ranch-list.ts @@ -244,6 +244,10 @@ export const modularTsps: TypeSpecRanchConfig[] = [ outputPath: "azure/core", inputPath: "azure/core/basic" }, + { + outputPath: "payload/pageable", + inputPath: "payload/pageable" + }, { outputPath: "encode/bytes", inputPath: "encode/bytes" diff --git a/packages/typespec-ts/test/modularIntegration/azureCore.spec.ts b/packages/typespec-ts/test/modularIntegration/azureCore.spec.ts index 631e5f0fe7..eca29fdcfd 100644 --- a/packages/typespec-ts/test/modularIntegration/azureCore.spec.ts +++ b/packages/typespec-ts/test/modularIntegration/azureCore.spec.ts @@ -1,4 +1,4 @@ -import { BasicClient } from "./generated/azure/core/src/index.js"; +import { BasicClient, User } from "./generated/azure/core/src/index.js"; import { assert } from "chai"; describe("BasicClient Classical Client", () => { @@ -10,7 +10,153 @@ describe("BasicClient Classical Client", () => { }); }); - it("should create the client", async () => { - assert.isNotNull(client); + describe("list", () => { + describe("next", () => { + it("should list all users", async () => { + const iter = client.list({ + top: 5, + skip: 10, + orderby: ["id"], + filter: "id lt 10", + select: ["id", "orders", "etag"], + expand: ["orders"], + requestOptions: { skipUrlEncoding: true } + }); + const items = []; + for await (const user of iter) { + items.push(user); + } + assert.strictEqual(items.length, 2); + assert.strictEqual(items[0]?.name, "Madge"); + assert.strictEqual( + items[1]?.etag, + "11bdc430-65e8-45ad-81d9-8ffa60d55b5a" + ); + }); + }); + + describe("byPage", () => { + it("should get all users by page without any settings", async () => { + const iter = client.list({ + top: 5, + skip: 10, + orderby: ["id"], + filter: "id lt 10", + select: ["id", "orders", "etag"], + expand: ["orders"], + requestOptions: { skipUrlEncoding: true } + }); + const pagedItems = iter.byPage(); + const items: User[] = []; + for await (const page of pagedItems) { + items.push(...page); + } + assert.strictEqual(items.length, 2); + assert.strictEqual(items[0]?.name, "Madge"); + assert.strictEqual( + items[1]?.etag, + "11bdc430-65e8-45ad-81d9-8ffa60d55b5a" + ); + }); + + it("maxPageSize param should be ignored", async () => { + const iter = client.list({ + top: 5, + skip: 10, + orderby: ["id"], + filter: "id lt 10", + select: ["id", "orders", "etag"], + expand: ["orders"], + requestOptions: { skipUrlEncoding: true } + }); + + const pagedIter = iter.byPage({ maxPageSize: 10 } as any); + const items: User[] = (await pagedIter.next()).value; + assert.strictEqual(items.length, 2); + }); + + it("should get users by continuationToken", async () => { + const iter = client.list(); + const pagedItems = iter.byPage({ + continuationToken: + "/azure/core/basic/users?top=5&skip=10&orderby=id&filter=id%20lt%2010&select=id&select=orders&select=etag&expand=orders&api-version=2022-12-01-preview" + }); + const items: User[] = []; + for await (const user of pagedItems) { + items.push(...user); + } + assert.strictEqual(items.length, 2); + assert.strictEqual(items[0]?.name, "Madge"); + assert.strictEqual( + items[1]?.etag, + "11bdc430-65e8-45ad-81d9-8ffa60d55b5a" + ); + }); + }); + }); + + it("should get a user", async () => { + const user = await client.get(1); + assert.strictEqual(user?.name, "Madge"); + assert.strictEqual(user?.etag, "11bdc430-65e8-45ad-81d9-8ffa60d55b59"); + }); + + it("should list with custom page model", async () => { + const customPageIter = await client.listWithCustomPageModel(); + const items = []; + for await (const user of customPageIter) { + items.push(user); + } + assert.strictEqual(items.length, 1); + assert.strictEqual(items[0]?.name, "Madge"); + assert.strictEqual(items[0]?.etag, "11bdc430-65e8-45ad-81d9-8ffa60d55b59"); + }); + + it("should list with page", async () => { + const customPageIter = await client.listWithPage(); + const items = []; + for await (const user of customPageIter) { + items.push(user); + } + assert.strictEqual(items.length, 1); + assert.strictEqual(items[0]?.name, "Madge"); + assert.strictEqual(items[0]?.etag, "11bdc430-65e8-45ad-81d9-8ffa60d55b59"); + }); + + it("should list with parameters", async () => { + const customPageIter = await client.listWithParameters( + { + inputName: "Madge" + }, + { + another: "Second" + } + ); + const items = []; + for await (const user of customPageIter) { + items.push(user); + } + assert.strictEqual(items.length, 1); + assert.strictEqual(items[0]?.name, "Madge"); + }); + + it("should list first item", async () => { + const customPageIter = await client.listFirstItem(); + const items = []; + for await (const user of customPageIter) { + items.push(user); + } + assert.strictEqual(items.length, 1); + assert.strictEqual(items[0]?.id, 1); + }); + + it("should list second item", async () => { + const customPageIter = await client.listSecondItem(); + const items = []; + for await (const user of customPageIter) { + items.push(user); + } + assert.strictEqual(items.length, 1); + assert.strictEqual(items[0]?.name, "Madge"); }); }); diff --git a/packages/typespec-ts/test/modularIntegration/generated/azure/core/src/BasicClient.ts b/packages/typespec-ts/test/modularIntegration/generated/azure/core/src/BasicClient.ts index b2a8f6eb8f..16d1bf330f 100644 --- a/packages/typespec-ts/test/modularIntegration/generated/azure/core/src/BasicClient.ts +++ b/packages/typespec-ts/test/modularIntegration/generated/azure/core/src/BasicClient.ts @@ -5,10 +5,8 @@ import { Pipeline } from "@azure/core-rest-pipeline"; import { User, ListItemInputBody, - UserListResults, - PagedUser, - PagedFirstItem, - PagedSecondItem, + FirstItem, + SecondItem, } from "./models/models.js"; import { CreateOrUpdateOptions, @@ -23,6 +21,7 @@ import { ListFirstItemOptions, ListSecondItemOptions, } from "./models/options.js"; +import { PagedAsyncIterableIterator } from "./models/pagingTypes.js"; import { createBasic, BasicClientOptions, @@ -77,14 +76,16 @@ export class BasicClient { } /** Lists all Users */ - list(options: ListOptions = { requestOptions: {} }): Promise { + list( + options: ListOptions = { requestOptions: {} } + ): PagedAsyncIterableIterator { return list(this._client, options); } /** List with Azure.Core.Page<>. */ listWithPage( options: ListWithPageOptions = { requestOptions: {} } - ): Promise { + ): PagedAsyncIterableIterator { return listWithPage(this._client, options); } @@ -92,14 +93,14 @@ export class BasicClient { listWithParameters( bodyInput: ListItemInputBody, options: ListWithParametersOptions = { requestOptions: {} } - ): Promise { + ): PagedAsyncIterableIterator { return listWithParameters(this._client, bodyInput, options); } /** List with custom page model. */ listWithCustomPageModel( options: ListWithCustomPageModelOptions = { requestOptions: {} } - ): Promise { + ): PagedAsyncIterableIterator { return listWithCustomPageModel(this._client, options); } @@ -123,14 +124,14 @@ export class BasicClient { /** Two operations with two different page item types should be successfully generated. Should generate model for FirstItem. */ listFirstItem( options: ListFirstItemOptions = { requestOptions: {} } - ): Promise { + ): PagedAsyncIterableIterator { return listFirstItem(this._client, options); } /** Two operations with two different page item types should be successfully generated. Should generate model for SecondItem. */ listSecondItem( options: ListSecondItemOptions = { requestOptions: {} } - ): Promise { + ): PagedAsyncIterableIterator { return listSecondItem(this._client, options); } } diff --git a/packages/typespec-ts/test/modularIntegration/generated/azure/core/src/api/operations.ts b/packages/typespec-ts/test/modularIntegration/generated/azure/core/src/api/operations.ts index 9efe2c27ee..1c9b00b115 100644 --- a/packages/typespec-ts/test/modularIntegration/generated/azure/core/src/api/operations.ts +++ b/packages/typespec-ts/test/modularIntegration/generated/azure/core/src/api/operations.ts @@ -7,8 +7,12 @@ import { UserListResults, PagedUser, PagedFirstItem, + FirstItem, PagedSecondItem, + SecondItem, } from "../models/models.js"; +import { PagedAsyncIterableIterator } from "../models/pagingTypes.js"; +import { buildPagedAsyncIterator } from "./pagingHelpers.js"; import { isUnexpected, BasicContext as Client, @@ -275,12 +279,16 @@ export async function _listDeserialize( } /** Lists all Users */ -export async function list( +export function list( context: Client, options: ListOptions = { requestOptions: {} } -): Promise { - const result = await _listSend(context, options); - return _listDeserialize(result); +): PagedAsyncIterableIterator { + return buildPagedAsyncIterator( + context, + () => _listSend(context, options), + _listDeserialize, + { itemName: "value", nextLinkName: "nextLink" } + ); } export function _listWithPageSend( @@ -317,12 +325,16 @@ export async function _listWithPageDeserialize( } /** List with Azure.Core.Page<>. */ -export async function listWithPage( +export function listWithPage( context: Client, options: ListWithPageOptions = { requestOptions: {} } -): Promise { - const result = await _listWithPageSend(context, options); - return _listWithPageDeserialize(result); +): PagedAsyncIterableIterator { + return buildPagedAsyncIterator( + context, + () => _listWithPageSend(context, options), + _listWithPageDeserialize, + { itemName: "value", nextLinkName: "nextLink" } + ); } export function _listWithParametersSend( @@ -366,13 +378,17 @@ export async function _listWithParametersDeserialize( } /** List with extensible enum parameter Azure.Core.Page<>. */ -export async function listWithParameters( +export function listWithParameters( context: Client, bodyInput: ListItemInputBody, options: ListWithParametersOptions = { requestOptions: {} } -): Promise { - const result = await _listWithParametersSend(context, bodyInput, options); - return _listWithParametersDeserialize(result); +): PagedAsyncIterableIterator { + return buildPagedAsyncIterator( + context, + () => _listWithParametersSend(context, bodyInput, options), + _listWithParametersDeserialize, + { itemName: "value", nextLinkName: "nextLink" } + ); } export function _listWithCustomPageModelSend( @@ -413,12 +429,16 @@ export async function _listWithCustomPageModelDeserialize( } /** List with custom page model. */ -export async function listWithCustomPageModel( +export function listWithCustomPageModel( context: Client, options: ListWithCustomPageModelOptions = { requestOptions: {} } -): Promise { - const result = await _listWithCustomPageModelSend(context, options); - return _listWithCustomPageModelDeserialize(result); +): PagedAsyncIterableIterator { + return buildPagedAsyncIterator( + context, + () => _listWithCustomPageModelSend(context, options), + _listWithCustomPageModelDeserialize, + { itemName: "items", nextLinkName: "nextLink" } + ); } export function _deleteOperationSend( @@ -524,12 +544,16 @@ export async function _listFirstItemDeserialize( } /** Two operations with two different page item types should be successfully generated. Should generate model for FirstItem. */ -export async function listFirstItem( +export function listFirstItem( context: Client, options: ListFirstItemOptions = { requestOptions: {} } -): Promise { - const result = await _listFirstItemSend(context, options); - return _listFirstItemDeserialize(result); +): PagedAsyncIterableIterator { + return buildPagedAsyncIterator( + context, + () => _listFirstItemSend(context, options), + _listFirstItemDeserialize, + { itemName: "value", nextLinkName: "nextLink" } + ); } export function _listSecondItemSend( @@ -555,10 +579,14 @@ export async function _listSecondItemDeserialize( } /** Two operations with two different page item types should be successfully generated. Should generate model for SecondItem. */ -export async function listSecondItem( +export function listSecondItem( context: Client, options: ListSecondItemOptions = { requestOptions: {} } -): Promise { - const result = await _listSecondItemSend(context, options); - return _listSecondItemDeserialize(result); +): PagedAsyncIterableIterator { + return buildPagedAsyncIterator( + context, + () => _listSecondItemSend(context, options), + _listSecondItemDeserialize, + { itemName: "value", nextLinkName: "nextLink" } + ); } diff --git a/packages/typespec-ts/test/modularIntegration/generated/azure/core/src/api/pagingHelpers.ts b/packages/typespec-ts/test/modularIntegration/generated/azure/core/src/api/pagingHelpers.ts new file mode 100644 index 0000000000..5ef48f6c4a --- /dev/null +++ b/packages/typespec-ts/test/modularIntegration/generated/azure/core/src/api/pagingHelpers.ts @@ -0,0 +1,191 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +import { + Client, + createRestError, + PathUncheckedResponse, +} from "@azure-rest/core-client"; +import { RestError } from "@azure/core-rest-pipeline"; +import { + BuildPagedAsyncIteratorOptions, + ContinuablePage, + PageSettings, + PagedAsyncIterableIterator, + PagedResult, +} from "../models/pagingTypes.js"; +import { isUnexpected } from "../rest/index.js"; + +/** + * Helper to paginate results in a generic way and return a PagedAsyncIterableIterator + */ +export function buildPagedAsyncIterator< + TElement, + TPage = TElement[], + TPageSettings extends PageSettings = PageSettings, + TResponse extends PathUncheckedResponse = PathUncheckedResponse +>( + client: Client, + getInitialResponse: () => PromiseLike, + processResponseBody: (result: TResponse) => PromiseLike, + options: BuildPagedAsyncIteratorOptions = {} +): PagedAsyncIterableIterator { + const itemName = options.itemName ?? "value"; + const nextLinkName = options.nextLinkName ?? "nextLink"; + const pagedResult: PagedResult = { + getPage: async (pageLink?: string) => { + const result = + pageLink === undefined + ? await getInitialResponse() + : await client.pathUnchecked(pageLink).get(); + checkPagingRequest(result); + const results = await processResponseBody(result as TResponse); + const nextLink = getNextLink(results, nextLinkName); + const values = getElements(results, itemName) as TPage; + return { + page: values, + nextPageLink: nextLink, + }; + }, + byPage: (settings?: TPageSettings) => { + const { continuationToken } = settings ?? {}; + return getPageAsyncIterator(pagedResult, { + pageLink: continuationToken, + }); + }, + }; + return getPagedAsyncIterator(pagedResult); +} + +/** + * returns an async iterator that iterates over results. It also has a `byPage` + * method that returns pages of items at once. + * + * @param pagedResult - an object that specifies how to get pages. + * @returns a paged async iterator that iterates over results. + */ + +function getPagedAsyncIterator< + TElement, + TPage = TElement[], + TPageSettings extends PageSettings = PageSettings +>( + pagedResult: PagedResult +): PagedAsyncIterableIterator { + const iter = getItemAsyncIterator( + pagedResult + ); + return { + next() { + return iter.next(); + }, + [Symbol.asyncIterator]() { + return this; + }, + byPage: + pagedResult?.byPage ?? + ((settings?: TPageSettings) => { + const { continuationToken } = settings ?? {}; + return getPageAsyncIterator(pagedResult, { + pageLink: continuationToken, + }); + }), + }; +} + +async function* getItemAsyncIterator< + TElement, + TPage, + TPageSettings extends PageSettings +>( + pagedResult: PagedResult +): AsyncIterableIterator { + const pages = getPageAsyncIterator(pagedResult); + for await (const page of pages) { + yield* page as unknown as TElement[]; + } +} + +async function* getPageAsyncIterator< + TElement, + TPage, + TPageSettings extends PageSettings +>( + pagedResult: PagedResult, + options: { + pageLink?: string; + } = {} +): AsyncIterableIterator> { + const { pageLink } = options; + let response = await pagedResult.getPage( + pageLink ?? pagedResult.firstPageLink + ); + if (!response) { + return; + } + let result = response.page as ContinuablePage; + result.continuationToken = response.nextPageLink; + yield result; + while (response.nextPageLink) { + response = await pagedResult.getPage(response.nextPageLink); + if (!response) { + return; + } + result = response.page as ContinuablePage; + result.continuationToken = response.nextPageLink; + yield result; + } +} + +/** + * Gets for the value of nextLink in the body + */ +function getNextLink(body: unknown, nextLinkName?: string): string | undefined { + if (!nextLinkName) { + return undefined; + } + + const nextLink = (body as Record)[nextLinkName]; + + if ( + typeof nextLink !== "string" && + typeof nextLink !== "undefined" && + nextLink !== null + ) { + throw new RestError( + `Body Property ${nextLinkName} should be a string or undefined or null but got ${typeof nextLink}` + ); + } + + if (nextLink === null) { + return undefined; + } + + return nextLink; +} + +/** + * Gets the elements of the current request in the body. + */ +function getElements(body: unknown, itemName: string): T[] { + const value = (body as Record)[itemName] as T[]; + if (!Array.isArray(value)) { + throw new RestError( + `Couldn't paginate response\n Body doesn't contain an array property with name: ${itemName}` + ); + } + + return value ?? []; +} + +/** + * Checks if a request failed + */ +function checkPagingRequest(response: PathUncheckedResponse): void { + if (isUnexpected(response)) { + throw createRestError( + `Pagination failed with unexpected statusCode ${response.status}`, + response + ); + } +} diff --git a/packages/typespec-ts/test/modularIntegration/generated/azure/core/src/index.ts b/packages/typespec-ts/test/modularIntegration/generated/azure/core/src/index.ts index d87df83d45..b854207261 100644 --- a/packages/typespec-ts/test/modularIntegration/generated/azure/core/src/index.ts +++ b/packages/typespec-ts/test/modularIntegration/generated/azure/core/src/index.ts @@ -24,4 +24,7 @@ export { ExportOperationOptions, ListFirstItemOptions, ListSecondItemOptions, + PageSettings, + ContinuablePage, + PagedAsyncIterableIterator, } from "./models/index.js"; diff --git a/packages/typespec-ts/test/modularIntegration/generated/azure/core/src/models/index.ts b/packages/typespec-ts/test/modularIntegration/generated/azure/core/src/models/index.ts index 8a3be3eff8..f22447d5aa 100644 --- a/packages/typespec-ts/test/modularIntegration/generated/azure/core/src/models/index.ts +++ b/packages/typespec-ts/test/modularIntegration/generated/azure/core/src/models/index.ts @@ -26,3 +26,8 @@ export { ListFirstItemOptions, ListSecondItemOptions, } from "./options.js"; +export { + PageSettings, + ContinuablePage, + PagedAsyncIterableIterator, +} from "./pagingTypes.js"; diff --git a/packages/typespec-ts/test/modularIntegration/generated/azure/core/src/models/pagingTypes.ts b/packages/typespec-ts/test/modularIntegration/generated/azure/core/src/models/pagingTypes.ts new file mode 100644 index 0000000000..5618769059 --- /dev/null +++ b/packages/typespec-ts/test/modularIntegration/generated/azure/core/src/models/pagingTypes.ts @@ -0,0 +1,89 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +/** + * Options for the byPage method + */ +export interface PageSettings { + /** + * A reference to a specific page to start iterating from. + */ + continuationToken?: string; +} + +/** + * An interface that describes a page of results. + */ +export type ContinuablePage = TPage & { + /** + * The token that keeps track of where to continue the iterator + */ + continuationToken?: string; +}; + +/** + * An interface that allows async iterable iteration both to completion and by page. + */ +export interface PagedAsyncIterableIterator< + TElement, + TPage = TElement[], + TPageSettings extends PageSettings = PageSettings +> { + /** + * The next method, part of the iteration protocol + */ + next(): Promise>; + /** + * The connection to the async iterator, part of the iteration protocol + */ + [Symbol.asyncIterator](): PagedAsyncIterableIterator< + TElement, + TPage, + TPageSettings + >; + /** + * Return an AsyncIterableIterator that works a page at a time + */ + byPage: ( + settings?: TPageSettings + ) => AsyncIterableIterator>; +} + +/** + * An interface that describes how to communicate with the service. + */ +export interface PagedResult< + TElement, + TPage = TElement[], + TPageSettings extends PageSettings = PageSettings +> { + /** + * Link to the first page of results. + */ + firstPageLink?: string; + /** + * A method that returns a page of results. + */ + getPage: ( + pageLink?: string + ) => Promise<{ page: TPage; nextPageLink?: string } | undefined>; + /** + * a function to implement the `byPage` method on the paged async iterator. + */ + byPage?: ( + settings?: TPageSettings + ) => AsyncIterableIterator>; + + /** + * A function to extract elements from a page. + */ + toElements?: (page: TPage) => TElement[]; +} + +/** + * Options for the paging helper + */ +export interface BuildPagedAsyncIteratorOptions { + itemName?: string; + nextLinkName?: string; +} diff --git a/packages/typespec-ts/test/modularIntegration/generated/payload/pageable/src/PageableClient.ts b/packages/typespec-ts/test/modularIntegration/generated/payload/pageable/src/PageableClient.ts new file mode 100644 index 0000000000..8ed100b9f7 --- /dev/null +++ b/packages/typespec-ts/test/modularIntegration/generated/payload/pageable/src/PageableClient.ts @@ -0,0 +1,34 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +import { Pipeline } from "@azure/core-rest-pipeline"; +import { User } from "./models/models.js"; +import { ListOptions } from "./models/options.js"; +import { PagedAsyncIterableIterator } from "./models/pagingTypes.js"; +import { + list, + createPageable, + PageableClientOptions, + PageableContext, +} from "./api/index.js"; + +export { PageableClientOptions } from "./api/PageableContext.js"; + +export class PageableClient { + private _client: PageableContext; + /** The pipeline used by this client to make requests */ + public readonly pipeline: Pipeline; + + /** Test describing pageable. */ + constructor(options: PageableClientOptions = {}) { + this._client = createPageable(options); + this.pipeline = this._client.pipeline; + } + + /** List users */ + list( + options: ListOptions = { requestOptions: {} } + ): PagedAsyncIterableIterator { + return list(this._client, options); + } +} diff --git a/packages/typespec-ts/test/modularIntegration/generated/payload/pageable/src/api/PageableContext.ts b/packages/typespec-ts/test/modularIntegration/generated/payload/pageable/src/api/PageableContext.ts new file mode 100644 index 0000000000..83bd9b8fc0 --- /dev/null +++ b/packages/typespec-ts/test/modularIntegration/generated/payload/pageable/src/api/PageableContext.ts @@ -0,0 +1,18 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +import { ClientOptions } from "@azure-rest/core-client"; +import { PageableContext } from "../rest/index.js"; +import getClient from "../rest/index.js"; + +export interface PageableClientOptions extends ClientOptions {} + +export { PageableContext } from "../rest/index.js"; + +/** Test describing pageable. */ +export function createPageable( + options: PageableClientOptions = {} +): PageableContext { + const clientContext = getClient(options); + return clientContext; +} diff --git a/packages/typespec-ts/test/modularIntegration/generated/payload/pageable/src/api/index.ts b/packages/typespec-ts/test/modularIntegration/generated/payload/pageable/src/api/index.ts new file mode 100644 index 0000000000..7c45798210 --- /dev/null +++ b/packages/typespec-ts/test/modularIntegration/generated/payload/pageable/src/api/index.ts @@ -0,0 +1,9 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +export { list } from "./operations.js"; +export { + createPageable, + PageableClientOptions, + PageableContext, +} from "./PageableContext.js"; diff --git a/packages/typespec-ts/test/modularIntegration/generated/payload/pageable/src/api/operations.ts b/packages/typespec-ts/test/modularIntegration/generated/payload/pageable/src/api/operations.ts new file mode 100644 index 0000000000..ae7335d1d7 --- /dev/null +++ b/packages/typespec-ts/test/modularIntegration/generated/payload/pageable/src/api/operations.ts @@ -0,0 +1,50 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +import { PagedUser, User } from "../models/models.js"; +import { PagedAsyncIterableIterator } from "../models/pagingTypes.js"; +import { buildPagedAsyncIterator } from "./pagingHelpers.js"; +import { List200Response, PageableContext as Client } from "../rest/index.js"; +import { + StreamableMethod, + operationOptionsToRequestParameters, +} from "@azure-rest/core-client"; +import { ListOptions } from "../models/options.js"; + +export function _listSend( + context: Client, + options: ListOptions = { requestOptions: {} } +): StreamableMethod { + return context + .path("/payload/pageable") + .get({ + ...operationOptionsToRequestParameters(options), + queryParameters: { maxpagesize: options?.maxpagesize }, + }); +} + +export async function _listDeserialize( + result: List200Response +): Promise { + if (result.status !== "200") { + throw result.body; + } + + return { + value: result.body["value"].map((p) => ({ name: p["name"] })), + nextLink: result.body["nextLink"], + }; +} + +/** List users */ +export function list( + context: Client, + options: ListOptions = { requestOptions: {} } +): PagedAsyncIterableIterator { + return buildPagedAsyncIterator( + context, + () => _listSend(context, options), + _listDeserialize, + { itemName: "value", nextLinkName: "nextLink" } + ); +} diff --git a/packages/typespec-ts/test/modularIntegration/generated/payload/pageable/src/api/pagingHelpers.ts b/packages/typespec-ts/test/modularIntegration/generated/payload/pageable/src/api/pagingHelpers.ts new file mode 100644 index 0000000000..d9253c521d --- /dev/null +++ b/packages/typespec-ts/test/modularIntegration/generated/payload/pageable/src/api/pagingHelpers.ts @@ -0,0 +1,202 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +import { + Client, + createRestError, + PathUncheckedResponse, +} from "@azure-rest/core-client"; +import { RestError } from "@azure/core-rest-pipeline"; +import { + BuildPagedAsyncIteratorOptions, + ContinuablePage, + PageSettings, + PagedAsyncIterableIterator, + PagedResult, +} from "../models/pagingTypes.js"; + +/** + * Helper to paginate results in a generic way and return a PagedAsyncIterableIterator + */ +export function buildPagedAsyncIterator< + TElement, + TPage = TElement[], + TPageSettings extends PageSettings = PageSettings, + TResponse extends PathUncheckedResponse = PathUncheckedResponse +>( + client: Client, + getInitialResponse: () => PromiseLike, + processResponseBody: (result: TResponse) => PromiseLike, + options: BuildPagedAsyncIteratorOptions = {} +): PagedAsyncIterableIterator { + const itemName = options.itemName ?? "value"; + const nextLinkName = options.nextLinkName ?? "nextLink"; + const pagedResult: PagedResult = { + getPage: async (pageLink?: string) => { + const result = + pageLink === undefined + ? await getInitialResponse() + : await client.pathUnchecked(pageLink).get(); + checkPagingRequest(result); + const results = await processResponseBody(result as TResponse); + const nextLink = getNextLink(results, nextLinkName); + const values = getElements(results, itemName) as TPage; + return { + page: values, + nextPageLink: nextLink, + }; + }, + byPage: (settings?: TPageSettings) => { + const { continuationToken } = settings ?? {}; + return getPageAsyncIterator(pagedResult, { + pageLink: continuationToken, + }); + }, + }; + return getPagedAsyncIterator(pagedResult); +} + +/** + * returns an async iterator that iterates over results. It also has a `byPage` + * method that returns pages of items at once. + * + * @param pagedResult - an object that specifies how to get pages. + * @returns a paged async iterator that iterates over results. + */ + +function getPagedAsyncIterator< + TElement, + TPage = TElement[], + TPageSettings extends PageSettings = PageSettings +>( + pagedResult: PagedResult +): PagedAsyncIterableIterator { + const iter = getItemAsyncIterator( + pagedResult + ); + return { + next() { + return iter.next(); + }, + [Symbol.asyncIterator]() { + return this; + }, + byPage: + pagedResult?.byPage ?? + ((settings?: TPageSettings) => { + const { continuationToken } = settings ?? {}; + return getPageAsyncIterator(pagedResult, { + pageLink: continuationToken, + }); + }), + }; +} + +async function* getItemAsyncIterator< + TElement, + TPage, + TPageSettings extends PageSettings +>( + pagedResult: PagedResult +): AsyncIterableIterator { + const pages = getPageAsyncIterator(pagedResult); + for await (const page of pages) { + yield* page as unknown as TElement[]; + } +} + +async function* getPageAsyncIterator< + TElement, + TPage, + TPageSettings extends PageSettings +>( + pagedResult: PagedResult, + options: { + pageLink?: string; + } = {} +): AsyncIterableIterator> { + const { pageLink } = options; + let response = await pagedResult.getPage( + pageLink ?? pagedResult.firstPageLink + ); + if (!response) { + return; + } + let result = response.page as ContinuablePage; + result.continuationToken = response.nextPageLink; + yield result; + while (response.nextPageLink) { + response = await pagedResult.getPage(response.nextPageLink); + if (!response) { + return; + } + result = response.page as ContinuablePage; + result.continuationToken = response.nextPageLink; + yield result; + } +} + +/** + * Gets for the value of nextLink in the body + */ +function getNextLink(body: unknown, nextLinkName?: string): string | undefined { + if (!nextLinkName) { + return undefined; + } + + const nextLink = (body as Record)[nextLinkName]; + + if ( + typeof nextLink !== "string" && + typeof nextLink !== "undefined" && + nextLink !== null + ) { + throw new RestError( + `Body Property ${nextLinkName} should be a string or undefined or null but got ${typeof nextLink}` + ); + } + + if (nextLink === null) { + return undefined; + } + + return nextLink; +} + +/** + * Gets the elements of the current request in the body. + */ +function getElements(body: unknown, itemName: string): T[] { + const value = (body as Record)[itemName] as T[]; + if (!Array.isArray(value)) { + throw new RestError( + `Couldn't paginate response\n Body doesn't contain an array property with name: ${itemName}` + ); + } + + return value ?? []; +} + +/** + * Checks if a request failed + */ +function checkPagingRequest(response: PathUncheckedResponse): void { + const Http2xxStatusCodes = [ + "200", + "201", + "202", + "203", + "204", + "205", + "206", + "207", + "208", + "226", + ]; + if (!Http2xxStatusCodes.includes(response.status)) { + throw createRestError( + `Pagination failed with unexpected statusCode ${response.status}`, + response + ); + } +} diff --git a/packages/typespec-ts/test/modularIntegration/generated/payload/pageable/src/index.ts b/packages/typespec-ts/test/modularIntegration/generated/payload/pageable/src/index.ts new file mode 100644 index 0000000000..9107e55f0e --- /dev/null +++ b/packages/typespec-ts/test/modularIntegration/generated/payload/pageable/src/index.ts @@ -0,0 +1,12 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +export { PageableClient, PageableClientOptions } from "./PageableClient.js"; +export { + PagedUser, + User, + ListOptions, + PageSettings, + ContinuablePage, + PagedAsyncIterableIterator, +} from "./models/index.js"; diff --git a/packages/typespec-ts/test/modularIntegration/generated/payload/pageable/src/logger.ts b/packages/typespec-ts/test/modularIntegration/generated/payload/pageable/src/logger.ts new file mode 100644 index 0000000000..174a3a425d --- /dev/null +++ b/packages/typespec-ts/test/modularIntegration/generated/payload/pageable/src/logger.ts @@ -0,0 +1,5 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +import { createClientLogger } from "@azure/logger"; +export const logger = createClientLogger("payload-pageable"); diff --git a/packages/typespec-ts/test/modularIntegration/generated/payload/pageable/src/models/index.ts b/packages/typespec-ts/test/modularIntegration/generated/payload/pageable/src/models/index.ts new file mode 100644 index 0000000000..5bffc4d5ff --- /dev/null +++ b/packages/typespec-ts/test/modularIntegration/generated/payload/pageable/src/models/index.ts @@ -0,0 +1,10 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +export { PagedUser, User } from "./models.js"; +export { ListOptions } from "./options.js"; +export { + PageSettings, + ContinuablePage, + PagedAsyncIterableIterator, +} from "./pagingTypes.js"; diff --git a/packages/typespec-ts/test/modularIntegration/generated/payload/pageable/src/models/models.ts b/packages/typespec-ts/test/modularIntegration/generated/payload/pageable/src/models/models.ts new file mode 100644 index 0000000000..3daeac79db --- /dev/null +++ b/packages/typespec-ts/test/modularIntegration/generated/payload/pageable/src/models/models.ts @@ -0,0 +1,16 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +/** Paged collection of User items */ +export interface PagedUser { + /** The User items on this page */ + value: User[]; + /** The link to the next page of items */ + nextLink?: string; +} + +/** User model */ +export interface User { + /** User name */ + name: string; +} diff --git a/packages/typespec-ts/test/modularIntegration/generated/payload/pageable/src/models/options.ts b/packages/typespec-ts/test/modularIntegration/generated/payload/pageable/src/models/options.ts new file mode 100644 index 0000000000..e644e0ef53 --- /dev/null +++ b/packages/typespec-ts/test/modularIntegration/generated/payload/pageable/src/models/options.ts @@ -0,0 +1,9 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +import { OperationOptions } from "@azure-rest/core-client"; + +export interface ListOptions extends OperationOptions { + /** The maximum number of result items per page. */ + maxpagesize?: number; +} diff --git a/packages/typespec-ts/test/modularIntegration/generated/payload/pageable/src/models/pagingTypes.ts b/packages/typespec-ts/test/modularIntegration/generated/payload/pageable/src/models/pagingTypes.ts new file mode 100644 index 0000000000..5618769059 --- /dev/null +++ b/packages/typespec-ts/test/modularIntegration/generated/payload/pageable/src/models/pagingTypes.ts @@ -0,0 +1,89 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +/** + * Options for the byPage method + */ +export interface PageSettings { + /** + * A reference to a specific page to start iterating from. + */ + continuationToken?: string; +} + +/** + * An interface that describes a page of results. + */ +export type ContinuablePage = TPage & { + /** + * The token that keeps track of where to continue the iterator + */ + continuationToken?: string; +}; + +/** + * An interface that allows async iterable iteration both to completion and by page. + */ +export interface PagedAsyncIterableIterator< + TElement, + TPage = TElement[], + TPageSettings extends PageSettings = PageSettings +> { + /** + * The next method, part of the iteration protocol + */ + next(): Promise>; + /** + * The connection to the async iterator, part of the iteration protocol + */ + [Symbol.asyncIterator](): PagedAsyncIterableIterator< + TElement, + TPage, + TPageSettings + >; + /** + * Return an AsyncIterableIterator that works a page at a time + */ + byPage: ( + settings?: TPageSettings + ) => AsyncIterableIterator>; +} + +/** + * An interface that describes how to communicate with the service. + */ +export interface PagedResult< + TElement, + TPage = TElement[], + TPageSettings extends PageSettings = PageSettings +> { + /** + * Link to the first page of results. + */ + firstPageLink?: string; + /** + * A method that returns a page of results. + */ + getPage: ( + pageLink?: string + ) => Promise<{ page: TPage; nextPageLink?: string } | undefined>; + /** + * a function to implement the `byPage` method on the paged async iterator. + */ + byPage?: ( + settings?: TPageSettings + ) => AsyncIterableIterator>; + + /** + * A function to extract elements from a page. + */ + toElements?: (page: TPage) => TElement[]; +} + +/** + * Options for the paging helper + */ +export interface BuildPagedAsyncIteratorOptions { + itemName?: string; + nextLinkName?: string; +} diff --git a/packages/typespec-ts/test/modularIntegration/generated/payload/pageable/src/rest/clientDefinitions.ts b/packages/typespec-ts/test/modularIntegration/generated/payload/pageable/src/rest/clientDefinitions.ts new file mode 100644 index 0000000000..0fad321cba --- /dev/null +++ b/packages/typespec-ts/test/modularIntegration/generated/payload/pageable/src/rest/clientDefinitions.ts @@ -0,0 +1,20 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +import { ListParameters } from "./parameters.js"; +import { List200Response } from "./responses.js"; +import { Client, StreamableMethod } from "@azure-rest/core-client"; + +export interface List { + /** List users */ + get(options?: ListParameters): StreamableMethod; +} + +export interface Routes { + /** Resource for '/payload/pageable' has methods for the following verbs: get */ + (path: "/payload/pageable"): List; +} + +export type PageableContext = Client & { + path: Routes; +}; diff --git a/packages/typespec-ts/test/modularIntegration/generated/payload/pageable/src/rest/index.ts b/packages/typespec-ts/test/modularIntegration/generated/payload/pageable/src/rest/index.ts new file mode 100644 index 0000000000..5a40467423 --- /dev/null +++ b/packages/typespec-ts/test/modularIntegration/generated/payload/pageable/src/rest/index.ts @@ -0,0 +1,13 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +import PageableClient from "./pageableClient.js"; + +export * from "./pageableClient.js"; +export * from "./parameters.js"; +export * from "./responses.js"; +export * from "./clientDefinitions.js"; +export * from "./outputModels.js"; +export * from "./paginateHelper.js"; + +export default PageableClient; diff --git a/packages/typespec-ts/test/modularIntegration/generated/payload/pageable/src/rest/outputModels.ts b/packages/typespec-ts/test/modularIntegration/generated/payload/pageable/src/rest/outputModels.ts new file mode 100644 index 0000000000..606feaf02f --- /dev/null +++ b/packages/typespec-ts/test/modularIntegration/generated/payload/pageable/src/rest/outputModels.ts @@ -0,0 +1,13 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +import { Paged } from "@azure/core-paging"; + +/** User model */ +export interface UserOutput { + /** User name */ + name: string; +} + +/** Paged collection of User items */ +export type PagedUserOutput = Paged; diff --git a/packages/typespec-ts/test/modularIntegration/generated/payload/pageable/src/rest/pageableClient.ts b/packages/typespec-ts/test/modularIntegration/generated/payload/pageable/src/rest/pageableClient.ts new file mode 100644 index 0000000000..a5b47a1d7a --- /dev/null +++ b/packages/typespec-ts/test/modularIntegration/generated/payload/pageable/src/rest/pageableClient.ts @@ -0,0 +1,35 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +import { getClient, ClientOptions } from "@azure-rest/core-client"; +import { logger } from "../logger.js"; +import { PageableContext } from "./clientDefinitions.js"; + +/** + * Initialize a new instance of `PageableContext` + * @param options - the parameter for all optional parameters + */ +export default function createClient( + options: ClientOptions = {} +): PageableContext { + const baseUrl = options.baseUrl ?? `http://localhost:3000`; + options.apiVersion = options.apiVersion ?? "1.0.0"; + const userAgentInfo = `azsdk-js-payload-pageable-rest/1.0.0-beta.1`; + const userAgentPrefix = + options.userAgentOptions && options.userAgentOptions.userAgentPrefix + ? `${options.userAgentOptions.userAgentPrefix} ${userAgentInfo}` + : `${userAgentInfo}`; + options = { + ...options, + userAgentOptions: { + userAgentPrefix, + }, + loggingOptions: { + logger: options.loggingOptions?.logger ?? logger.info, + }, + }; + + const client = getClient(baseUrl, options) as PageableContext; + + return client; +} diff --git a/packages/typespec-ts/test/modularIntegration/generated/payload/pageable/src/rest/paginateHelper.ts b/packages/typespec-ts/test/modularIntegration/generated/payload/pageable/src/rest/paginateHelper.ts new file mode 100644 index 0000000000..1c9af35b1e --- /dev/null +++ b/packages/typespec-ts/test/modularIntegration/generated/payload/pageable/src/rest/paginateHelper.ts @@ -0,0 +1,154 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +import { + getPagedAsyncIterator, + PagedAsyncIterableIterator, + PagedResult, +} from "@azure/core-paging"; +import { + Client, + createRestError, + PathUncheckedResponse, +} from "@azure-rest/core-client"; + +/** + * Helper type to extract the type of an array + */ +export type GetArrayType = T extends Array ? TData : never; + +/** + * The type of a custom function that defines how to get a page and a link to the next one if any. + */ +export type GetPage = ( + pageLink: string, + maxPageSize?: number +) => Promise<{ + page: TPage; + nextPageLink?: string; +}>; + +/** + * Options for the paging helper + */ +export interface PagingOptions { + /** + * Custom function to extract pagination details for crating the PagedAsyncIterableIterator + */ + customGetPage?: GetPage[]>; +} + +/** + * Helper type to infer the Type of the paged elements from the response type + * This type is generated based on the swagger information for x-ms-pageable + * specifically on the itemName property which indicates the property of the response + * where the page items are found. The default value is `value`. + * This type will allow us to provide strongly typed Iterator based on the response we get as second parameter + */ +export type PaginateReturn = TResult extends { + body: { value?: infer TPage }; +} + ? GetArrayType + : Array; + +/** + * Helper to paginate results from an initial response that follows the specification of Autorest `x-ms-pageable` extension + * @param client - Client to use for sending the next page requests + * @param initialResponse - Initial response containing the nextLink and current page of elements + * @param customGetPage - Optional - Function to define how to extract the page and next link to be used to paginate the results + * @returns - PagedAsyncIterableIterator to iterate the elements + */ +export function paginate( + client: Client, + initialResponse: TResponse, + options: PagingOptions = {} +): PagedAsyncIterableIterator> { + // Extract element type from initial response + type TElement = PaginateReturn; + let firstRun = true; + const itemName = "value"; + const nextLinkName = "nextLink"; + const { customGetPage } = options; + const pagedResult: PagedResult = { + firstPageLink: "", + getPage: + typeof customGetPage === "function" + ? customGetPage + : async (pageLink: string) => { + const result = firstRun + ? initialResponse + : await client.pathUnchecked(pageLink).get(); + firstRun = false; + checkPagingRequest(result); + const nextLink = getNextLink(result.body, nextLinkName); + const values = getElements(result.body, itemName); + return { + page: values, + nextPageLink: nextLink, + }; + }, + }; + + return getPagedAsyncIterator(pagedResult); +} + +/** + * Gets for the value of nextLink in the body + */ +function getNextLink(body: unknown, nextLinkName?: string): string | undefined { + if (!nextLinkName) { + return undefined; + } + + const nextLink = (body as Record)[nextLinkName]; + + if (typeof nextLink !== "string" && typeof nextLink !== "undefined") { + throw new Error( + `Body Property ${nextLinkName} should be a string or undefined` + ); + } + + return nextLink; +} + +/** + * Gets the elements of the current request in the body. + */ +function getElements(body: unknown, itemName: string): T[] { + const value = (body as Record)[itemName] as T[]; + + // value has to be an array according to the x-ms-pageable extension. + // The fact that this must be an array is used above to calculate the + // type of elements in the page in PaginateReturn + if (!Array.isArray(value)) { + throw new Error( + `Couldn't paginate response\n Body doesn't contain an array property with name: ${itemName}` + ); + } + + return value ?? []; +} + +/** + * Checks if a request failed + */ +function checkPagingRequest(response: PathUncheckedResponse): void { + const Http2xxStatusCodes = [ + "200", + "201", + "202", + "203", + "204", + "205", + "206", + "207", + "208", + "226", + ]; + if (!Http2xxStatusCodes.includes(response.status)) { + throw createRestError( + `Pagination failed with unexpected statusCode ${response.status}`, + response + ); + } +} diff --git a/packages/typespec-ts/test/modularIntegration/generated/payload/pageable/src/rest/parameters.ts b/packages/typespec-ts/test/modularIntegration/generated/payload/pageable/src/rest/parameters.ts new file mode 100644 index 0000000000..612f76120d --- /dev/null +++ b/packages/typespec-ts/test/modularIntegration/generated/payload/pageable/src/rest/parameters.ts @@ -0,0 +1,15 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +import { RequestParameters } from "@azure-rest/core-client"; + +export interface ListQueryParamProperties { + /** The maximum number of result items per page. */ + maxpagesize?: number; +} + +export interface ListQueryParam { + queryParameters?: ListQueryParamProperties; +} + +export type ListParameters = ListQueryParam & RequestParameters; diff --git a/packages/typespec-ts/test/modularIntegration/generated/payload/pageable/src/rest/responses.ts b/packages/typespec-ts/test/modularIntegration/generated/payload/pageable/src/rest/responses.ts new file mode 100644 index 0000000000..d615eae74b --- /dev/null +++ b/packages/typespec-ts/test/modularIntegration/generated/payload/pageable/src/rest/responses.ts @@ -0,0 +1,11 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +import { HttpResponse } from "@azure-rest/core-client"; +import { PagedUserOutput } from "./outputModels.js"; + +/** The request has succeeded. */ +export interface List200Response extends HttpResponse { + status: "200"; + body: PagedUserOutput; +} diff --git a/packages/typespec-ts/test/modularIntegration/generated/payload/pageable/tspconfig.yaml b/packages/typespec-ts/test/modularIntegration/generated/payload/pageable/tspconfig.yaml new file mode 100644 index 0000000000..c11fdc1769 --- /dev/null +++ b/packages/typespec-ts/test/modularIntegration/generated/payload/pageable/tspconfig.yaml @@ -0,0 +1,14 @@ +emit: + - "@azure-tools/typespec-ts" +options: + "@azure-tools/typespec-ts": + "emitter-output-dir": "{project-root}" + generateMetadata: false + generateTest: false + addCredentials: false + azureSdkForJs: false + isTypeSpecTest: true + isModularLibrary: true + packageDetails: + name: "@msinternal/payload-pageable" + description: "Payload Pageable Test Service" diff --git a/packages/typespec-ts/test/modularIntegration/payloadPageable.spec.ts b/packages/typespec-ts/test/modularIntegration/payloadPageable.spec.ts new file mode 100644 index 0000000000..db00742160 --- /dev/null +++ b/packages/typespec-ts/test/modularIntegration/payloadPageable.spec.ts @@ -0,0 +1,95 @@ +import { + PageableClient, + User +} from "./generated/payload/pageable/src/index.js"; +import { assert } from "chai"; + +describe("PageableClient Classical Client", () => { + let client: PageableClient; + + beforeEach(() => { + client = new PageableClient({ + allowInsecureConnection: true + }); + }); + + it("should throw exceptions if no maxpagesize set", async () => { + const iter = client.list(); + const items = []; + try { + for await (const user of iter) { + items.push(user); + } + assert.fail("Should throw exception"); + } catch (err: any) { + assert.isNotNull(err); + assert.strictEqual( + err.message, + "Pagination failed with unexpected statusCode 400" + ); + } + }); + + it("should list all users if maxpagesize=3", async () => { + const iter = client.list({ + maxpagesize: 3 + }); + const items = []; + try { + for await (const user of iter) { + items.push(user); + } + assert.strictEqual(items.length, 4); + } catch (err: any) { + assert.fail(err as string); + } + }); + + it("should list all users byPage", async () => { + const iter = client.list({ + maxpagesize: 3 + }); + const items: User[] = []; + try { + for await (const user of iter.byPage()) { + items.push(...user); + } + assert.strictEqual(items.length, 4); + } catch (err: any) { + assert.fail(err as string); + } + }); + + it("should list left users byPage if continuationToken is set", async () => { + const iter = client.list({ + maxpagesize: 3 + }); + /** + * two pages: + * - 1st page has 3 items + * - 2nd page has 1 item + */ + const firstPage = await iter.byPage().next(); + assert.strictEqual(firstPage.done, false); + assert.strictEqual(firstPage.value.length, 3); + // initiate another iterator starting with 2nd page + const continuationToken = firstPage.value.continuationToken; + assert.strictEqual( + continuationToken, + "http://localhost:3000/payload/pageable?skipToken=name-user7&maxpagesize=3" + ); + const items: User[] = []; + for await (const pagedUsers of iter.byPage({ continuationToken })) { + items.push(...pagedUsers); + } + assert.strictEqual(items.length, 1); + }); + + it("maxPageSize param should be ignored", async () => { + const pagedIter = client + .list({ maxpagesize: 3 }) + .byPage({ maxPageSize: 10 } as any); + const items: User[] = (await pagedIter.next()).value; + assert.strictEqual(items.length, 3); + }); +}); diff --git a/packages/typespec-ts/test/modularUnit/operations.spec.ts b/packages/typespec-ts/test/modularUnit/operations.spec.ts index 4abcf79d1c..32b351e0ef 100644 --- a/packages/typespec-ts/test/modularUnit/operations.spec.ts +++ b/packages/typespec-ts/test/modularUnit/operations.spec.ts @@ -522,4 +522,130 @@ describe("operations", () => { ); }); }); + + describe("paging operations", () => { + it("should generate paging if @items defined", async () => { + const tspContent = ` + @error + model Error { + code: int32; + message: string; + } + + @pagedResult + model Bar { + @items + lists: string[]; + } + @post + op test(): Error | Bar; + `; + const operationFiles = await emitModularOperationsFromTypeSpec( + tspContent, + true, + true, + true + ); + assert.ok(operationFiles); + + assert.equal(operationFiles?.length, 1); + // console.log(operationFiles?.[0]?.getFullText()!); + assertEqualContent( + operationFiles?.[0]?.getFullText()!, + ` + import { TestingContext as Client } from "../rest/index.js"; + import { StreamableMethod, operationOptionsToRequestParameters } from "@azure-rest/core-client"; + + export function _testSend(context: Client, options: TestOptions = { requestOptions: {} }): StreamableMethod { + return context.path("/", ).post({...operationOptionsToRequestParameters(options), }) ; + } + + export async function _testDeserialize(result: Test200Response | TestDefaultResponse): Promise { + if(result.status !== "200"){ + throw result.body + } + + return { + "lists": result.body["lists"] + } + } + + export function test(context: Client, options: TestOptions = { requestOptions: {} }): PagedAsyncIterableIterator { + return buildPagedAsyncIterator( + context, + () => _testSend(context, options), + _testDeserialize, + {itemName: "lists"} + ); + }`, + true + ); + }); + + it("should generate paging if no @items defined", async () => { + const tspContent = ` + @error + model Error { + code: int32; + message: string; + } + + @pagedResult + model Bar { + lists: string[]; + } + @post + op test(): Error | Bar; + `; + + try { + await emitModularOperationsFromTypeSpec(tspContent, true, true, true); + assert.fail("Should throw diagnostic warnings"); + } catch (e) { + const diagnostics = e as Diagnostic[]; + // console.log(diagnostics); + assert.equal(diagnostics.length, 1); + assert.equal( + diagnostics[0]?.code, + "@azure-tools/typespec-ts/no-paging-items-defined" + ); + assert.equal(diagnostics[0]?.severity, "warning"); + } + const operationFiles = await emitModularOperationsFromTypeSpec( + tspContent, + false, + true, + true + ); + assert.ok(operationFiles); + assert.equal(operationFiles?.length, 1); + // console.log(operationFiles?.[0]?.getFullText()!); + assertEqualContent( + operationFiles?.[0]?.getFullText()!, + ` + import { TestingContext as Client } from "../rest/index.js"; + import { StreamableMethod, operationOptionsToRequestParameters } from "@azure-rest/core-client"; + + export function _testSend(context: Client, options: TestOptions = { requestOptions: {} }): StreamableMethod { + return context.path("/", ).post({...operationOptionsToRequestParameters(options), }) ; + } + + export async function _testDeserialize(result: Test200Response | TestDefaultResponse): Promise { + if(result.status !== "200"){ + throw result.body + } + + return { + "lists": result.body["lists"] + } + } + + export async function test(context: Client, options: TestOptions = { requestOptions: {} }): Promise { + const result = await _testSend(context, options); + return _testDeserialize(result); + }`, + true + ); + }); + }); }); diff --git a/packages/typespec-ts/test/util/emitUtil.ts b/packages/typespec-ts/test/util/emitUtil.ts index eeda96c5b5..ebe66e5d42 100644 --- a/packages/typespec-ts/test/util/emitUtil.ts +++ b/packages/typespec-ts/test/util/emitUtil.ts @@ -315,9 +315,15 @@ export async function emitModularModelsFromTypeSpec( export async function emitModularOperationsFromTypeSpec( tspContent: string, - mustEmptyDiagnostic = true + mustEmptyDiagnostic = true, + needNamespaces: boolean = true, + needAzureCore: boolean = false ) { - const context = await rlcEmitterFor(tspContent); + const context = await rlcEmitterFor( + tspContent, + needNamespaces, + needAzureCore + ); const dpgContext = createDpgContextTestHelper(context.program); const serviceNameToRlcModelsMap: Map = new Map< string,