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
145 changes: 145 additions & 0 deletions JsonApiToolkit.Tests/Integration/PaginationLinkPreservationTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,145 @@
using System.Text.Json;
using JsonApiToolkit.Extensions;
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 PreserveQueryInPaginationLinks: first/last/prev/next
/// keep the request's query string with only the page parameters replaced.
/// Re-uses the QueryTest fixtures from JsonApiQueryAsyncTests.
/// </summary>
public class PaginationLinkPreservationTests : IDisposable
{
private readonly IHost _host;
private readonly HttpClient _client;

public PaginationLinkPreservationTests()
{
var databaseName = $"PaginationLinksTestDb_{Guid.NewGuid()}";

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

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

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

private static void SeedData(QueryTestDbContext context)
{
for (int i = 1; i <= 6; 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();
}

private async Task<Dictionary<string, string?>> GetLinksAsync(string url)
{
var response = await _client.GetAsync(url);
response.EnsureSuccessStatusCode();

using var doc = JsonDocument.Parse(await response.Content.ReadAsStringAsync());
return doc
.RootElement.GetProperty("links")
.EnumerateObject()
.ToDictionary(p => p.Name, p => p.Value.GetString());
}

[Fact]
public async Task Links_PreserveFilterAndSort_ReplaceOnlyPageParamsAsync()
{
var links = await GetLinksAsync(
"/api/articles?filter[isPublished]=true&sort=-viewCount&page[number]=2&page[size]=2"
);

// Keys are re-encoded by the link builder, so brackets appear as %5B/%5D
Assert.Equal(
"http://localhost/api/articles"
+ "?filter%5BisPublished%5D=true&sort=-viewCount&page%5Bnumber%5D=3&page%5Bsize%5D=2",
links["next"]
);
Assert.Contains("filter%5BisPublished%5D=true", links["prev"]);
Assert.Contains("page%5Bnumber%5D=1", links["prev"]);
Assert.Contains("page%5Bnumber%5D=1", links["first"]);
Assert.Contains("page%5Bnumber%5D=3", links["last"]);
}

[Fact]
public async Task Links_WithOnlyPageParams_ContainJustPageParamsAsync()
{
var links = await GetLinksAsync("/api/articles?page[number]=1&page[size]=2");

Assert.Equal(
"http://localhost/api/articles?page%5Bnumber%5D=2&page%5Bsize%5D=2",
links["next"]
);
Assert.False(links.ContainsKey("prev"));
}

[Fact]
public async Task NextLink_IsFollowable_AndKeepsFilterAppliedAsync()
{
var links = await GetLinksAsync(
"/api/articles?filter[viewCount][gt]=20&page[number]=1&page[size]=2"
);

// 4 matches (30..60), page size 2 -> next is page 2 with the filter intact
var next = new Uri(links["next"]!);
var response = await _client.GetAsync(next.PathAndQuery);
response.EnsureSuccessStatusCode();

using var doc = JsonDocument.Parse(await response.Content.ReadAsStringAsync());
var pagination = doc.RootElement.GetProperty("meta").GetProperty("pagination");
Assert.Equal(4, pagination.GetProperty("totalResources").GetInt32());
Assert.Equal(2, pagination.GetProperty("currentPage").GetInt32());
}

public void Dispose()
{
_client.Dispose();
_host.Dispose();
GC.SuppressFinalize(this);
}
}
8 changes: 8 additions & 0 deletions JsonApiToolkit/Configuration/JsonApiOptions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,14 @@ public class JsonApiOptions
/// </summary>
public bool StrictQueryValidation { get; set; }

/// <summary>
/// When true, pagination links (first/last/prev/next) preserve the request's full
/// query string (filter, sort, include, fields) with only the page parameters
/// replaced. Default: false (links are rebuilt from the bare path and drop all
/// other query parameters, for backwards compatibility).
/// </summary>
public bool PreserveQueryInPaginationLinks { get; set; }

/// <summary>
/// When true, applies database-level column filtering via EF Core Select() projection
/// when fields[type] is specified in the request. Only fetches requested columns from
Expand Down
9 changes: 6 additions & 3 deletions JsonApiToolkit/Controllers/JsonApiController.cs
Original file line number Diff line number Diff line change
Expand Up @@ -178,7 +178,8 @@ protected IActionResult JsonApiOk<T>(
paginationMeta,
mappedIncludes,
Logger,
parameters.Fields
parameters.Fields,
Options.PreserveQueryInPaginationLinks
);
return Ok(document);
}
Expand Down Expand Up @@ -254,7 +255,8 @@ string resourceType
paginationMeta,
mappedIncludes,
Logger,
parameters.Fields
parameters.Fields,
Options.PreserveQueryInPaginationLinks
);

return Ok(document);
Expand Down Expand Up @@ -589,7 +591,8 @@ QueryParameters parameters
paginationMeta,
mappedIncludes,
Logger,
parameters.Fields
parameters.Fields,
Options.PreserveQueryInPaginationLinks
);

return Ok(projectedDocument);
Expand Down
56 changes: 48 additions & 8 deletions JsonApiToolkit/Mapping/JsonApiMapper.cs
Original file line number Diff line number Diff line change
Expand Up @@ -265,6 +265,7 @@ public static JsonApiDocument<ResourceObject> ToDocument<T>(
/// <param name="includedRelationships">Optional list of relationship paths to include.</param>
/// <param name="logger">Optional logger for debugging and tracing</param>
/// <param name="fields">Optional sparse fieldsets per resource type</param>
/// <param name="preserveQueryInLinks">When true, pagination links keep the full query string with only the page parameters replaced</param>
/// <returns>The JSON:API collection document.</returns>
public static JsonApiCollectionDocument<ResourceObject> ToCollectionDocument<T>(
IEnumerable<T> entities,
Expand All @@ -273,7 +274,8 @@ public static JsonApiCollectionDocument<ResourceObject> ToCollectionDocument<T>(
PaginationMeta? paginationMeta = null,
List<string>? includedRelationships = null,
ILogger? logger = null,
Dictionary<string, List<string>>? fields = null
Dictionary<string, List<string>>? fields = null,
bool preserveQueryInLinks = false
)
where T : class
{
Expand Down Expand Up @@ -321,20 +323,20 @@ public static JsonApiCollectionDocument<ResourceObject> ToCollectionDocument<T>(

int pageSize = paginationMeta.PageSize;

document.Links.First = $"{baseUrl}?page[number]=1&page[size]={pageSize}";
document.Links.Last =
$"{baseUrl}?page[number]={paginationMeta.TotalPages}&page[size]={pageSize}";
string PageLink(int pageNumber) =>
BuildPaginationLink(baseUrl, selfLink, pageNumber, pageSize, preserveQueryInLinks);

document.Links.First = PageLink(1);
document.Links.Last = PageLink(paginationMeta.TotalPages);

if (paginationMeta.CurrentPage > 1)
{
document.Links.Prev =
$"{baseUrl}?page[number]={paginationMeta.CurrentPage - 1}&page[size]={pageSize}";
document.Links.Prev = PageLink(paginationMeta.CurrentPage - 1);
}

if (paginationMeta.CurrentPage < paginationMeta.TotalPages)
{
document.Links.Next =
$"{baseUrl}?page[number]={paginationMeta.CurrentPage + 1}&page[size]={pageSize}";
document.Links.Next = PageLink(paginationMeta.CurrentPage + 1);
}
}

Expand Down Expand Up @@ -375,4 +377,42 @@ public static JsonApiCollectionDocument<ResourceObject> ToCollectionDocument<T>(

return document;
}

/// <summary>
/// Builds a pagination link. Default: bare path + page params only (legacy).
/// With <paramref name="preserveQuery"/>: the original query string with only
/// the page parameters replaced, so filters/sort/include/fields survive.
/// </summary>
private static string BuildPaginationLink(
string baseUrl,
string selfLink,
int pageNumber,
int pageSize,
bool preserveQuery
)
{
if (!preserveQuery)
return $"{baseUrl}?page[number]={pageNumber}&page[size]={pageSize}";

int queryStart = selfLink.IndexOf('?');
string query = queryStart >= 0 ? selfLink[queryStart..] : string.Empty;

var builder = new Microsoft.AspNetCore.Http.Extensions.QueryBuilder();
foreach (var kvp in Microsoft.AspNetCore.WebUtilities.QueryHelpers.ParseQuery(query))
{
if (
kvp.Key.Equals("page[number]", StringComparison.OrdinalIgnoreCase)
|| kvp.Key.Equals("page[size]", StringComparison.OrdinalIgnoreCase)
)
continue;

foreach (string? value in kvp.Value)
builder.Add(kvp.Key, value ?? string.Empty);
}

builder.Add("page[number]", pageNumber.ToString());
builder.Add("page[size]", pageSize.ToString());

return baseUrl + builder.ToQueryString().Value;
}
}
28 changes: 28 additions & 0 deletions clients/typescript/contract/pagination_contract_test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import { assert, assertEquals, assertFalse } from '@std/assert';
import {
BASE_URL,
getDoc,
PUBLISHED_ARTICLES,
STRICT_BASE_URL,
total,
TOTAL_ARTICLES,
Expand Down Expand Up @@ -144,3 +145,30 @@ Deno.test('strict pagination (StrictPagination = true)', async (t) => {
},
);
});

Deno.test(
'pagination links with PreserveQueryInPaginationLinks (opt-in, strict instance)',
async (t) => {
await t.step('links keep the query, only page params change', async () => {
const { doc } = await getDoc(
'articles?filter%5Bpublished%5D=true&sort=-viewCount&page%5Bsize%5D=2',
STRICT_BASE_URL,
);
// keys are re-encoded by the link builder (%5B/%5D)
assertEquals(
doc.links.next,
`${STRICT_BASE_URL}/articles` +
'?filter%5Bpublished%5D=true&sort=-viewCount&page%5Bnumber%5D=2&page%5Bsize%5D=2',
);

// following next keeps the filtered, sorted result set
const next = new URL(doc.links.next);
const { doc: page2 } = await getDoc(
`articles${next.search}`,
STRICT_BASE_URL,
);
assertEquals(total(page2), PUBLISHED_ARTICLES);
assertEquals(page2.data[0].id, '21'); // -viewCount page 2: 21, 19
});
},
);
6 changes: 6 additions & 0 deletions docs/querying.md
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,12 @@ page[number]=2&page[size]=10

By default, invalid values are silently clamped (`page[number]=0` → 1, oversized → `MaxPageSize`). Enable `StrictPagination` to return errors instead. See [Security](security.md#strict-pagination).

Pagination links (`first`/`last`/`prev`/`next`) are rebuilt from the bare path by default and drop all other query parameters (filter, sort, include, fields), so do not follow them. Enable `PreserveQueryInPaginationLinks` to keep the full query string with only the page parameters replaced:

```csharp
builder.Services.AddJsonApiToolkit(options => options.PreserveQueryInPaginationLinks = true);
```

## Includes

`include=author,reviews` for top-level relationships, dot-separated for nesting:
Expand Down
4 changes: 3 additions & 1 deletion samples/ContractApi/Program.cs
Original file line number Diff line number Diff line change
Expand Up @@ -8,10 +8,12 @@
builder.Services.AddControllers();
builder.Services.AddJsonApiToolkit(o =>
{
// Contract tests run one fully strict instance and one default instance.
// Contract tests run one default instance and one instance with every
// opt-in behavior enabled.
bool strict = builder.Configuration.GetValue<bool>("JSONAPI_STRICT");
o.StrictPagination = strict;
o.StrictQueryValidation = strict;
o.PreserveQueryInPaginationLinks = strict;
});

var app = builder.Build();
Expand Down