Skip to content

Commit b639fdd

Browse files
feat(pagination): return 404 when page exceeds total pages in strict mode (#126)
- Adds runtime enforcement to `StrictPagination`: when enabled and a request asks for a page beyond the total page count, the controller returns 404 with `INVALID_PAGE_NUMBER` and meta showing value/totalPages/totalResources. - Empty result sets do not 404 (no pages exist to be wrong about). - Default behavior (`StrictPagination = false`) is unchanged — clamping preserved. - 4 new integration tests covering 404 boundary, last-page boundary, error body shape, and empty-result edge.
1 parent 8405bf8 commit b639fdd

4 files changed

Lines changed: 231 additions & 2 deletions

File tree

Lines changed: 173 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,173 @@
1+
using System.Net;
2+
using System.Text.Json;
3+
using JsonApiToolkit.Extensions;
4+
using JsonApiToolkit.Models.Documents;
5+
using JsonApiToolkit.Models.Errors;
6+
using JsonApiToolkit.Models.Resources;
7+
using Microsoft.AspNetCore.Builder;
8+
using Microsoft.AspNetCore.Hosting;
9+
using Microsoft.AspNetCore.TestHost;
10+
using Microsoft.EntityFrameworkCore;
11+
using Microsoft.Extensions.DependencyInjection;
12+
using Microsoft.Extensions.Hosting;
13+
14+
namespace JsonApiToolkit.Tests.Integration;
15+
16+
/// <summary>
17+
/// Integration tests for runtime strict-pagination behavior (page &gt; totalPages → 404).
18+
/// Re-uses the QueryTestArticle/QueryTestDbContext fixtures from JsonApiQueryAsyncTests.
19+
/// </summary>
20+
public class StrictPaginationIntegrationTests : IDisposable
21+
{
22+
private readonly IHost _host;
23+
private readonly HttpClient _client;
24+
private readonly JsonSerializerOptions _jsonOptions = new()
25+
{
26+
PropertyNameCaseInsensitive = true,
27+
};
28+
29+
public StrictPaginationIntegrationTests()
30+
{
31+
var databaseName = $"StrictPaginationTestDb_{Guid.NewGuid()}";
32+
33+
_host = new HostBuilder()
34+
.ConfigureWebHost(webBuilder =>
35+
{
36+
webBuilder
37+
.UseTestServer()
38+
.ConfigureServices(services =>
39+
{
40+
services.AddDbContext<QueryTestDbContext>(options =>
41+
options.UseInMemoryDatabase(databaseName)
42+
);
43+
services.AddControllers();
44+
services.AddJsonApiToolkit(options =>
45+
{
46+
options.StrictPagination = true;
47+
});
48+
})
49+
.Configure(app =>
50+
{
51+
app.UseRouting();
52+
app.UseEndpoints(endpoints => endpoints.MapControllers());
53+
54+
using var scope = app.ApplicationServices.CreateScope();
55+
var context =
56+
scope.ServiceProvider.GetRequiredService<QueryTestDbContext>();
57+
SeedFiveArticles(context);
58+
});
59+
})
60+
.Build();
61+
62+
_host.Start();
63+
_client = _host.GetTestClient();
64+
}
65+
66+
private static void SeedFiveArticles(QueryTestDbContext context)
67+
{
68+
for (int i = 1; i <= 5; i++)
69+
{
70+
context.Articles.Add(
71+
new QueryTestArticle
72+
{
73+
Id = i,
74+
Title = $"Article {i}",
75+
Content = $"Content {i}",
76+
CreatedAt = new DateTime(2024, 1, i),
77+
IsPublished = true,
78+
ViewCount = i * 10,
79+
}
80+
);
81+
}
82+
context.SaveChanges();
83+
}
84+
85+
[Fact]
86+
public async Task PageBeyondTotal_Returns404Async()
87+
{
88+
// 5 articles, page size 2 → 3 total pages. Page 100 must 404.
89+
var response = await _client.GetAsync("/api/articles?page[number]=100&page[size]=2");
90+
91+
Assert.Equal(HttpStatusCode.NotFound, response.StatusCode);
92+
}
93+
94+
[Fact]
95+
public async Task PageBeyondTotal_ErrorBodyHasMetaAsync()
96+
{
97+
var response = await _client.GetAsync("/api/articles?page[number]=10&page[size]=2");
98+
99+
Assert.Equal(HttpStatusCode.NotFound, response.StatusCode);
100+
101+
var content = await response.Content.ReadAsStringAsync();
102+
var doc = JsonSerializer.Deserialize<JsonApiErrorResponse>(content, _jsonOptions);
103+
104+
Assert.NotNull(doc?.Errors);
105+
var error = Assert.Single(doc.Errors);
106+
Assert.Equal("404", error.Status);
107+
Assert.Equal(JsonApiErrorCodes.InvalidPageNumber, error.Code);
108+
Assert.Equal("page[number]", error.Source?.Parameter);
109+
Assert.NotNull(error.Meta);
110+
Assert.Equal(10, GetIntFromMeta(error.Meta, "value"));
111+
Assert.Equal(3, GetIntFromMeta(error.Meta, "totalPages"));
112+
Assert.Equal(5, GetIntFromMeta(error.Meta, "totalResources"));
113+
}
114+
115+
[Fact]
116+
public async Task LastPage_Returns200Async()
117+
{
118+
// Exactly the last page must succeed (boundary check: > not >=).
119+
var response = await _client.GetAsync("/api/articles?page[number]=3&page[size]=2&sort=id");
120+
121+
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
122+
123+
var content = await response.Content.ReadAsStringAsync();
124+
var doc = JsonSerializer.Deserialize<JsonApiCollectionDocument<ResourceObject>>(
125+
content,
126+
_jsonOptions
127+
);
128+
129+
Assert.NotNull(doc?.Data);
130+
Assert.Single(doc.Data);
131+
Assert.Equal("5", doc.Data.First().Id);
132+
}
133+
134+
[Fact]
135+
public async Task EmptyResultWithPaging_DoesNotReturn404Async()
136+
{
137+
// Filter that matches no rows. With totalCount=0, strict mode must not 404 page=2 —
138+
// there are no pages to be wrong about.
139+
var response = await _client.GetAsync(
140+
"/api/articles?filter[title]=NoSuchArticle&page[number]=2&page[size]=10"
141+
);
142+
143+
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
144+
145+
var content = await response.Content.ReadAsStringAsync();
146+
var doc = JsonSerializer.Deserialize<JsonApiCollectionDocument<ResourceObject>>(
147+
content,
148+
_jsonOptions
149+
);
150+
151+
Assert.NotNull(doc?.Data);
152+
Assert.Empty(doc.Data);
153+
}
154+
155+
private static int GetIntFromMeta(Dictionary<string, object> meta, string key)
156+
{
157+
Assert.True(meta.TryGetValue(key, out var raw), $"Missing meta key '{key}'");
158+
return raw switch
159+
{
160+
JsonElement e => e.GetInt32(),
161+
int i => i,
162+
long l => (int)l,
163+
_ => Convert.ToInt32(raw),
164+
};
165+
}
166+
167+
public void Dispose()
168+
{
169+
_client.Dispose();
170+
_host.Dispose();
171+
GC.SuppressFinalize(this);
172+
}
173+
}

JsonApiToolkit/Controllers/JsonApiController.cs

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -206,6 +206,26 @@ string resourceType
206206
int totalCount = await filteredQuery.CountAsync().ConfigureAwait(false);
207207
LogCountResults<T>(parameters, totalCount);
208208

209+
if (Options.StrictPagination && parameters.Pagination != null && totalCount > 0)
210+
{
211+
int totalPages = (int)Math.Ceiling(totalCount / (double)parameters.Pagination.Size);
212+
if (parameters.Pagination.Number > totalPages)
213+
{
214+
throw new JsonApiNotFoundException(
215+
$"Page {parameters.Pagination.Number} does not exist. "
216+
+ $"This collection has {totalPages} page(s). Request a page between 1 and {totalPages}.",
217+
JsonApiErrorCodes.InvalidPageNumber,
218+
new ErrorSource { Parameter = "page[number]" },
219+
new Dictionary<string, object>
220+
{
221+
["value"] = parameters.Pagination.Number,
222+
["totalPages"] = totalPages,
223+
["totalResources"] = totalCount,
224+
}
225+
);
226+
}
227+
}
228+
209229
if (parameters.Pagination != null)
210230
filteredQuery = filteredQuery.ApplyPagination(parameters.Pagination, totalCount);
211231

docs/docs/security.md

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -183,15 +183,20 @@ builder.Services.AddJsonApiToolkit(options => {
183183

184184
### Behavior
185185

186-
When `StrictPagination` is enabled, the following values are rejected with 400 Bad Request:
186+
When `StrictPagination` is enabled, the following values are rejected with 400 Bad Request at parse time:
187187

188188
- `page[number]` less than 1 (e.g., `page[number]=0` or `page[number]=-5`)
189189
- `page[size]` less than 1 (e.g., `page[size]=0` or `page[size]=-10`)
190190
- `page[size]` exceeding `MaxPageSize` (e.g., `page[size]=200` when `MaxPageSize=100`)
191191

192+
After the count is computed, the following also returns **404 Not Found**:
193+
194+
- `page[number]` greater than the total page count (e.g., `page[number]=100` for a result set with 3 pages). Returns no 404 when the result set is empty (no pages exist).
195+
192196
When disabled (default), these values are silently clamped:
193197

194198
- `page[number]` less than 1 becomes 1
199+
- `page[number]` greater than total pages becomes the last page
195200
- `page[size]` exceeding `MaxPageSize` becomes `MaxPageSize`
196201

197202
### Error Response

docs/docs/upgrade-guide.md

Lines changed: 32 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,38 @@
22

33
This document tracks all breaking changes, new features, and migration steps for each version of JsonApiToolkit.
44

5-
**Current Version:** 2.0.0
5+
**Current Version:** 2.1.0
6+
7+
---
8+
9+
## v2.1.0 - Strict Pagination (runtime)
10+
11+
**New Behavior:**
12+
`StrictPagination` now also rejects requests for a page that does not exist (page number greater than the total number of pages). Previously these requests were clamped to the last page.
13+
14+
When `StrictPagination = true` and the requested `page[number]` exceeds the total page count for the resolved query, the controller returns **404 Not Found** with a JSON:API error document:
15+
16+
```json
17+
{
18+
"errors": [{
19+
"status": "404",
20+
"code": "INVALID_PAGE_NUMBER",
21+
"detail": "Page 10 does not exist. This collection has 3 page(s). Request a page between 1 and 3.",
22+
"source": { "parameter": "page[number]" },
23+
"meta": {
24+
"value": 10,
25+
"totalPages": 3,
26+
"totalResources": 5
27+
}
28+
}]
29+
}
30+
```
31+
32+
**Edge case:** When a query returns zero resources (for example, a filter that matches nothing), no 404 is raised regardless of the requested page number. There are no pages to be wrong about.
33+
34+
**Default behavior (`StrictPagination = false`) is unchanged**: out-of-range page numbers are clamped to the last page.
35+
36+
**Breaking Changes:** None. Behavior change is opt-in via `StrictPagination`. Parse-time `StrictPagination` errors (invalid page number, invalid page size, page size exceeds maximum) introduced earlier are unchanged.
637

738
---
839

0 commit comments

Comments
 (0)