Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
changeKind: feature
packages:
- "@azure-tools/typespec-go"
---

Support paging with a relative nextLink. Pagers now resolve a next link that's relative to the client endpoint before fetching the next page; absolute next links are unchanged.
77 changes: 73 additions & 4 deletions packages/typespec-go/src/codegen/core/operations.ts
Original file line number Diff line number Diff line change
Expand Up @@ -179,9 +179,9 @@ export function generateOperations(
// it must be done before the imports are written out
if (go.isLROMethod(method)) {
// generate Begin method
opText += generateLROBeginMethod(method, options, imports, indent);
opText += generateLROBeginMethod(method, options, imports, indent, azureARM);
}
opText += generateOperation(method, options, imports, indent);
opText += generateOperation(method, options, imports, indent, azureARM);
opText += createProtocolRequest(azureARM, method, imports, indent);
if (method.kind !== "lroMethod") {
// LRO responses are handled elsewhere, with the exception of pageable LROs
Expand Down Expand Up @@ -691,20 +691,76 @@ function generateNilChecks(
return checks.join(" && ");
}

/**
* returns the expression used to obtain the client's service endpoint, or undefined
* when the endpoint can't be determined from the client alone.
*
* @param client the client for which to obtain the endpoint expression
* @param azureARM indicates if the client is an ARM client
* @returns the endpoint expression or undefined
*/
function getClientEndpointExpr(client: go.Client, azureARM: boolean): string | undefined {
if (azureARM) {
// for ARM, the endpoint is handled via the azcore/arm.Client
return "client.internal.Endpoint()";
}
if (client.instance?.kind === "templatedHost") {
// NOTE: this is an Autorest-only compat case. the host is assembled per
// method as it can require method params, so there's no client endpoint.
return undefined;
}
const hostParams = client.parameters.filter((param) => param.kind === "uriParam");
if (hostParams.length === 1) {
return `client.${hostParams[0].name}`;
}
return undefined;
}

/**
* emits code that resolves a relative next link against the client's service endpoint.
* services are allowed to return a next link that's relative to the endpoint (e.g.
* "/foo/page/2"), however runtime.FetcherForNextLink requires an absolute URL. absolute
* next links are passed through unmodified.
*
* @param nextLinkVar the name of the local var containing the next link
* @param endpointExpr the expression used to obtain the client's service endpoint
* @param imports the import manager currently in scope
* @param indent the indentation helper currently in scope
* @returns the code to resolve a relative next link
*/
function emitRelativeNextLinkResolution(
nextLinkVar: string,
endpointExpr: string,
imports: ImportManager,
indent: helpers.Indentation,
): string {
imports.add("net/url");
let text = `${indent.get()}// the service can return a next link that's relative to the endpoint, however\n`;
text += `${indent.get()}// runtime.FetcherForNextLink requires an absolute URL, so resolve it here.\n`;
text += `${indent.get()}if ${nextLinkVar} != "" {\n`;
text += `${indent.push().get()}if u, err := url.Parse(${nextLinkVar}); err == nil && !u.IsAbs() {\n`;
text += `${indent.push().get()}${nextLinkVar} = runtime.JoinPaths(${endpointExpr}, ${nextLinkVar})\n`;
text += `${indent.pop().get()}}\n`;
text += `${indent.pop().get()}}\n`;
return text;
}

/**
* emits code that calls runtime.NewPager
*
* @param method the pageable method
* @param options emitter options
* @param imports the import manager currently in scope
* @param indent the indentation helper currently in scope
* @param azureARM indicates if the client is an ARM client
* @returns the complete call to runtime.NewPager(...)
*/
function emitPagerDefinition(
method: go.LROPageableMethod | go.PageableMethod,
options: go.Options,
imports: ImportManager,
indent: helpers.Indentation,
azureARM: boolean,
): string {
imports.add("context");
let text = `runtime.NewPager(runtime.PagingHandler[${method.returns.name}]{\n`;
Expand Down Expand Up @@ -806,16 +862,27 @@ function emitPagerDefinition(
}
case "nextLink": {
const nextLinkPath = helpers.buildNextLinkPath(method.strategy);
// a custom next page operation builds the request itself, so the next
// link must be passed through to it unmodified.
const endpointExpr = method.strategy.method
? undefined
: getClientEndpointExpr(method.receiver.type, azureARM);
let nextLinkVar: string;
if (method.kind === "pageableMethod") {
text += `${indent.get()}nextLink := ""\n`;
nextLinkVar = "nextLink";
text += `${indent.get()}if page != nil {\n`;
text += `${indent.push().get()}nextLink = *page.${nextLinkPath}\n`;
text += `${indent.pop().get()}}\n`;
} else if (endpointExpr) {
text += `${indent.get()}nextLink := *page.${nextLinkPath}\n`;
nextLinkVar = "nextLink";
} else {
nextLinkVar = `*page.${nextLinkPath}`;
}
if (endpointExpr) {
text += emitRelativeNextLinkResolution(nextLinkVar, endpointExpr, imports, indent);
}
text += `${indent.get()}resp, err := runtime.FetcherForNextLink(ctx, client.internal.Pipeline(), ${nextLinkVar}, func(ctx context.Context) (*policy.Request, error) {\n`;
text += `${indent.push().get()}return client.${method.naming.requestMethod}(${reqParams})\n`;
text += `${indent.pop().get()}}, `;
Expand Down Expand Up @@ -937,6 +1004,7 @@ function generateOperation(
options: go.Options,
imports: ImportManager,
indent: helpers.Indentation,
azureARM: boolean,
): string {
const params = getAPIParametersSig(method, imports);
const returns = generateReturnsInfo(method, "op");
Expand Down Expand Up @@ -965,7 +1033,7 @@ function generateOperation(
text += `func ${getClientReceiverDefinition(method.receiver)} ${methodName}(${params}) (${returns.join(", ")}) {\n`;
if (method.kind === "pageableMethod") {
text += `${indent.get()}return `;
text += emitPagerDefinition(method, options, imports, indent);
text += emitPagerDefinition(method, options, imports, indent, azureARM);
text += "}\n\n";
return text;
}
Expand Down Expand Up @@ -2122,6 +2190,7 @@ function generateLROBeginMethod(
options: go.Options,
imports: ImportManager,
indent: helpers.Indentation,
azureARM: boolean,
): string {
const params = getAPIParametersSig(method, imports);
const returns = generateReturnsInfo(method, "api");
Expand All @@ -2144,7 +2213,7 @@ function generateLROBeginMethod(
pollerTypeParam = `[*runtime.Pager${pollerTypeParam}]`;
pollerType = "&pager";
text += `${indent.get()}pager := `;
text += emitPagerDefinition(method, options, imports, indent);
text += emitPagerDefinition(method, options, imports, indent, azureARM);
}

text += `${indent.get()}if options == nil || options.ResumeToken == "" {\n`;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -110,8 +110,7 @@ func TestPageClient_NewListWithParametersPager(t *testing.T) {
require.EqualValues(t, 2, pages)
}*/

// TODO: runtime.FetcherForNextLink doesn't support relative next link URLs
/*func TestPageClient_NewWithRelativeNextLinkPager(t *testing.T) {
func TestPageClient_NewWithRelativeNextLinkPager(t *testing.T) {
client, err := azurepagegroup.NewPageClientWithNoCredential("http://localhost:3000", nil)
require.NoError(t, err)
pager := client.NewWithRelativeNextLinkPager(nil)
Expand Down Expand Up @@ -142,4 +141,4 @@ func TestPageClient_NewListWithParametersPager(t *testing.T) {
}
}
require.EqualValues(t, 2, pages)
}*/
}
10 changes: 9 additions & 1 deletion packages/typespec-go/test/unittest/scenarios/pageable-lro.md
Original file line number Diff line number Diff line change
Expand Up @@ -102,7 +102,15 @@ func (client *PageableLROsClient) BeginListPrivateEndPoints(ctx context.Context,
return page.NextLink != nil && len(*page.NextLink) > 0
},
Fetcher: func(ctx context.Context, page *PageableLROsClientListPrivateEndPointsResponse) (PageableLROsClientListPrivateEndPointsResponse, error) {
resp, err := runtime.FetcherForNextLink(ctx, client.internal.Pipeline(), *page.NextLink, func(ctx context.Context) (*policy.Request, error) {
nextLink := *page.NextLink
// the service can return a next link that's relative to the endpoint, however

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We shouldn't be emitting comments.

// runtime.FetcherForNextLink requires an absolute URL, so resolve it here.
if nextLink != "" {
if u, err := url.Parse(nextLink); err == nil && !u.IsAbs() {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

A few problems here.

  • err is discarded when not nil
  • we parse just to check IsAbs() then throw that away
    • this happens for all paths and IIRC relative paths are rare

Do we know at codegen time if the path is relative?

@tadelesh tadelesh Jul 29, 2026

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No, we could not know that. It's kind of a runtime behavior. I remembered that you said we already support it in swagger cases. But I could not find the related code. Current implementation will change all the generated code, which I do not prefer. Any suggestions?

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The swagger case was different than I remembered :(. e.g. here. It uses a swagger-defined operation for constructing the request for the next link.

It might be best to update azcore/runtime/FetcherForNextLink to handle this case. We'd still have to change all of the codegen call sites, but the logic can be centralized.

nextLink = runtime.JoinPaths(client.internal.Endpoint(), nextLink)
}
}
resp, err := runtime.FetcherForNextLink(ctx, client.internal.Pipeline(), nextLink, func(ctx context.Context) (*policy.Request, error) {
return client.listPrivateEndPointsCreateRequest(ctx, apiVersion, resourceGroupName, resourceName, options)
}, nil)
if err != nil {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -287,6 +287,13 @@ func (client *TenantItemsClient) NewListPager(apiVersion string, options *Tenant
if page != nil {
nextLink = *page.NextLink
}
// the service can return a next link that's relative to the endpoint, however
// runtime.FetcherForNextLink requires an absolute URL, so resolve it here.
if nextLink != "" {
if u, err := url.Parse(nextLink); err == nil && !u.IsAbs() {
nextLink = runtime.JoinPaths(client.internal.Endpoint(), nextLink)
}
}
resp, err := runtime.FetcherForNextLink(ctx, client.internal.Pipeline(), nextLink, func(ctx context.Context) (*policy.Request, error) {
return client.listCreateRequest(ctx, apiVersion, options)
}, nil)
Expand Down
Loading