Skip to content
Draft
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,20 @@
---
changeKind: feature
packages:
- "@typespec/compiler"
---

Add warning when `@list` is used with no paging navigation information. Operations decorated with `@list` now require at least one of `@nextLink`, `@pageIndex`, or `@continuationToken`.

```typespec
// This will now emit a warning: no paging navigation information
@list op list(): {
@pageItems items: string[];
};

// Correct: has nextLink
@list op list(): {
@pageItems items: string[];
@nextLink next: string;
};
```
6 changes: 6 additions & 0 deletions packages/compiler/src/core/messages.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1013,6 +1013,12 @@ const diagnostics = {
default: paramMessage`Paged operation '${"operationName"}' return type must have a property annotated with @pageItems.`,
},
},
"list-no-paging-navigation": {
severity: "warning",
messages: {
default: paramMessage`Paged operation '${"operationName"}' has no paging navigation. Add a @nextLink, @pageIndex, or @continuationToken property to enable pagination.`,
},
},
/**
* Service
*/
Expand Down
18 changes: 18 additions & 0 deletions packages/compiler/src/lib/paging.ts
Original file line number Diff line number Diff line change
Expand Up @@ -280,6 +280,24 @@ export function getPagingOperation(
);
return diags.wrap(undefined);
}

const hasNavigationInfo =
result.output.nextLink !== undefined ||
result.input.pageIndex !== undefined ||
result.input.offset !== undefined ||
result.input.continuationToken !== undefined ||
result.output.continuationToken !== undefined;

if (!hasNavigationInfo) {
diags.add(
createDiagnostic({
code: "list-no-paging-navigation",
format: { operationName: op.name },
target: op,
}),
);
}

return diags.wrap(result);
}

Expand Down
63 changes: 60 additions & 3 deletions packages/compiler/test/decorators/paging.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,11 +33,64 @@ it("emit error if missing pageItems property", async () => {
});
});

it("emit warning when @list has no paging navigation information", async () => {
const diagnostics = await Tester.diagnose(`
@list op list(): {
@pageItems items: string[];
};
`);

expectDiagnostics(diagnostics, {
code: "list-no-paging-navigation",
severity: "warning",
message: `Paged operation 'list' has no paging navigation. Add a @nextLink, @pageIndex, or @continuationToken property to enable pagination.`,
});
});

it("no warning when @list has @nextLink", async () => {
const diagnostics = await Tester.diagnose(`
@list op list(): {
@pageItems items: string[];
@nextLink next: string;
};
`);
expectDiagnosticEmpty(diagnostics);
});

it("no warning when @list has @pageIndex", async () => {
const diagnostics = await Tester.diagnose(`
@list op list(@pageIndex page: int32): {
@pageItems items: string[];
};
`);
expectDiagnosticEmpty(diagnostics);
});

it("no warning when @list has @continuationToken in output", async () => {
const diagnostics = await Tester.diagnose(`
@list op list(): {
@pageItems items: string[];
@continuationToken token: string;
};
`);
expectDiagnosticEmpty(diagnostics);
});

it("no warning when @list has @continuationToken in input", async () => {
const diagnostics = await Tester.diagnose(`
@list op list(@continuationToken token?: string): {
@pageItems items: string[];
};
`);
expectDiagnosticEmpty(diagnostics);
});

it("identifies inherited paging properties", async () => {
const diagnostics = await Tester.diagnose(`
model ListTestResult {
@pageItems
values: string[];
@nextLink next: string;
}
model ExtendedListTestResult extends ListTestResult {}

Expand Down Expand Up @@ -71,7 +124,7 @@ describe("emit conflict diagnostic if multiple properties are annotated with the
@list op list(
@${name} prop1: ${type};
@${name} prop2: ${type};
): { @pageItems items: string[] };
): { @pageItems items: string[]; @nextLink next: string; };
`);

expectDiagnostics(diagnostics, [
Expand Down Expand Up @@ -99,6 +152,7 @@ describe("emit conflict diagnostic if multiple properties are annotated with the
@${name} next: ${type};
@${name} nextToo: ${type};
${name !== "pageItems" ? "@pageItems items: string[];" : ""}
${!["nextLink", "continuationToken"].includes(name) ? "@nextLink nav: string;" : ""}
};
`);

Expand All @@ -125,7 +179,7 @@ describe("collect paging properties", () => {
const { list, prop, program } = await Tester.compile(t.code`
@list op ${t.op("list")}(
@${name} ${t.modelProperty("prop")}: ${type};
): { @pageItems items: string[] };
): { @pageItems items: string[]; @nextLink next: string; };
`);

const paging = ignoreDiagnostics(getPagingOperation(program, list));
Expand All @@ -145,6 +199,7 @@ describe("collect paging properties", () => {
@list op ${t.op("list")}(): {
@${name} ${t.modelProperty("prop")}: ${type};
${name !== "pageItems" ? "@pageItems items: string[];" : ""}
${name !== "nextLink" && name !== "continuationToken" ? "@nextLink nav: string;" : ""}
};
`);

Expand All @@ -164,7 +219,7 @@ describe("collect nested paging properties", () => {
const { list, prop, program } = await Tester.compile(t.code`
@list op ${t.op("list")}(
@${name} ${t.modelProperty("prop")}: ${type};
): { @pageItems items: string[] };
): { @pageItems items: string[]; @nextLink next: string; };
`);

const paging = ignoreDiagnostics(getPagingOperation(program, list));
Expand All @@ -183,6 +238,7 @@ describe("collect nested paging properties", () => {
@list op ${t.op("list")}(): {
results : { @pageItems items: string[]; };
pagination: { @${name} ${t.modelProperty("prop")}: ${type} };
${name !== "nextLink" && name !== "continuationToken" ? "@nextLink nav: string;" : ""}
};
`);

Expand All @@ -199,6 +255,7 @@ describe("collect nested paging properties", () => {
const { list, program } = await Tester.compile(t.code`
@list op ${t.op("list")}(): {
results : { @pageItems items: string[]; };
@nextLink next: string;
};
`);

Expand Down
4 changes: 4 additions & 0 deletions packages/http-specs/smoke/todoapp/main.tsp
Original file line number Diff line number Diff line change
Expand Up @@ -240,6 +240,10 @@ namespace TodoItems {
//@friendlyName("{name}List", T)
model Page<T> {
@pageItems items: T[];

/** A link to the next page, if it exists */
@nextLink
nextLink?: url;
}

@list op list(...PaginationControls): WithStandardErrors<TodoPage>;
Expand Down
2 changes: 2 additions & 0 deletions packages/http-specs/specs/payload/pageable/main.tsp
Original file line number Diff line number Diff line change
Expand Up @@ -500,6 +500,7 @@ namespace ServerDrivenPagination {

@route("/pagesize")
namespace PageSize {
#suppress "list-no-paging-navigation" "Intentional: single-page list with no navigation for testing purposes"
@scenario
@scenarioDoc("""
Test case for simple pagination without nextlink or continuationToken.
Expand All @@ -525,6 +526,7 @@ namespace PageSize {
pets: Pet[];
};

#suppress "list-no-paging-navigation" "Intentional: page-size-only list with no navigation for testing purposes"
@scenario
@scenarioDoc("""
Test case for pagination with a regular @pageSize parameter.
Expand Down
4 changes: 4 additions & 0 deletions packages/samples/specs/todoApp/main.tsp
Original file line number Diff line number Diff line change
Expand Up @@ -241,6 +241,10 @@ namespace TodoItems {
//@friendlyName("{name}List", T)
model Page<T> {
@pageItems items: T[];

/** A link to the next page, if it exists */
@nextLink
nextLink?: url;
}

@list op list(...PaginationControls): WithStandardErrors<TodoPage>;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -183,6 +183,10 @@ paths:
type: array
items:
$ref: '#/components/schemas/TodoAttachment'
nextLink:
type: string
format: uri
description: A link to the next page, if it exists
'404':
description: The server cannot find the requested resource.
content:
Expand Down