fix(633): document the 0-based paging contract on the OpenAPI parameters #646
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user