Skip to content
11 changes: 11 additions & 0 deletions .chronus/changes/http-client-java-azure-continuation-token.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
---
changeKind: feature
packages:
- "@typespec/http-client-java"
---

Support continuation-token pagination in Azure Java clients. Tokens are hidden from client method signatures; resume through the paging API:

```java
client.list().streamByPage(token);
```
1 change: 1 addition & 0 deletions .github/instructions/http-client-java.instructions.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ applyTo: "packages/http-client-java/**/*"
- The directories mentioned in this instruction are relative to `<repository-root>/packages/http-client-java`. For example, the root is the folder `<repository-root>/packages/http-client-java`; the directory `generator/http-client-generator-test` refers to `<repository-root>/packages/http-client-java/generator/http-client-generator-test`.
- Always use absolute paths when changing directories or running scripts.
- Whenever calling `npm`, use `--registry=https://pkgs.dev.azure.com/azure-sdk/public/_packaging/azure-sdk-for-js/npm/registry/`.
- `README.md` is generated by `tspd`; do not edit it directly. Update `.tspd/docs/` or emitter option documentation, then run `npm run regen-docs`.

# Update Node.js packages for latest dependencies

Expand Down
96 changes: 47 additions & 49 deletions packages/http-client-java/emitter/src/code-model-builder.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1304,63 +1304,61 @@ export class CodeModelBuilder {
let continuationTokenParameter: Parameter | undefined;
let continuationTokenResponseProperty: Property[] | undefined;
let continuationTokenResponseHeader: HttpHeader | undefined;
if (!this.isAzureV1()) {
// parameter would either be query or header parameter, so taking the last segment would be enough
const continuationTokenParameterSegment =
sdkMethod.pagingMetadata.continuationTokenParameterSegments?.at(-1);
// response could be response header, where the last segment would do; or it be json path in the response body, where we use "findResponsePropertySegments" to find them
const continuationTokenResponseSegment =
sdkMethod.pagingMetadata.continuationTokenResponseSegments?.at(-1);
if (continuationTokenParameterSegment && op.parameters) {
// for now, continuationToken is either request query or header parameter
const parameter = getHttpOperationParameter(sdkMethod, continuationTokenParameterSegment);
if (parameter) {
for (const param of op.parameters) {
if (param.protocol.http?.in === parameter.kind) {
if (
parameter.kind === "header" &&
param.language.default.serializedName?.toLowerCase() ===
parameter.serializedName.toLowerCase()
) {
continuationTokenParameter = param;
break;
} else if (
parameter.kind === "query" &&
param.language.default.serializedName === parameter.serializedName
) {
continuationTokenParameter = param;
break;
}
// parameter would either be query or header parameter, so taking the last segment would be enough
const continuationTokenParameterSegment =
sdkMethod.pagingMetadata.continuationTokenParameterSegments?.at(-1);
// response could be response header, where the last segment would do; or it be json path in the response body, where we use "findResponsePropertySegments" to find them
const continuationTokenResponseSegment =
sdkMethod.pagingMetadata.continuationTokenResponseSegments?.at(-1);
if (continuationTokenParameterSegment && op.parameters) {
// for now, continuationToken is either request query or header parameter
const parameter = getHttpOperationParameter(sdkMethod, continuationTokenParameterSegment);
if (parameter) {
for (const param of op.parameters) {
if (param.protocol.http?.in === parameter.kind) {
if (
parameter.kind === "header" &&
param.language.default.serializedName?.toLowerCase() ===
parameter.serializedName.toLowerCase()
) {
continuationTokenParameter = param;
break;
} else if (
parameter.kind === "query" &&
param.language.default.serializedName === parameter.serializedName
) {
continuationTokenParameter = param;
break;
}
}
}
}
if (continuationTokenResponseSegment && op.responses) {
if (continuationTokenResponseSegment?.kind === "responseheader") {
// continuationToken is response header
for (const response of op.responses) {
if (isSchemaResponse(response) && response.protocol.http) {
for (const header of response.protocol.http.headers ?? []) {
if (
header.header.toLowerCase() ===
continuationTokenResponseSegment.serializedName.toLowerCase()
) {
continuationTokenResponseHeader = header;
break;
}
}
if (continuationTokenResponseSegment && op.responses) {
if (continuationTokenResponseSegment?.kind === "responseheader") {
// continuationToken is response header
for (const response of op.responses) {
if (isSchemaResponse(response) && response.protocol.http) {
for (const header of response.protocol.http.headers ?? []) {
if (
header.header.toLowerCase() ===
continuationTokenResponseSegment.serializedName.toLowerCase()
) {
continuationTokenResponseHeader = header;
break;
}
}
if (continuationTokenResponseHeader) {
break;
}
}
} else if (continuationTokenResponseSegment?.kind === "property") {
// continuationToken is response body property
continuationTokenResponseProperty = findResponsePropertySegments(
op,
sdkMethod.pagingMetadata.continuationTokenResponseSegments,
);
if (continuationTokenResponseHeader) {
break;
}
}
} else if (continuationTokenResponseSegment?.kind === "property") {
// continuationToken is response body property
continuationTokenResponseProperty = findResponsePropertySegments(
op,
sdkMethod.pagingMetadata.continuationTokenResponseSegments,
);
}
}

Expand Down
52 changes: 52 additions & 0 deletions packages/http-client-java/emitter/test/code-model.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { fileURLToPath } from "node:url";
import { assert, describe, expect, it } from "vitest";
import { parse, stringify } from "yaml";
import { CodeModelBuilder } from "../src/code-model-builder.js";
import type { PageableContinuationToken } from "../src/common/client.js";
import { addSubClient, createClient } from "../src/common/client.js";
import { addGlobalParameter, createCodeModel } from "../src/common/code-model.js";
import {
Expand Down Expand Up @@ -40,6 +41,57 @@ import { createResponseErrorSchema } from "../src/external-schemas.js";
import { cloneOperationParameter } from "../src/operation-utils.js";

describe("code model characterization", () => {
it.each(["azure", "standard"] as const)(
"preserves continuation-token metadata for %s",
async (flavor) => {
const program = await compile(
NodeHost,
fileURLToPath(new URL("./fixtures/continuation-token.tsp", import.meta.url)),
{ noEmit: true },
);
expectDiagnosticEmpty(program.diagnostics);
const model = await new CodeModelBuilder(program, {
program,
emitterOutputDir: "/code-model-test-output",
options: { flavor, "dev-options": {} },
perf: {
startTimer: () => ({ end: () => 0 }),
time: (_label, callback) => callback(),
timeAsync: (_label, callback) => callback(),
report: () => {},
},
}).build();
expectDiagnosticEmpty(program.diagnostics);
const operations = model.clients.flatMap((client) =>
client.operationGroups.flatMap((group) => group.operations ?? []),
);
expect(operations).toHaveLength(4);
for (const operation of operations) {
const name = operation.language.default.name;
const pageable = operation.extensions?.["x-ms-pageable"] as
{ continuationToken?: PageableContinuationToken; nextLinkName?: string } | undefined;
assert.isDefined(pageable?.continuationToken);
const token = pageable.continuationToken;
expect(token.parameter).toBe(
operation.parameters?.find((p) => p.language.default.serializedName === "token"),
);
expect(token.parameter.protocol.http?.in).toBe(
name.startsWith("query") ? "query" : "header",
);
expect(pageable.nextLinkName).toBeUndefined();
if (name.endsWith("Header")) {
expect(token.responseHeader?.header).toBe("next-token");
expect(token.responseProperty).toBeUndefined();
} else {
expect(token.responseProperty?.map((p) => p.serializedName)).toEqual(
name === "queryBody" ? ["next", "token"] : ["nextToken"],
);
expect(token.responseHeader).toBeUndefined();
}
}
},
);

it("preserves metadata defaults and supplied schema references", () => {
const schema = createStringSchema("string", "A string");
const property = createProperty("value", "A value", schema, {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
import "@typespec/http";

using TypeSpec.Http;

@service
namespace ContinuationToken;

model Item {
id: string;
}

@list
@route("/query-body")
op queryBody(@query @continuationToken token?: string): {
@pageItems items: Item[];
next?: {
@continuationToken token?: string;
};
};

@list
@route("/header-body")
op headerBody(@header @continuationToken token?: string): {
@pageItems items: Item[];
@continuationToken nextToken?: string;
};

@list
@route("/query-header")
op queryHeader(@query @continuationToken token?: string): {
@pageItems items: Item[];
@header("next-token") @continuationToken nextToken?: string;
};

@list
@route("/header-header")
op headerHeader(@header @continuationToken token?: string): {
@pageItems items: Item[];
@header("next-token") @continuationToken nextToken?: string;
};
Original file line number Diff line number Diff line change
Expand Up @@ -89,8 +89,9 @@ static PagingMetadata create(Operation operation, ProxyMethod proxyMethod,
nextMethods = List.of();
}

final MethodPageDetails.ContinuationToken continuationToken
= getContinuationToken(xmsPageable, responseType, parametersDetails);
final MethodPageDetails.ContinuationToken continuationToken = CoreUtils.isNullOrEmpty(nextLinkPropertyReference)
? getContinuationToken(xmsPageable, responseType, parametersDetails)
: null;
final ClientMethodParameter maxPageSizeParameter = getPageSizeClientMethodParameter(parametersDetails);

MethodPageDetails.NextLinkReInjection nextLinkReInjection = null;
Expand Down
Loading
Loading