Skip to content
Open
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
120 changes: 120 additions & 0 deletions PAGINATION.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,120 @@
# Pagination Support

## Overview

The MythAPI now supports pagination for all list-returning endpoints. This improves performance and usability when dealing with large datasets.

## Usage

### Query Parameters

All list endpoints now accept optional pagination parameters:

- `page` - The page number (1-based). Default: returns all results if not specified
- `pageSize` - The number of items per page. Default: 10, Maximum: 100

### Examples

#### Without Pagination (Backward Compatible)
```bash
# Returns all gods
GET /api/v1/gods

# Returns all mythologies
GET /api/v1/mythologies
```

#### With Pagination
```bash
# Returns first page with 10 items (default page size)
GET /api/v1/gods?page=1

Copilot AI Feb 18, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot uses AI. Check for mistakes.

# Returns second page with 5 items per page
GET /api/v1/gods?page=2&pageSize=5

# Returns first page of mythologies with 20 items
GET /api/v1/mythologies?page=1&pageSize=20
```

## Response Format

### Non-Paginated Response
When pagination parameters are not provided, the response is a simple list:
```json
[
{
"id": 1,
"name": "Zeus",
"description": "God of the sky",
"mythologyId": 1,
"aliases": []
},
...
]
```

### Paginated Response
When pagination parameters are provided, the response includes metadata:
```json
{
"items": [
{
"id": 1,
"name": "Zeus",
"description": "God of the sky",
"mythologyId": 1,
"aliases": []
},
...
],
"page": 1,
"pageSize": 10,
"totalCount": 42,
"totalPages": 5,
"hasNext": true,
"hasPrevious": false
}
```

### Response Fields

- `items` - Array of items for the current page
- `page` - Current page number (1-based)
- `pageSize` - Number of items per page
- `totalCount` - Total number of items across all pages
- `totalPages` - Total number of pages available
- `hasNext` - Boolean indicating if there's a next page
- `hasPrevious` - Boolean indicating if there's a previous page

## Validation

The API automatically validates and corrects invalid pagination parameters:

- `page` values less than 1 are automatically set to 1
- `pageSize` values greater than 100 are automatically capped at 100
- `pageSize` values less than 1 are automatically set to the default (10)

## Endpoints Supporting Pagination

The following endpoints support pagination:

1. **GET /api/v1/gods** - List all gods
2. **GET /api/v1/mythologies** - List all mythologies

## Implementation Notes

- Pagination is implemented at the database level for optimal performance
- Uses Entity Framework's `Skip()` and `Take()` methods
- Eager loading is used to avoid N+1 query problems
- Results are ordered by ID to ensure consistent pagination

## Backward Compatibility

Existing clients that don't provide pagination parameters will continue to work as before, receiving the complete list of items. This ensures no breaking changes for existing integrations.

## Best Practices

1. **Use appropriate page sizes**: Balance between reducing the number of requests and keeping response sizes manageable
2. **Handle edge cases**: Always check `hasNext` and `hasPrevious` before navigating
3. **Cache when possible**: If data doesn't change frequently, consider caching paginated results
4. **Consistent parameters**: Use the same page size across related requests for better caching
15 changes: 15 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -488,6 +488,21 @@ app.UseSwaggerUI();
app.Run();
```

# Features

## Pagination Support

All list-returning endpoints now support optional pagination to improve performance and usability. See [PAGINATION.md](PAGINATION.md) for detailed documentation.

### Quick Example
```bash
# Get first page with 10 items
GET /api/v1/gods?page=1&pageSize=10

# Get all items (backward compatible)
GET /api/v1/gods
```

# Future work

- Improve Bicep scripts
Expand Down
43 changes: 43 additions & 0 deletions src/Common/Models/PagedResult.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
namespace MythApi.Common.Models;

/// <summary>
/// Represents a paginated result set
/// </summary>
/// <typeparam name="T">The type of items in the result</typeparam>
public class PagedResult<T>
{
/// <summary>
/// The items in the current page
/// </summary>
public IList<T> Items { get; set; } = new List<T>();

/// <summary>
/// Current page number (1-based)
/// </summary>
public int Page { get; set; }

/// <summary>
/// Number of items per page
/// </summary>
public int PageSize { get; set; }

/// <summary>
/// Total number of items across all pages
/// </summary>
public int TotalCount { get; set; }

/// <summary>
/// Total number of pages
/// </summary>
public int TotalPages => (int)Math.Ceiling(TotalCount / (double)PageSize);

Copilot AI Feb 18, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Suggested change
public int TotalPages => (int)Math.Ceiling(TotalCount / (double)PageSize);
public int TotalPages => PageSize <= 0 ? 0 : (int)Math.Ceiling(TotalCount / (double)PageSize);

Copilot uses AI. Check for mistakes.

/// <summary>
/// Whether there is a previous page
/// </summary>
public bool HasPrevious => Page > 1;

/// <summary>
/// Whether there is a next page
/// </summary>
public bool HasNext => Page < TotalPages;

Copilot AI Feb 18, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Suggested change
public bool HasNext => Page < TotalPages;
public bool HasNext => TotalCount > 0 && Page < TotalPages;

Copilot uses AI. Check for mistakes.
}
36 changes: 36 additions & 0 deletions src/Common/Models/PaginationParameters.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
namespace MythApi.Common.Models;

/// <summary>
/// Parameters for pagination requests
/// </summary>
public class PaginationParameters
{
private const int MaxPageSize = 100;
private const int DefaultPageSize = 10;

private int _pageSize = DefaultPageSize;
private int _page = 1;

/// <summary>
/// Page number (1-based)
/// </summary>
public int Page
{
get => _page;
set => _page = value < 1 ? 1 : value;
}

/// <summary>
/// Number of items per page (default: 10, max: 100)
/// </summary>
public int PageSize
{
get => _pageSize;
set => _pageSize = value > MaxPageSize ? MaxPageSize : (value < 1 ? DefaultPageSize : value);
}

/// <summary>
/// Calculate the number of items to skip
/// </summary>
public int Skip => (Page - 1) * PageSize;
}
Comment on lines +1 to +36

Copilot AI Feb 18, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The PR description claims "3 unit tests for parameter validation logic," but there are no dedicated unit tests for PaginationParameters validation. The existing unit tests only verify the endpoint behavior, not the parameter validation logic itself (e.g., page < 1 → 1, pageSize > 100 → 100, pageSize < 1 → 10). Consider adding unit tests specifically for PaginationParameters to verify the validation setters work correctly.

Copilot uses AI. Check for mistakes.
26 changes: 24 additions & 2 deletions src/Endpoints/v1/Gods.cs
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
using Microsoft.AspNetCore.Mvc;
using MythApi.Gods.Interfaces;
using MythApi.Common.Database.Models;
using MythApi.Common.Models;
using MythApi.Gods.Models;

namespace MythApi.Endpoints.v1;
Expand All @@ -10,13 +11,34 @@ public static void RegisterGodEndpoints(this IEndpointRouteBuilder endpoints) {
var gods = endpoints.MapGroup("/api/v1/gods");


gods.MapGet("", GetAlllGods);
gods.MapGet("", GetAllGods);
gods.MapGet("{id}", (int id, IGodRepository repository) => repository.GetGodAsync(new GodParameter(id)));
gods.MapGet("search/{name}", (string name, IGodRepository repository, [FromQuery] bool includeAliases = false) => repository.GetGodByNameAsync(new GodByNameParameter(name, includeAliases)));
gods.MapPost("", AddOrUpdateGods);
}

public static Task<List<God>> AddOrUpdateGods(List<GodInput> gods, IGodRepository repository) => repository.AddOrUpdateGods(gods);

public static Task<IList<God>> GetAlllGods(IGodRepository repository) => repository.GetAllGodsAsync();
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);
}
Comment on lines +27 to +32

Copilot AI Feb 18, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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?

Copilot uses AI. Check for mistakes.

// Use pagination
var pagination = new PaginationParameters
{
Page = page ?? 1,
PageSize = pageSize ?? 10
};

var pagedResult = await repository.GetAllGodsAsync(pagination);
return Results.Ok(pagedResult);
}
Comment on lines +22 to +43

Copilot AI Feb 18, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot uses AI. Check for mistakes.
}
25 changes: 24 additions & 1 deletion src/Endpoints/v1/Mythologies.cs
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
using Microsoft.AspNetCore.Mvc;
using MythApi.Common.Database.Models;
using MythApi.Common.Models;
using MythApi.Mythologies.Interfaces;

Copilot AI Feb 18, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Missing namespace declaration. The Gods.cs file has 'namespace MythApi.Endpoints.v1;' but this file is missing a namespace declaration entirely. This violates codebase conventions as seen in src/Endpoints/v1/Gods.cs:7. Add 'namespace MythApi.Endpoints.v1;' at the beginning of the file.

Suggested change
namespace MythApi.Endpoints.v1;

Copilot uses AI. Check for mistakes.
public static class Mythologies
Expand All @@ -10,5 +12,26 @@ public static void RegisterMythologiesEndpoints(this IEndpointRouteBuilder endpo
mythologies.MapGet("", GetAllMythologies);
}

public static Task<IList<Mythology>> GetAllMythologies(IMythologyRepository repository) => repository.GetAllMythologiesAsync();
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
Comment on lines +20 to +27

Copilot AI Feb 18, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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?

Suggested change
// 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

Copilot uses AI. Check for mistakes.
var pagination = new PaginationParameters
{
Page = page ?? 1,
PageSize = pageSize ?? 10
};

var pagedResult = await repository.GetAllMythologiesAsync(pagination);
return Results.Ok(pagedResult);
}
Comment on lines +15 to +36

Copilot AI Feb 18, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot uses AI. Check for mistakes.
}
29 changes: 24 additions & 5 deletions src/Gods/DBRepositories/GodRepository.cs
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
using MythApi.Gods.Interfaces;
using MythApi.Common.Database.Models;
using MythApi.Common.Database;
using MythApi.Common.Models;
using MythApi.Gods.Models;

namespace MythApi.Gods.DBRepositories;
Expand Down Expand Up @@ -44,14 +45,32 @@ public async Task<List<God>> AddOrUpdateGods(List<GodInput> gods)

public async Task<IList<God>> GetAllGodsAsync()
{
var gods = await _context.Gods.ToListAsync();
foreach (var god in gods)
{
_context.Entry(god).Collection(x => x.Aliases).Load();
}
var gods = await _context.Gods
.Include(g => g.Aliases)
.ToListAsync();
return gods;
}

public async Task<PagedResult<God>> GetAllGodsAsync(PaginationParameters pagination)
{
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();
Comment on lines +56 to +63

Copilot AI Feb 18, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot uses AI. Check for mistakes.

return new PagedResult<God>
{
Items = gods,
Page = pagination.Page,
PageSize = pagination.PageSize,
TotalCount = totalCount
};
}

public async Task<God> GetGodAsync(GodParameter parameter)
{
return await _context.Gods.FirstAsync(x => x.Id == parameter.Id);
Expand Down
3 changes: 3 additions & 0 deletions src/Gods/Interfaces/IGodRepository.cs
Original file line number Diff line number Diff line change
@@ -1,11 +1,14 @@
using MythApi.Common.Database.Models;
using MythApi.Common.Models;
using MythApi.Gods.Models;

namespace MythApi.Gods.Interfaces;

public interface IGodRepository{
public Task<IList<God>> GetAllGodsAsync();

public Task<PagedResult<God>> GetAllGodsAsync(PaginationParameters pagination);

public Task<God> GetGodAsync(GodParameter parameter);

public Task<List<God>> GetGodByNameAsync(GodByNameParameter parameter);
Expand Down
18 changes: 18 additions & 0 deletions src/Gods/Mocks/GodRepository.cs
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
using MythApi.Gods.Interfaces;
using MythApi.Common.Database.Models;
using MythApi.Common.Models;
using MythApi.Gods.Models;

namespace MythApi.Gods.Mocks;
Expand Down Expand Up @@ -40,6 +41,23 @@ public Task<IList<God>> GetAllGodsAsync()
return Task.FromResult(gods as IList<God>);
}

public Task<PagedResult<God>> GetAllGodsAsync(PaginationParameters pagination)
{
var totalCount = gods.Count;
var items = gods

Copilot AI Feb 18, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Suggested change
var items = gods
var items = gods
.OrderBy(g => g.Id)

Copilot uses AI. Check for mistakes.
.Skip(pagination.Skip)
.Take(pagination.PageSize)
.ToList();

return Task.FromResult(new PagedResult<God>
{
Items = items,
Page = pagination.Page,
PageSize = pagination.PageSize,
TotalCount = totalCount
});
}

public Task<God> GetGodAsync(GodParameter parameter)
{
return Task.FromResult(gods[parameter.Id]);
Expand Down
Loading