Skip to content
Merged
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
173 changes: 173 additions & 0 deletions JsonApiToolkit.Tests/Integration/StrictPaginationIntegrationTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,173 @@
using System.Net;
using System.Text.Json;
using JsonApiToolkit.Extensions;
using JsonApiToolkit.Models.Documents;
using JsonApiToolkit.Models.Errors;
using JsonApiToolkit.Models.Resources;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Hosting;
using Microsoft.AspNetCore.TestHost;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;

namespace JsonApiToolkit.Tests.Integration;

/// <summary>
/// Integration tests for runtime strict-pagination behavior (page &gt; totalPages → 404).
/// Re-uses the QueryTestArticle/QueryTestDbContext fixtures from JsonApiQueryAsyncTests.
/// </summary>
public class StrictPaginationIntegrationTests : IDisposable
{
private readonly IHost _host;
private readonly HttpClient _client;
private readonly JsonSerializerOptions _jsonOptions = new()
{
PropertyNameCaseInsensitive = true,
};

public StrictPaginationIntegrationTests()
{
var databaseName = $"StrictPaginationTestDb_{Guid.NewGuid()}";

_host = new HostBuilder()
.ConfigureWebHost(webBuilder =>
{
webBuilder
.UseTestServer()
.ConfigureServices(services =>
{
services.AddDbContext<QueryTestDbContext>(options =>
options.UseInMemoryDatabase(databaseName)
);
services.AddControllers();
services.AddJsonApiToolkit(options =>
{
options.StrictPagination = true;
});
})
.Configure(app =>
{
app.UseRouting();
app.UseEndpoints(endpoints => endpoints.MapControllers());

using var scope = app.ApplicationServices.CreateScope();
var context =
scope.ServiceProvider.GetRequiredService<QueryTestDbContext>();
SeedFiveArticles(context);
});
})
.Build();

_host.Start();
_client = _host.GetTestClient();
}

private static void SeedFiveArticles(QueryTestDbContext context)
{
for (int i = 1; i <= 5; i++)
{
context.Articles.Add(
new QueryTestArticle
{
Id = i,
Title = $"Article {i}",
Content = $"Content {i}",
CreatedAt = new DateTime(2024, 1, i),
IsPublished = true,
ViewCount = i * 10,
}
);
}
context.SaveChanges();
}

[Fact]
public async Task PageBeyondTotal_Returns404Async()
{
// 5 articles, page size 2 → 3 total pages. Page 100 must 404.
var response = await _client.GetAsync("/api/articles?page[number]=100&page[size]=2");

Assert.Equal(HttpStatusCode.NotFound, response.StatusCode);
}

[Fact]
public async Task PageBeyondTotal_ErrorBodyHasMetaAsync()
{
var response = await _client.GetAsync("/api/articles?page[number]=10&page[size]=2");

Assert.Equal(HttpStatusCode.NotFound, response.StatusCode);

var content = await response.Content.ReadAsStringAsync();
var doc = JsonSerializer.Deserialize<JsonApiErrorResponse>(content, _jsonOptions);

Assert.NotNull(doc?.Errors);
var error = Assert.Single(doc.Errors);
Assert.Equal("404", error.Status);
Assert.Equal(JsonApiErrorCodes.InvalidPageNumber, error.Code);
Assert.Equal("page[number]", error.Source?.Parameter);
Assert.NotNull(error.Meta);
Assert.Equal(10, GetIntFromMeta(error.Meta, "value"));
Assert.Equal(3, GetIntFromMeta(error.Meta, "totalPages"));
Assert.Equal(5, GetIntFromMeta(error.Meta, "totalResources"));
}

[Fact]
public async Task LastPage_Returns200Async()
{
// Exactly the last page must succeed (boundary check: > not >=).
var response = await _client.GetAsync("/api/articles?page[number]=3&page[size]=2&sort=id");

Assert.Equal(HttpStatusCode.OK, response.StatusCode);

var content = await response.Content.ReadAsStringAsync();
var doc = JsonSerializer.Deserialize<JsonApiCollectionDocument<ResourceObject>>(
content,
_jsonOptions
);

Assert.NotNull(doc?.Data);
Assert.Single(doc.Data);
Assert.Equal("5", doc.Data.First().Id);
}

[Fact]
public async Task EmptyResultWithPaging_DoesNotReturn404Async()
{
// Filter that matches no rows. With totalCount=0, strict mode must not 404 page=2 —
// there are no pages to be wrong about.
var response = await _client.GetAsync(
"/api/articles?filter[title]=NoSuchArticle&page[number]=2&page[size]=10"
);

Assert.Equal(HttpStatusCode.OK, response.StatusCode);

var content = await response.Content.ReadAsStringAsync();
var doc = JsonSerializer.Deserialize<JsonApiCollectionDocument<ResourceObject>>(
content,
_jsonOptions
);

Assert.NotNull(doc?.Data);
Assert.Empty(doc.Data);
}

private static int GetIntFromMeta(Dictionary<string, object> meta, string key)
{
Assert.True(meta.TryGetValue(key, out var raw), $"Missing meta key '{key}'");
return raw switch
{
JsonElement e => e.GetInt32(),
int i => i,
long l => (int)l,
_ => Convert.ToInt32(raw),
};
}

public void Dispose()
{
_client.Dispose();
_host.Dispose();
GC.SuppressFinalize(this);
}
}
20 changes: 20 additions & 0 deletions JsonApiToolkit/Controllers/JsonApiController.cs
Original file line number Diff line number Diff line change
Expand Up @@ -206,6 +206,26 @@ string resourceType
int totalCount = await filteredQuery.CountAsync().ConfigureAwait(false);
LogCountResults<T>(parameters, totalCount);

if (Options.StrictPagination && parameters.Pagination != null && totalCount > 0)
{
int totalPages = (int)Math.Ceiling(totalCount / (double)parameters.Pagination.Size);
if (parameters.Pagination.Number > totalPages)
{
throw new JsonApiNotFoundException(
$"Page {parameters.Pagination.Number} does not exist. "
+ $"This collection has {totalPages} page(s). Request a page between 1 and {totalPages}.",
JsonApiErrorCodes.InvalidPageNumber,
new ErrorSource { Parameter = "page[number]" },
new Dictionary<string, object>
{
["value"] = parameters.Pagination.Number,
["totalPages"] = totalPages,
["totalResources"] = totalCount,
}
);
}
}

if (parameters.Pagination != null)
filteredQuery = filteredQuery.ApplyPagination(parameters.Pagination, totalCount);

Expand Down
7 changes: 6 additions & 1 deletion docs/docs/security.md
Original file line number Diff line number Diff line change
Expand Up @@ -183,15 +183,20 @@ builder.Services.AddJsonApiToolkit(options => {

### Behavior

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

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

After the count is computed, the following also returns **404 Not Found**:

- `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).

When disabled (default), these values are silently clamped:

- `page[number]` less than 1 becomes 1
- `page[number]` greater than total pages becomes the last page
- `page[size]` exceeding `MaxPageSize` becomes `MaxPageSize`

### Error Response
Expand Down
33 changes: 32 additions & 1 deletion docs/docs/upgrade-guide.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,38 @@

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

**Current Version:** 2.0.0
**Current Version:** 2.1.0

---

## v2.1.0 - Strict Pagination (runtime)

**New Behavior:**
`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.

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:

```json
{
"errors": [{
"status": "404",
"code": "INVALID_PAGE_NUMBER",
"detail": "Page 10 does not exist. This collection has 3 page(s). Request a page between 1 and 3.",
"source": { "parameter": "page[number]" },
"meta": {
"value": 10,
"totalPages": 3,
"totalResources": 5
}
}]
}
```

**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.

**Default behavior (`StrictPagination = false`) is unchanged**: out-of-range page numbers are clamped to the last page.

**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.

---

Expand Down
Loading