From 52a2c1bb2709f24c3adfe9c20580e69f1782f66e Mon Sep 17 00:00:00 2001 From: Erlend Ellefsen Date: Fri, 21 Aug 2026 12:50:09 +0200 Subject: [PATCH] feat: preserve query string in pagination links (opt-in) --- .../PaginationLinkPreservationTests.cs | 145 ++++++++++++++++++ .../Configuration/JsonApiOptions.cs | 8 + .../Controllers/JsonApiController.cs | 9 +- JsonApiToolkit/Mapping/JsonApiMapper.cs | 56 ++++++- .../contract/pagination_contract_test.ts | 28 ++++ docs/querying.md | 6 + samples/ContractApi/Program.cs | 4 +- 7 files changed, 244 insertions(+), 12 deletions(-) create mode 100644 JsonApiToolkit.Tests/Integration/PaginationLinkPreservationTests.cs diff --git a/JsonApiToolkit.Tests/Integration/PaginationLinkPreservationTests.cs b/JsonApiToolkit.Tests/Integration/PaginationLinkPreservationTests.cs new file mode 100644 index 0000000..6ce1fe1 --- /dev/null +++ b/JsonApiToolkit.Tests/Integration/PaginationLinkPreservationTests.cs @@ -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; + +/// +/// 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. +/// +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(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(); + 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> 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); + } +} diff --git a/JsonApiToolkit/Configuration/JsonApiOptions.cs b/JsonApiToolkit/Configuration/JsonApiOptions.cs index 0fa6cd2..c0d231a 100644 --- a/JsonApiToolkit/Configuration/JsonApiOptions.cs +++ b/JsonApiToolkit/Configuration/JsonApiOptions.cs @@ -63,6 +63,14 @@ public class JsonApiOptions /// public bool StrictQueryValidation { get; set; } + /// + /// 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). + /// + public bool PreserveQueryInPaginationLinks { get; set; } + /// /// 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 diff --git a/JsonApiToolkit/Controllers/JsonApiController.cs b/JsonApiToolkit/Controllers/JsonApiController.cs index 05dc4de..339a5d3 100644 --- a/JsonApiToolkit/Controllers/JsonApiController.cs +++ b/JsonApiToolkit/Controllers/JsonApiController.cs @@ -178,7 +178,8 @@ protected IActionResult JsonApiOk( paginationMeta, mappedIncludes, Logger, - parameters.Fields + parameters.Fields, + Options.PreserveQueryInPaginationLinks ); return Ok(document); } @@ -254,7 +255,8 @@ string resourceType paginationMeta, mappedIncludes, Logger, - parameters.Fields + parameters.Fields, + Options.PreserveQueryInPaginationLinks ); return Ok(document); @@ -589,7 +591,8 @@ QueryParameters parameters paginationMeta, mappedIncludes, Logger, - parameters.Fields + parameters.Fields, + Options.PreserveQueryInPaginationLinks ); return Ok(projectedDocument); diff --git a/JsonApiToolkit/Mapping/JsonApiMapper.cs b/JsonApiToolkit/Mapping/JsonApiMapper.cs index a929d0f..cb938e8 100644 --- a/JsonApiToolkit/Mapping/JsonApiMapper.cs +++ b/JsonApiToolkit/Mapping/JsonApiMapper.cs @@ -265,6 +265,7 @@ public static JsonApiDocument ToDocument( /// Optional list of relationship paths to include. /// Optional logger for debugging and tracing /// Optional sparse fieldsets per resource type + /// When true, pagination links keep the full query string with only the page parameters replaced /// The JSON:API collection document. public static JsonApiCollectionDocument ToCollectionDocument( IEnumerable entities, @@ -273,7 +274,8 @@ public static JsonApiCollectionDocument ToCollectionDocument( PaginationMeta? paginationMeta = null, List? includedRelationships = null, ILogger? logger = null, - Dictionary>? fields = null + Dictionary>? fields = null, + bool preserveQueryInLinks = false ) where T : class { @@ -321,20 +323,20 @@ public static JsonApiCollectionDocument ToCollectionDocument( 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); } } @@ -375,4 +377,42 @@ public static JsonApiCollectionDocument ToCollectionDocument( return document; } + + /// + /// Builds a pagination link. Default: bare path + page params only (legacy). + /// With : the original query string with only + /// the page parameters replaced, so filters/sort/include/fields survive. + /// + 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; + } } diff --git a/clients/typescript/contract/pagination_contract_test.ts b/clients/typescript/contract/pagination_contract_test.ts index 003eaff..61f256c 100644 --- a/clients/typescript/contract/pagination_contract_test.ts +++ b/clients/typescript/contract/pagination_contract_test.ts @@ -6,6 +6,7 @@ import { assert, assertEquals, assertFalse } from '@std/assert'; import { BASE_URL, getDoc, + PUBLISHED_ARTICLES, STRICT_BASE_URL, total, TOTAL_ARTICLES, @@ -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 + }); + }, +); diff --git a/docs/querying.md b/docs/querying.md index 2489c20..554f5ca 100644 --- a/docs/querying.md +++ b/docs/querying.md @@ -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: diff --git a/samples/ContractApi/Program.cs b/samples/ContractApi/Program.cs index 8e29620..515e25d 100644 --- a/samples/ContractApi/Program.cs +++ b/samples/ContractApi/Program.cs @@ -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("JSONAPI_STRICT"); o.StrictPagination = strict; o.StrictQueryValidation = strict; + o.PreserveQueryInPaginationLinks = strict; }); var app = builder.Build();