fix(633): document the 0-based paging contract on the OpenAPI parameters #646

Merged
timothy merged 3 commits from fix/633-openapi-paging-descriptions into main 2026-07-26 13:06:32 +02:00
13 changed files with 312 additions and 31 deletions
@@ -0,0 +1,183 @@
using System.Text.RegularExpressions;
using ErsatzTV.Tests.Support;
using Microsoft.OpenApi;
using NUnit.Framework;
using Shouldly;
namespace ErsatzTV.Tests.Controllers;
/// <summary>
/// Pins the <c>api.paging-zero-based</c> contract onto the generated OpenAPI document (ersatztv#633).
/// The spec is the contract REST consumers read — and what generated clients surface to their users —
/// so a paging parameter that documents nothing forces every consumer to infer the base from
/// <c>default: 0</c>. That is exactly the inference that cost ersatztv#487 a verification pass on the
/// MCP side, where the description was present but wrong. The MCP wrapper is pinned the same way in
/// <c>ErsatzTV.Mcp.Tests.ToolCatalogTests</c>; this is the API-side half.
/// </summary>
[TestFixture]
public class OpenApiPagingContractTests
{
/// <summary>
/// Every operation that pages. Named explicitly rather than discovered, because a test that only
/// FILTERS on "declares pageNum" cannot see the endpoint that should page and does not — the
/// defect escapes the filter and the test still passes green over a shrinking scope. That is not
/// hypothetical: ersatztv#616 found two MCP tools doing precisely that. So the expected set is
/// pinned here, and <see cref="Paged_Operations_Should_Be_Exactly_The_Pinned_Set" /> asserts the
/// discovered set equals it in BOTH directions — a new paged endpoint fails until it is added
/// (with descriptions), and an endpoint that silently drops paging fails too.
/// </summary>
private static readonly string[] PagedOperations =
[
"GET /api/v1/channels/auto-tune/members",
"GET /api/v1/collections/{id}/items",
"GET /api/v1/library/browse",
"GET /api/v1/logs",
"GET /api/v1/multi-collections",
"GET /api/v1/playouts",
"GET /api/v1/playouts/{id}/blocks/{blockId}/history",
"GET /api/v1/playouts/{id}/items",
"GET /api/v1/rerun-collections",
"GET /api/v1/search",
"GET /api/v1/search/all-items",
"GET /api/v1/trakt/lists"
];
private static OpenApiDocument _document = null!;
[OneTimeSetUp]
public async Task BuildDocument() => _document = await GeneratedOpenApiDocument.BuildV1Async();
[Test]
public void Paged_Operations_Should_Be_Exactly_The_Pinned_Set()
{
List<string> discovered = EnumerateOperations()
.Where(op => ParameterNames(op.Operation).Overlaps(new[] { "pageNum", "pageSize" }))
.Select(op => $"{op.Method} {op.Path}")
.OrderBy(s => s, StringComparer.Ordinal)
.ToList();
discovered.ShouldBe(PagedOperations.OrderBy(s => s, StringComparer.Ordinal).ToList());
}
[Test]
public void Every_Paged_Operation_Should_Declare_Both_Paging_Parameters()
{
foreach (string key in PagedOperations)
{
HashSet<string> names = ParameterNames(Find(key));
names.ShouldContain("pageNum", $"{key} should declare pageNum");
names.ShouldContain("pageSize", $"{key} should declare pageSize");
}
}
[Test]
public void Every_PageNum_Parameter_Should_Document_The_ZeroBased_Contract()
{
foreach (string key in PagedOperations)
{
string description = Description(key, "pageNum");
// The whole point of the record: a consumer must not have to infer the base from `default: 0`.
description.ShouldContain("0-based", Case.Insensitive, $"{key} pageNum should say it is 0-based");
description.ShouldNotContain("1-based", Case.Insensitive, $"{key} pageNum must not claim 1-based");
}
}
[Test]
public void Every_PageSize_Parameter_Should_Document_The_Cap_And_The_Effective_Offset()
{
foreach (string key in PagedOperations)
{
string description = Description(key, "pageSize");
// `api.paging-zero-based` is explicit that the cap is PER-ENDPOINT and must not be documented
// as one number, and that the offset derives from the effective (capped) size — so an
// over-large pageSize narrows the page without widening the offset.
description.ShouldContain("capped at", Case.Insensitive, $"{key} pageSize should state its cap");
description.ShouldContain("this endpoint", Case.Insensitive, $"{key} pageSize cap should be scoped to the endpoint");
description.ShouldContain("effective", Case.Insensitive, $"{key} pageSize should explain the effective-size offset");
}
}
[Test]
public void PageSize_Caps_Should_Match_The_Values_The_Controllers_Actually_Clamp_To()
{
// The caps genuinely differ per endpoint, which is why the record forbids documenting one number.
// A description naming the wrong cap is worse than none — a wrong justification outlives a wrong
// line — so pin each against the value its controller clamps to.
var expectedCaps = new Dictionary<string, int>(StringComparer.Ordinal)
{
["GET /api/v1/channels/auto-tune/members"] = 200,
["GET /api/v1/collections/{id}/items"] = 100,
["GET /api/v1/library/browse"] = 100,
["GET /api/v1/logs"] = 100,
["GET /api/v1/multi-collections"] = 100,
["GET /api/v1/playouts"] = 100,
["GET /api/v1/playouts/{id}/blocks/{blockId}/history"] = 100,
["GET /api/v1/playouts/{id}/items"] = 100,
["GET /api/v1/rerun-collections"] = 100,
["GET /api/v1/search"] = 100,
["GET /api/v1/search/all-items"] = 1000,
["GET /api/v1/trakt/lists"] = 100
};
// Guard the guard: every pinned operation must carry an expected cap, so adding one above
// without its cap here cannot quietly skip this assertion.
expectedCaps.Keys.OrderBy(k => k, StringComparer.Ordinal)
.ShouldBe(PagedOperations.OrderBy(k => k, StringComparer.Ordinal));
foreach ((string key, int cap) in expectedCaps)
{
// Enumerate EVERY cap claim in the description and require the set to be exactly one
// number, the right one. Two weaker forms were rejected on the way here:
// - ShouldContain("capped at 100") is satisfied by the string "capped at 1000", so a
// cap-100 endpoint claiming 1000 passed — the very defect this test exists to catch.
// - Matching one occurrence as a whole token ("capped at 100(?!\d)") fixes that, but
// still passes a description that names a wrong cap somewhere ELSE in the sentence
// and the right one later. Presence of a true claim is not absence of a false one.
List<int> claimedCaps = Regex
.Matches(Description(key, "pageSize"), @"capped at (\d+)", RegexOptions.IgnoreCase)
.Select(match => int.Parse(match.Groups[1].Value))
.ToList();
claimedCaps.ShouldBe([cap], $"{key} pageSize should make exactly one cap claim, of {cap}");
}
}
private static string Description(string key, string parameterName)
{
// Not `First(...)`: a missing parameter would throw "Sequence contains no matching element",
// which names neither the endpoint nor the parameter and reads as a broken test rather than
// the contract violation it is.
IOpenApiParameter parameter = (Find(key).Parameters ?? [])
.FirstOrDefault(p => string.Equals(p.Name, parameterName, StringComparison.Ordinal))
.ShouldNotBeNull($"{key} should declare a {parameterName} parameter");
string? description = parameter.Description;
description.ShouldNotBeNullOrWhiteSpace($"{key} {parameterName} should carry a description");
return description!;
}
private static OpenApiOperation Find(string key) =>
EnumerateOperations()
.Where(op => string.Equals($"{op.Method} {op.Path}", key, StringComparison.Ordinal))
.Select(op => op.Operation)
.FirstOrDefault()
.ShouldNotBeNull($"{key} should exist in the generated document");
private static HashSet<string> ParameterNames(OpenApiOperation operation) =>
(operation.Parameters ?? []).Select(p => p.Name ?? string.Empty).ToHashSet(StringComparer.Ordinal);
private static IEnumerable<(string Method, string Path, OpenApiOperation Operation)> EnumerateOperations()
{
foreach ((string path, IOpenApiPathItem item) in _document.Paths)
{
foreach ((HttpMethod method, OpenApiOperation operation) in item.Operations!)
{
yield return (method.Method.ToUpperInvariant(), path, operation);
}
}
}
}
@@ -1,3 +1,4 @@
using System.ComponentModel;
using System.ComponentModel.DataAnnotations;
using System.Threading.Channels;
using ErsatzTV.Application;
@@ -264,8 +265,12 @@ public class ChannelController(
public async Task<PagedLibraryBrowseItemsResponseModel> GetAutoTuneChannelMembers(
[FromQuery] AutoTuneAxis axis,
[FromQuery] string value,
[FromQuery] int pageNum,
[FromQuery] int pageSize,
[FromQuery]
[Description("0-based page index: the first page is 0, not 1. A negative value is clamped to 0.")]
int pageNum,
[FromQuery]
[Description("Rows per page; capped at 200 for this endpoint. A value of 0 or less falls back to 100 rather than being clamped to 1. The page offset is derived from the effective (capped) size, so a larger value narrows the page instead of widening the offset.")]
int pageSize,
CancellationToken cancellationToken)
{
pageNum = Math.Max(0, pageNum);
@@ -1,3 +1,4 @@
using System.ComponentModel;
using System.ComponentModel.DataAnnotations;
using ErsatzTV.Application.MediaCollections;
using ErsatzTV.Controllers.Api.Requests;
@@ -46,8 +47,12 @@ public class CollectionController(IMediator mediator) : ControllerBase
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status404NotFound)]
public async Task<IActionResult> GetItems(
int id,
[FromQuery] int pageNum = 0,
[FromQuery] int pageSize = 100,
[FromQuery]
[Description("0-based page index: the first page is 0, not 1. A negative value is clamped to 0.")]
int pageNum = 0,
[FromQuery]
[Description("Rows per page (default 100); capped at 100 for this endpoint. The page offset is derived from the effective (capped) size, so a larger value narrows the page instead of widening the offset.")]
int pageSize = 100,
CancellationToken cancellationToken = default)
{
int clampedPageNum = Math.Max(0, pageNum);
@@ -21,8 +21,12 @@ public class LibraryBrowseController(IMediator mediator) : ControllerBase
[FromQuery] string query = "",
[FromQuery] int? libraryId = null,
[FromQuery] LibraryBrowseMediaType? mediaType = null,
[FromQuery] int pageNum = 0,
[FromQuery] int pageSize = 100,
[FromQuery]
[Description("0-based page index: the first page is 0, not 1. A negative value is clamped to 0.")]
int pageNum = 0,
[FromQuery]
[Description("Rows per page (default 100); capped at 100 for this endpoint. The page offset is derived from the effective (capped) size, so a larger value narrows the page instead of widening the offset.")]
int pageSize = 100,
[FromQuery]
[Description("Parent id for a drill-in listing; only used with mediaType=TelevisionSeason (that show's seasons), mediaType=Episode (that season's episodes) or mediaType=MusicVideo (that artist's music videos), ignored otherwise")]
int? parentId = null,
+7 -2
View File
@@ -1,3 +1,4 @@
using System.ComponentModel;
using System.Linq.Expressions;
using ErsatzTV.Application.Logs;
using ErsatzTV.Core.Api.Logs;
@@ -29,8 +30,12 @@ public class LogsController(IMediator mediator) : ControllerBase
[EndpointGroupName("general")]
[ProducesResponseType(typeof(PagedLogEntriesResponseModel), StatusCodes.Status200OK)]
public async Task<PagedLogEntriesResponseModel> GetLogs(
[FromQuery] int pageNum = 0,
[FromQuery] int pageSize = 100,
[FromQuery]
[Description("0-based page index: the first page is 0, not 1. A negative value is clamped to 0.")]
int pageNum = 0,
[FromQuery]
[Description("Rows per page (default 100); capped at 100 for this endpoint. The page offset is derived from the effective (capped) size, so a larger value narrows the page instead of widening the offset.")]
int pageSize = 100,
[FromQuery] string filter = "",
[FromQuery] string sortField = "timestamp",
[FromQuery] string sortDirection = "desc",
@@ -1,3 +1,4 @@
using System.ComponentModel;
using System.ComponentModel.DataAnnotations;
using ErsatzTV.Application.MediaCollections;
using ErsatzTV.Controllers.Api.Requests;
@@ -22,8 +23,12 @@ public class MultiCollectionController(IMediator mediator) : ControllerBase
[ProducesResponseType(typeof(PagedMultiCollectionsResponseModel), StatusCodes.Status200OK)]
public async Task<PagedMultiCollectionsResponseModel> GetAll(
[FromQuery] string query = "",
[FromQuery] int pageNum = 0,
[FromQuery] int pageSize = 100,
[FromQuery]
[Description("0-based page index: the first page is 0, not 1. A negative value is clamped to 0.")]
int pageNum = 0,
[FromQuery]
[Description("Rows per page (default 100); capped at 100 for this endpoint. The page offset is derived from the effective (capped) size, so a larger value narrows the page instead of widening the offset.")]
int pageSize = 100,
CancellationToken cancellationToken = default)
{
int clampedPageNum = Math.Max(0, pageNum);
+19 -6
View File
@@ -1,3 +1,4 @@
using System.ComponentModel;
using System.ComponentModel.DataAnnotations;
using ErsatzTV.Application.Playouts;
using ErsatzTV.Application.ProgramSchedules;
@@ -41,8 +42,12 @@ public class PlayoutController(IMediator mediator, IEntityLocker entityLocker) :
[ProducesResponseType(typeof(PagedPlayoutsResponseModel), StatusCodes.Status200OK)]
public async Task<PagedPlayoutsResponseModel> GetAll(
[FromQuery] string query = "",
[FromQuery] int pageNum = 0,
[FromQuery] int pageSize = 100,
[FromQuery]
[Description("0-based page index: the first page is 0, not 1. A negative value is clamped to 0.")]
int pageNum = 0,
[FromQuery]
[Description("Rows per page (default 100); capped at 100 for this endpoint. The page offset is derived from the effective (capped) size, so a larger value narrows the page instead of widening the offset.")]
int pageSize = 100,
CancellationToken cancellationToken = default)
{
pageNum = Math.Max(0, pageNum);
@@ -83,8 +88,12 @@ public class PlayoutController(IMediator mediator, IEntityLocker entityLocker) :
public async Task<IActionResult> GetItems(
int id,
[FromQuery] bool showFiller = false,
[FromQuery] int pageNum = 0,
[FromQuery] int pageSize = 100,
[FromQuery]
[Description("0-based page index: the first page is 0, not 1. A negative value is clamped to 0.")]
int pageNum = 0,
[FromQuery]
[Description("Rows per page (default 100); capped at 100 for this endpoint. The page offset is derived from the effective (capped) size, so a larger value narrows the page instead of widening the offset.")]
int pageSize = 100,
CancellationToken cancellationToken = default)
{
Option<PlayoutNameViewModel> maybePlayout = await mediator.Send(new GetPlayoutById(id), cancellationToken);
@@ -543,8 +552,12 @@ public class PlayoutController(IMediator mediator, IEntityLocker entityLocker) :
public async Task<IActionResult> GetBlockHistory(
int id,
int blockId,
[FromQuery] int pageNum = 0,
[FromQuery] int pageSize = 100,
[FromQuery]
[Description("0-based page index: the first page is 0, not 1. A negative value is clamped to 0.")]
int pageNum = 0,
[FromQuery]
[Description("Rows per page (default 100); capped at 100 for this endpoint. The page offset is derived from the effective (capped) size, so a larger value narrows the page instead of widening the offset.")]
int pageSize = 100,
CancellationToken cancellationToken = default)
{
Option<PlayoutNameViewModel> maybePlayout = await mediator.Send(new GetPlayoutById(id), cancellationToken);
@@ -1,3 +1,4 @@
using System.ComponentModel;
using System.ComponentModel.DataAnnotations;
using ErsatzTV.Application.MediaCollections;
using ErsatzTV.Controllers.Api.Requests;
@@ -22,8 +23,12 @@ public class RerunCollectionController(IMediator mediator) : ControllerBase
[ProducesResponseType(typeof(PagedRerunCollectionsResponseModel), StatusCodes.Status200OK)]
public async Task<PagedRerunCollectionsResponseModel> GetAll(
[FromQuery] string query = "",
[FromQuery] int pageNum = 0,
[FromQuery] int pageSize = 100,
[FromQuery]
[Description("0-based page index: the first page is 0, not 1. A negative value is clamped to 0.")]
int pageNum = 0,
[FromQuery]
[Description("Rows per page (default 100); capped at 100 for this endpoint. The page offset is derived from the effective (capped) size, so a larger value narrows the page instead of widening the offset.")]
int pageSize = 100,
CancellationToken cancellationToken = default)
{
int clampedPageNum = Math.Max(0, pageNum);
+13 -4
View File
@@ -1,3 +1,4 @@
using System.ComponentModel;
using ErsatzTV.Application.MediaCollections;
using ErsatzTV.Application.MediaItems;
using ErsatzTV.Application.Search;
@@ -35,8 +36,12 @@ public class SearchController(IMediator mediator) : ControllerBase
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status422UnprocessableEntity)]
public async Task<IActionResult> Search(
[FromQuery] string query = "",
[FromQuery] int pageNum = 0,
[FromQuery] int pageSize = 50,
[FromQuery]
[Description("0-based page index: the first page is 0, not 1. A negative value is clamped to 0.")]
int pageNum = 0,
[FromQuery]
[Description("Rows per page (default 50); capped at 100 for this endpoint. The page offset is derived from the effective (capped) size, so a larger value narrows the page instead of widening the offset.")]
int pageSize = 50,
CancellationToken cancellationToken = default)
{
if (string.IsNullOrWhiteSpace(query))
@@ -65,8 +70,12 @@ public class SearchController(IMediator mediator) : ControllerBase
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status422UnprocessableEntity)]
public async Task<IActionResult> SearchAllItems(
[FromQuery] string query = "",
[FromQuery] int pageNum = 0,
[FromQuery] int pageSize = DefaultAllItemsPageSize,
[FromQuery]
[Description("0-based page index: the first page is 0, not 1. A negative value is clamped to 0. Unlike the other paged endpoints this one is also bounded ABOVE, at 2000000, so that pageNum * pageSize cannot overflow; a larger value is clamped down to that maximum rather than rejected.")]
int pageNum = 0,
[FromQuery]
[Description("Rows per page (default 500); capped at 1000 for this endpoint. The page offset is derived from the effective (capped) size, so a larger value narrows the page instead of widening the offset.")]
int pageSize = DefaultAllItemsPageSize,
CancellationToken cancellationToken = default)
{
if (string.IsNullOrWhiteSpace(query))
+7 -2
View File
@@ -1,3 +1,4 @@
using System.ComponentModel;
using System.ComponentModel.DataAnnotations;
using System.Text.RegularExpressions;
using System.Threading.Channels;
@@ -28,8 +29,12 @@ public partial class TraktController(
[EndpointGroupName("general")]
[ProducesResponseType(typeof(PagedTraktListsResponseModel), StatusCodes.Status200OK)]
public async Task<PagedTraktListsResponseModel> GetAll(
[FromQuery] int pageNum = 0,
[FromQuery] int pageSize = 100,
[FromQuery]
[Description("0-based page index: the first page is 0, not 1. A negative value is clamped to 0.")]
int pageNum = 0,
[FromQuery]
[Description("Rows per page (default 100); capped at 100 for this endpoint. The page offset is derived from the effective (capped) size, so a larger value narrows the page instead of widening the offset.")]
int pageSize = 100,
CancellationToken cancellationToken = default)
{
int clampedPageNum = Math.Max(0, pageNum);
+24
View File
@@ -2578,6 +2578,7 @@
{
"name": "pageNum",
"in": "query",
"description": "0-based page index: the first page is 0, not 1. A negative value is clamped to 0.",
"schema": {
"type": "integer",
"format": "int32"
@@ -2586,6 +2587,7 @@
{
"name": "pageSize",
"in": "query",
"description": "Rows per page; capped at 200 for this endpoint. A value of 0 or less falls back to 100 rather than being clamped to 1. The page offset is derived from the effective (capped) size, so a larger value narrows the page instead of widening the offset.",
"schema": {
"type": "integer",
"format": "int32"
@@ -3863,6 +3865,7 @@
{
"name": "pageNum",
"in": "query",
"description": "0-based page index: the first page is 0, not 1. A negative value is clamped to 0.",
"schema": {
"type": "integer",
"format": "int32",
@@ -3872,6 +3875,7 @@
{
"name": "pageSize",
"in": "query",
"description": "Rows per page (default 100); capped at 100 for this endpoint. The page offset is derived from the effective (capped) size, so a larger value narrows the page instead of widening the offset.",
"schema": {
"type": "integer",
"format": "int32",
@@ -9385,6 +9389,7 @@
{
"name": "pageNum",
"in": "query",
"description": "0-based page index: the first page is 0, not 1. A negative value is clamped to 0.",
"schema": {
"type": "integer",
"format": "int32",
@@ -9394,6 +9399,7 @@
{
"name": "pageSize",
"in": "query",
"description": "Rows per page (default 100); capped at 100 for this endpoint. The page offset is derived from the effective (capped) size, so a larger value narrows the page instead of widening the offset.",
"schema": {
"type": "integer",
"format": "int32",
@@ -10154,6 +10160,7 @@
{
"name": "pageNum",
"in": "query",
"description": "0-based page index: the first page is 0, not 1. A negative value is clamped to 0.",
"schema": {
"type": "integer",
"format": "int32",
@@ -10163,6 +10170,7 @@
{
"name": "pageSize",
"in": "query",
"description": "Rows per page (default 100); capped at 100 for this endpoint. The page offset is derived from the effective (capped) size, so a larger value narrows the page instead of widening the offset.",
"schema": {
"type": "integer",
"format": "int32",
@@ -10760,6 +10768,7 @@
{
"name": "pageNum",
"in": "query",
"description": "0-based page index: the first page is 0, not 1. A negative value is clamped to 0.",
"schema": {
"type": "integer",
"format": "int32",
@@ -10769,6 +10778,7 @@
{
"name": "pageSize",
"in": "query",
"description": "Rows per page (default 100); capped at 100 for this endpoint. The page offset is derived from the effective (capped) size, so a larger value narrows the page instead of widening the offset.",
"schema": {
"type": "integer",
"format": "int32",
@@ -12479,6 +12489,7 @@
{
"name": "pageNum",
"in": "query",
"description": "0-based page index: the first page is 0, not 1. A negative value is clamped to 0.",
"schema": {
"type": "integer",
"format": "int32",
@@ -12488,6 +12499,7 @@
{
"name": "pageSize",
"in": "query",
"description": "Rows per page (default 100); capped at 100 for this endpoint. The page offset is derived from the effective (capped) size, so a larger value narrows the page instead of widening the offset.",
"schema": {
"type": "integer",
"format": "int32",
@@ -13075,6 +13087,7 @@
{
"name": "pageNum",
"in": "query",
"description": "0-based page index: the first page is 0, not 1. A negative value is clamped to 0.",
"schema": {
"type": "integer",
"format": "int32",
@@ -13084,6 +13097,7 @@
{
"name": "pageSize",
"in": "query",
"description": "Rows per page (default 100); capped at 100 for this endpoint. The page offset is derived from the effective (capped) size, so a larger value narrows the page instead of widening the offset.",
"schema": {
"type": "integer",
"format": "int32",
@@ -14040,6 +14054,7 @@
{
"name": "pageNum",
"in": "query",
"description": "0-based page index: the first page is 0, not 1. A negative value is clamped to 0.",
"schema": {
"type": "integer",
"format": "int32",
@@ -14049,6 +14064,7 @@
{
"name": "pageSize",
"in": "query",
"description": "Rows per page (default 100); capped at 100 for this endpoint. The page offset is derived from the effective (capped) size, so a larger value narrows the page instead of widening the offset.",
"schema": {
"type": "integer",
"format": "int32",
@@ -15533,6 +15549,7 @@
{
"name": "pageNum",
"in": "query",
"description": "0-based page index: the first page is 0, not 1. A negative value is clamped to 0.",
"schema": {
"type": "integer",
"format": "int32",
@@ -15542,6 +15559,7 @@
{
"name": "pageSize",
"in": "query",
"description": "Rows per page (default 100); capped at 100 for this endpoint. The page offset is derived from the effective (capped) size, so a larger value narrows the page instead of widening the offset.",
"schema": {
"type": "integer",
"format": "int32",
@@ -17351,6 +17369,7 @@
{
"name": "pageNum",
"in": "query",
"description": "0-based page index: the first page is 0, not 1. A negative value is clamped to 0.",
"schema": {
"type": "integer",
"format": "int32",
@@ -17360,6 +17379,7 @@
{
"name": "pageSize",
"in": "query",
"description": "Rows per page (default 50); capped at 100 for this endpoint. The page offset is derived from the effective (capped) size, so a larger value narrows the page instead of widening the offset.",
"schema": {
"type": "integer",
"format": "int32",
@@ -17454,6 +17474,7 @@
{
"name": "pageNum",
"in": "query",
"description": "0-based page index: the first page is 0, not 1. A negative value is clamped to 0. Unlike the other paged endpoints this one is also bounded ABOVE, at 2000000, so that pageNum * pageSize cannot overflow; a larger value is clamped down to that maximum rather than rejected.",
"schema": {
"type": "integer",
"format": "int32",
@@ -17463,6 +17484,7 @@
{
"name": "pageSize",
"in": "query",
"description": "Rows per page (default 500); capped at 1000 for this endpoint. The page offset is derived from the effective (capped) size, so a larger value narrows the page instead of widening the offset.",
"schema": {
"type": "integer",
"format": "int32",
@@ -21042,6 +21064,7 @@
{
"name": "pageNum",
"in": "query",
"description": "0-based page index: the first page is 0, not 1. A negative value is clamped to 0.",
"schema": {
"type": "integer",
"format": "int32",
@@ -21051,6 +21074,7 @@
{
"name": "pageSize",
"in": "query",
"description": "Rows per page (default 100); capped at 100 for this endpoint. The page offset is derived from the effective (capped) size, so a larger value narrows the page instead of widening the offset.",
"schema": {
"type": "integer",
"format": "int32",
+8 -1
View File
@@ -46,7 +46,14 @@ Exemplars:
narrower pages — it never widens the offset. Say "0-based" in the description of any paging
parameter you expose, including on wrapper surfaces like the MCP tool catalog: describing it as
1-based makes a caller skip the first page silently, which reads as data loss rather than as an
off-by-one (ersatztv#616). See `api.paging-zero-based`.
off-by-one (ersatztv#616). Put that description on the parameter itself with
`[Description("...")]` (`System.ComponentModel`, on the `[FromQuery]` parameter) so it reaches the
generated OpenAPI document — an attribute-free paging parameter is emitted with no description at
all, leaving a REST consumer to infer the base from `default: 0` (ersatztv#633). State the
endpoint's **own** cap, never one global number: the caps differ (100 typical, 200 auto-tune
members, 1000 `search/all-items`). `OpenApiPagingContractTests` pins this and names the expected
set of paged operations, so a new paged endpoint fails until it is added there **with**
descriptions. See `api.paging-zero-based`.
- **Sortable GET with allow-listed sort params**: same file — `sortField`/`sortDirection` are
normalized against a fixed allow-list (`AllowedSortFields`) rather than trusted or rejected with
a 422: an unrecognized `sortField` silently falls back to the default field, an unrecognized
@@ -1,6 +1,6 @@
---
key: api.paging-zero-based
title: 2026-07-25 — Paging is 0-based everywhere; every wrapper must say so (OpenAPI still doesn't) (#616)
title: 2026-07-25 — Paging is 0-based everywhere; every wrapper must say so (#616, #633)
status: active
since: '2026-07-25'
supersedes: none
@@ -42,11 +42,22 @@ dozen controllers and the SPA, and a 1-based wrapper over a 0-based API would ma
parameter name* mean different things on two surfaces a reader routinely reads together — trading a
documented off-by-one for an undocumented one. Accuracy in the description is the cheaper contract.
**Where "0-based" is stated, and where it still isn't.** The MCP tool catalog and these docs say it
explicitly. The generated OpenAPI `pageNum` parameters carry **no description at all** (12 of them),
so a REST consumer reading only `v1.json` still has to infer the base from the default — a real
remaining gap, tracked separately rather than fixed here. Treat "every wrapper says 0-based" as the
target this record sets, not a property already true of the OpenAPI surface.
**Where "0-based" is stated.** The MCP tool catalog, these docs, and — since #633 — the generated
OpenAPI document all say it explicitly. All 24 paging parameters across the 12 paged operations carry
a `[Description]` (`System.ComponentModel`, on the `[FromQuery]` parameter, the same mechanism
`parentId` already used), so a REST consumer reading only `v1.json` no longer has to infer the base
from `default: 0` — which is the inference that cost #487 a verification pass on the MCP side, where
the description was present but wrong. `pageSize` descriptions state the endpoint's own cap and that
the offset derives from the effective size, never a single global number.
That sweep is pinned by `OpenApiPagingContractTests` against the in-process generated document. The
test names the expected set of 12 operations rather than only filtering for parameters called
`pageNum`: a filter cannot see an endpoint that *should* page and doesn't, so set-equality is
asserted in both directions — a new paged endpoint fails until it is added with descriptions, and an
endpoint that quietly drops paging fails too. Both directions are mutation-verified. The residual
gap this cannot close is a brand-new endpoint that returns a page while declaring no paging
parameters at all under any name; nothing in the document distinguishes that from an unpaged
endpoint, so it stays a review concern.
**Corollary — ids in paged rows.** A row that names a related entity should expose that entity's id,
not only its display fields, wherever a caller is expected to act on that entity. This is a rule about