Add pagination support to list endpoints#2
Conversation
Co-authored-by: moller2866 <83021349+moller2866@users.noreply.github.com>
Co-authored-by: moller2866 <83021349+moller2866@users.noreply.github.com>
Co-authored-by: moller2866 <83021349+moller2866@users.noreply.github.com>
Co-authored-by: moller2866 <83021349+moller2866@users.noreply.github.com>
There was a problem hiding this comment.
Pull request overview
This PR adds pagination support to the MythAPI's list endpoints to improve performance and scalability. The implementation introduces optional pagination via query parameters while maintaining backward compatibility by returning unpaginated results when no pagination parameters are provided.
Changes:
- Added
PaginationParametersandPagedResult<T>models for pagination support with auto-correction of invalid values - Extended repository interfaces and implementations with pagination overloads for both Gods and Mythologies
- Updated
/api/v1/godsand/api/v1/mythologiesendpoints to accept optionalpageandpageSizequery parameters - Added 2 unit tests and 8 integration tests covering pagination behavior
- Included comprehensive documentation in PAGINATION.md and updated README.md
- Fixed typo in endpoint method name (GetAlllGods → GetAllGods)
- Improved eager loading in GetAllGodsAsync using .Include() instead of manual loading
Reviewed changes
Copilot reviewed 13 out of 13 changed files in this pull request and generated 12 comments.
Show a summary per file
| File | Description |
|---|---|
| src/Common/Models/PaginationParameters.cs | New model for pagination parameters with validation (page ≥1, pageSize ≤100) |
| src/Common/Models/PagedResult.cs | New response envelope with items, metadata, and navigation properties |
| src/Gods/Interfaces/IGodRepository.cs | Added pagination overload to interface |
| src/Gods/DBRepositories/GodRepository.cs | Implemented pagination with Skip/Take and eager loading; improved non-paginated method |
| src/Gods/Mocks/GodRepository.cs | Added pagination support to mock repository |
| src/Mythologies/Interfaces/IMythologyRepository.cs | Added pagination overload to interface |
| src/Mythologies/DBRepository/MythologyRepository.cs | Implemented pagination with Skip/Take |
| src/Endpoints/v1/Gods.cs | Updated endpoint to handle optional pagination parameters; fixed method name typo |
| src/Endpoints/v1/Mythologies.cs | Updated endpoint to handle optional pagination parameters |
| tests/UnitTests/GodEndpointsTests.cs | Updated existing test and added new pagination test |
| tests/IntegrationTests/PaginationTests.cs | Added 8 integration tests for pagination edge cases |
| PAGINATION.md | Comprehensive documentation on pagination usage |
| README.md | Added reference to pagination feature |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| var totalCount = await _context.Gods.CountAsync(); | ||
|
|
||
| var gods = await _context.Gods | ||
| .Include(g => g.Aliases) | ||
| .OrderBy(g => g.Id) | ||
| .Skip(pagination.Skip) | ||
| .Take(pagination.PageSize) | ||
| .ToListAsync(); |
There was a problem hiding this comment.
The paginated query performs two separate database operations: one for counting total records (line 56) and one for fetching the page (lines 58-63). While this is a common pattern, it could have performance implications on large tables. For optimal performance under high load, consider whether the count operation could be cached or performed less frequently, especially if the total count doesn't change often. This is particularly important as the API scales.
| /// <summary> | ||
| /// Total number of pages | ||
| /// </summary> | ||
| public int TotalPages => (int)Math.Ceiling(TotalCount / (double)PageSize); |
There was a problem hiding this comment.
Division by zero will occur when PageSize is 0. While PaginationParameters validates PageSize to be at least 1, PagedResult doesn't validate the PageSize property when it's set directly (e.g., during deserialization or testing). Consider adding validation in the PageSize setter or defensive coding in TotalPages to prevent division by zero.
| public int TotalPages => (int)Math.Ceiling(TotalCount / (double)PageSize); | |
| public int TotalPages => PageSize <= 0 ? 0 : (int)Math.Ceiling(TotalCount / (double)PageSize); |
| // If pagination parameters are not provided, return all gods (backward compatibility) | ||
| if (page == null && pageSize == null) | ||
| { | ||
| var allGods = await repository.GetAllGodsAsync(); | ||
| return Results.Ok(allGods); | ||
| } |
There was a problem hiding this comment.
The backward compatibility logic has an inconsistency: providing only 'page' (without 'pageSize') or only 'pageSize' (without 'page') will trigger pagination, but the PR description states pagination should be optional. This means GET /api/v1/gods?page=1 will use pagination with default pageSize=10, while GET /api/v1/gods will return all results. This could be confusing for API consumers. Consider clarifying the intended behavior: should ANY pagination parameter trigger pagination, or should BOTH be required?
| // If pagination parameters are not provided, return all mythologies (backward compatibility) | ||
| if (page == null && pageSize == null) | ||
| { | ||
| var allMythologies = await repository.GetAllMythologiesAsync(); | ||
| return Results.Ok(allMythologies); | ||
| } | ||
|
|
||
| // Use pagination |
There was a problem hiding this comment.
The backward compatibility logic has an inconsistency: providing only 'page' (without 'pageSize') or only 'pageSize' (without 'page') will trigger pagination, but the PR description states pagination should be optional. This means GET /api/v1/mythologies?page=1 will use pagination with default pageSize=10, while GET /api/v1/mythologies will return all results. This could be confusing for API consumers. Consider clarifying the intended behavior: should ANY pagination parameter trigger pagination, or should BOTH be required?
| // If pagination parameters are not provided, return all mythologies (backward compatibility) | |
| if (page == null && pageSize == null) | |
| { | |
| var allMythologies = await repository.GetAllMythologiesAsync(); | |
| return Results.Ok(allMythologies); | |
| } | |
| // Use pagination | |
| // Backward compatibility: if neither pagination parameter is provided, return all mythologies | |
| if (page == null && pageSize == null) | |
| { | |
| var allMythologies = await repository.GetAllMythologiesAsync(); | |
| return Results.Ok(allMythologies); | |
| } | |
| // Use pagination whenever at least one pagination parameter is provided; default missing values |
| public Task<PagedResult<God>> GetAllGodsAsync(PaginationParameters pagination) | ||
| { | ||
| var totalCount = gods.Count; | ||
| var items = gods |
There was a problem hiding this comment.
The mock repository implementation doesn't maintain ordering consistency with the database implementation. The database implementation uses .OrderBy(g => g.Id) to ensure stable pagination, but the mock repository just uses the in-memory list order. This could cause inconsistent behavior in tests versus production. Add .OrderBy(g => g.Id) before Skip/Take to match the database implementation.
| var items = gods | |
| var items = gods | |
| .OrderBy(g => g.Id) |
| #### With Pagination | ||
| ```bash | ||
| # Returns first page with 10 items (default page size) | ||
| GET /api/v1/gods?page=1 |
There was a problem hiding this comment.
The documentation on line 30 shows GET /api/v1/gods?page=1 as an example, which contradicts the backward compatibility statement on line 113. According to the implementation, providing only 'page' (without 'pageSize') will trigger pagination with a default pageSize of 10, not return all results. Either update the example to include both parameters (GET /api/v1/gods?page=1&pageSize=10) or clarify in the documentation that providing ANY pagination parameter triggers pagination mode.
| @@ -31,9 +34,38 @@ public async Task GetAllGods_ShouldReturnAllGods() | |||
| }; | |||
| _mockRepository.Setup(repo => repo.GetAllGodsAsync()).ReturnsAsync(gods); | |||
|
|
|||
| var result = await MythApi.Endpoints.v1.Gods.GetAlllGods(_mockRepository.Object); | |||
| var result = await MythApi.Endpoints.v1.Gods.GetAllGods(_mockRepository.Object, null, null); | |||
|
|
|||
| Assert.That(result.Count, Is.EqualTo(2)); | |||
| Assert.That(result, Is.InstanceOf<Ok<IList<God>>>()); | |||
| var okResult = result as Ok<IList<God>>; | |||
| Assert.That(okResult, Is.Not.Null); | |||
| Assert.That(okResult!.Value.Count, Is.EqualTo(2)); | |||
| } | |||
|
|
|||
| [Test] | |||
| public async Task GetAllGods_WithPagination_ShouldReturnPagedResult() | |||
| { | |||
| var pagedResult = new PagedResult<God> | |||
| { | |||
| Items = new List<God> | |||
| { | |||
| new God { Name = "Zeus", MythologyId = 1, Description = "God of the sky" } | |||
| }, | |||
| Page = 1, | |||
| PageSize = 1, | |||
| TotalCount = 2 | |||
| }; | |||
| _mockRepository.Setup(repo => repo.GetAllGodsAsync(It.IsAny<PaginationParameters>())) | |||
| .ReturnsAsync(pagedResult); | |||
|
|
|||
| var result = await MythApi.Endpoints.v1.Gods.GetAllGods(_mockRepository.Object, 1, 1); | |||
|
|
|||
| Assert.That(result, Is.InstanceOf<Ok<PagedResult<God>>>()); | |||
| var okResult = result as Ok<PagedResult<God>>; | |||
| Assert.That(okResult, Is.Not.Null); | |||
| Assert.That(okResult!.Value.Items.Count, Is.EqualTo(1)); | |||
| Assert.That(okResult.Value.TotalCount, Is.EqualTo(2)); | |||
| Assert.That(okResult.Value.TotalPages, Is.EqualTo(2)); | |||
| } | |||
There was a problem hiding this comment.
The PR description claims "3 unit tests for parameter validation logic," but only 2 unit tests are added to GodEndpointsTests (GetAllGods_WithoutPagination_ShouldReturnAllGods and GetAllGods_WithPagination_ShouldReturnPagedResult). Additionally, these tests verify endpoint behavior, not the PaginationParameters validation logic itself. Consider adding dedicated unit tests for PaginationParameters class to verify the validation setters (e.g., page < 1 → 1, pageSize > 100 → 100, pageSize < 1 → 10).
| /// <summary> | ||
| /// Whether there is a next page | ||
| /// </summary> | ||
| public bool HasNext => Page < TotalPages; |
There was a problem hiding this comment.
The HasNext calculation (Page < TotalPages) will incorrectly return true when there are 0 items total. For example, if TotalCount=0 and PageSize=10, then TotalPages=0, and if Page=1, then HasNext would be 1 < 0 = false (correct). However, if Page=0 (which shouldn't happen but could if PagedResult is constructed without PaginationParameters), HasNext would be 0 < 0 = false. More critically, when TotalCount=0, TotalPages=0, and the current Page=1, the calculation is correct but the semantic meaning might be confusing. Consider adding a guard: HasNext => Page < TotalPages && TotalCount > 0 for clarity.
| public bool HasNext => Page < TotalPages; | |
| public bool HasNext => TotalCount > 0 && Page < TotalPages; |
| public static async Task<IResult> GetAllGods( | ||
| IGodRepository repository, | ||
| [FromQuery] int? page = null, | ||
| [FromQuery] int? pageSize = null) | ||
| { | ||
| // If pagination parameters are not provided, return all gods (backward compatibility) | ||
| if (page == null && pageSize == null) | ||
| { | ||
| var allGods = await repository.GetAllGodsAsync(); | ||
| return Results.Ok(allGods); | ||
| } | ||
|
|
||
| // Use pagination | ||
| var pagination = new PaginationParameters | ||
| { | ||
| Page = page ?? 1, | ||
| PageSize = pageSize ?? 10 | ||
| }; | ||
|
|
||
| var pagedResult = await repository.GetAllGodsAsync(pagination); | ||
| return Results.Ok(pagedResult); | ||
| } |
There was a problem hiding this comment.
The PR description mentions "Updates to OpenAPI/Swagger documentation to reflect changes" as part of the acceptance criteria, but there are no code changes to configure Swagger/OpenAPI annotations for the new pagination parameters. The endpoint signatures have changed, but without explicit Swagger attributes (e.g., ProducesResponseType, SwaggerOperation), the auto-generated Swagger documentation may not clearly show that endpoints can return either IList or PagedResult depending on parameters. Consider adding Swagger annotations to document the different response types and parameter descriptions.
| public static async Task<IResult> GetAllMythologies( | ||
| IMythologyRepository repository, | ||
| [FromQuery] int? page = null, | ||
| [FromQuery] int? pageSize = null) | ||
| { | ||
| // If pagination parameters are not provided, return all mythologies (backward compatibility) | ||
| if (page == null && pageSize == null) | ||
| { | ||
| var allMythologies = await repository.GetAllMythologiesAsync(); | ||
| return Results.Ok(allMythologies); | ||
| } | ||
|
|
||
| // Use pagination | ||
| var pagination = new PaginationParameters | ||
| { | ||
| Page = page ?? 1, | ||
| PageSize = pageSize ?? 10 | ||
| }; | ||
|
|
||
| var pagedResult = await repository.GetAllMythologiesAsync(pagination); | ||
| return Results.Ok(pagedResult); | ||
| } |
There was a problem hiding this comment.
The PR description mentions "Updates to OpenAPI/Swagger documentation to reflect changes" as part of the acceptance criteria, but there are no code changes to configure Swagger/OpenAPI annotations for the new pagination parameters. The endpoint signatures have changed, but without explicit Swagger attributes (e.g., ProducesResponseType, SwaggerOperation), the auto-generated Swagger documentation may not clearly show that endpoints can return either IList or PagedResult depending on parameters. Consider adding Swagger annotations to document the different response types and parameter descriptions.
List endpoints currently return unbounded results, creating performance and usability issues at scale.
Changes
Core models (
src/Common/Models/)PaginationParameters: Query parameter binding with validation (page ≥1, pageSize ≤100)PagedResult<T>: Response envelope with navigation metadataRepository layer
IGodRepositoryandIMythologyRepository.Include()to avoid N+1 queriesEndpoints (
/api/v1/gods,/api/v1/mythologies)pageandpageSizequery parametersPagedResult<T>when paginated,IList<T>otherwise (backward compatible)Tests
Example
Design notes
Original prompt
💡 You can make Copilot smarter by setting up custom instructions, customizing its development environment and configuring Model Context Protocol (MCP) servers. Learn more Copilot coding agent tips in the docs.