diff --git a/PAGINATION.md b/PAGINATION.md
new file mode 100644
index 0000000..79739b2
--- /dev/null
+++ b/PAGINATION.md
@@ -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
+
+# 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
diff --git a/README.md b/README.md
index c666398..d086025 100644
--- a/README.md
+++ b/README.md
@@ -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
diff --git a/src/Common/Models/PagedResult.cs b/src/Common/Models/PagedResult.cs
new file mode 100644
index 0000000..4846525
--- /dev/null
+++ b/src/Common/Models/PagedResult.cs
@@ -0,0 +1,43 @@
+namespace MythApi.Common.Models;
+
+///
+/// Represents a paginated result set
+///
+/// The type of items in the result
+public class PagedResult
+{
+ ///
+ /// The items in the current page
+ ///
+ public IList Items { get; set; } = new List();
+
+ ///
+ /// Current page number (1-based)
+ ///
+ public int Page { get; set; }
+
+ ///
+ /// Number of items per page
+ ///
+ public int PageSize { get; set; }
+
+ ///
+ /// Total number of items across all pages
+ ///
+ public int TotalCount { get; set; }
+
+ ///
+ /// Total number of pages
+ ///
+ public int TotalPages => (int)Math.Ceiling(TotalCount / (double)PageSize);
+
+ ///
+ /// Whether there is a previous page
+ ///
+ public bool HasPrevious => Page > 1;
+
+ ///
+ /// Whether there is a next page
+ ///
+ public bool HasNext => Page < TotalPages;
+}
diff --git a/src/Common/Models/PaginationParameters.cs b/src/Common/Models/PaginationParameters.cs
new file mode 100644
index 0000000..eaa6e72
--- /dev/null
+++ b/src/Common/Models/PaginationParameters.cs
@@ -0,0 +1,36 @@
+namespace MythApi.Common.Models;
+
+///
+/// Parameters for pagination requests
+///
+public class PaginationParameters
+{
+ private const int MaxPageSize = 100;
+ private const int DefaultPageSize = 10;
+
+ private int _pageSize = DefaultPageSize;
+ private int _page = 1;
+
+ ///
+ /// Page number (1-based)
+ ///
+ public int Page
+ {
+ get => _page;
+ set => _page = value < 1 ? 1 : value;
+ }
+
+ ///
+ /// Number of items per page (default: 10, max: 100)
+ ///
+ public int PageSize
+ {
+ get => _pageSize;
+ set => _pageSize = value > MaxPageSize ? MaxPageSize : (value < 1 ? DefaultPageSize : value);
+ }
+
+ ///
+ /// Calculate the number of items to skip
+ ///
+ public int Skip => (Page - 1) * PageSize;
+}
diff --git a/src/Endpoints/v1/Gods.cs b/src/Endpoints/v1/Gods.cs
index a0352e2..a872883 100644
--- a/src/Endpoints/v1/Gods.cs
+++ b/src/Endpoints/v1/Gods.cs
@@ -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;
@@ -10,7 +11,7 @@ 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);
@@ -18,5 +19,26 @@ public static void RegisterGodEndpoints(this IEndpointRouteBuilder endpoints) {
public static Task> AddOrUpdateGods(List gods, IGodRepository repository) => repository.AddOrUpdateGods(gods);
- public static Task> GetAlllGods(IGodRepository repository) => repository.GetAllGodsAsync();
+ public static async Task 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);
+ }
}
\ No newline at end of file
diff --git a/src/Endpoints/v1/Mythologies.cs b/src/Endpoints/v1/Mythologies.cs
index 6add897..8488939 100644
--- a/src/Endpoints/v1/Mythologies.cs
+++ b/src/Endpoints/v1/Mythologies.cs
@@ -1,4 +1,6 @@
+using Microsoft.AspNetCore.Mvc;
using MythApi.Common.Database.Models;
+using MythApi.Common.Models;
using MythApi.Mythologies.Interfaces;
public static class Mythologies
@@ -10,5 +12,26 @@ public static void RegisterMythologiesEndpoints(this IEndpointRouteBuilder endpo
mythologies.MapGet("", GetAllMythologies);
}
- public static Task> GetAllMythologies(IMythologyRepository repository) => repository.GetAllMythologiesAsync();
+ public static async Task 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);
+ }
}
diff --git a/src/Gods/DBRepositories/GodRepository.cs b/src/Gods/DBRepositories/GodRepository.cs
index d2bef4e..76e5d72 100644
--- a/src/Gods/DBRepositories/GodRepository.cs
+++ b/src/Gods/DBRepositories/GodRepository.cs
@@ -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;
@@ -44,14 +45,32 @@ public async Task> AddOrUpdateGods(List gods)
public async Task> 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> 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();
+
+ return new PagedResult
+ {
+ Items = gods,
+ Page = pagination.Page,
+ PageSize = pagination.PageSize,
+ TotalCount = totalCount
+ };
+ }
+
public async Task GetGodAsync(GodParameter parameter)
{
return await _context.Gods.FirstAsync(x => x.Id == parameter.Id);
diff --git a/src/Gods/Interfaces/IGodRepository.cs b/src/Gods/Interfaces/IGodRepository.cs
index 4e25ac7..4f266b1 100644
--- a/src/Gods/Interfaces/IGodRepository.cs
+++ b/src/Gods/Interfaces/IGodRepository.cs
@@ -1,4 +1,5 @@
using MythApi.Common.Database.Models;
+using MythApi.Common.Models;
using MythApi.Gods.Models;
namespace MythApi.Gods.Interfaces;
@@ -6,6 +7,8 @@ namespace MythApi.Gods.Interfaces;
public interface IGodRepository{
public Task> GetAllGodsAsync();
+ public Task> GetAllGodsAsync(PaginationParameters pagination);
+
public Task GetGodAsync(GodParameter parameter);
public Task> GetGodByNameAsync(GodByNameParameter parameter);
diff --git a/src/Gods/Mocks/GodRepository.cs b/src/Gods/Mocks/GodRepository.cs
index 1ebaf63..6c7d576 100644
--- a/src/Gods/Mocks/GodRepository.cs
+++ b/src/Gods/Mocks/GodRepository.cs
@@ -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;
@@ -40,6 +41,23 @@ public Task> GetAllGodsAsync()
return Task.FromResult(gods as IList);
}
+ public Task> GetAllGodsAsync(PaginationParameters pagination)
+ {
+ var totalCount = gods.Count;
+ var items = gods
+ .Skip(pagination.Skip)
+ .Take(pagination.PageSize)
+ .ToList();
+
+ return Task.FromResult(new PagedResult
+ {
+ Items = items,
+ Page = pagination.Page,
+ PageSize = pagination.PageSize,
+ TotalCount = totalCount
+ });
+ }
+
public Task GetGodAsync(GodParameter parameter)
{
return Task.FromResult(gods[parameter.Id]);
diff --git a/src/Mythologies/DBRepository/MythologyRepository.cs b/src/Mythologies/DBRepository/MythologyRepository.cs
index fcad2ef..b582d2e 100644
--- a/src/Mythologies/DBRepository/MythologyRepository.cs
+++ b/src/Mythologies/DBRepository/MythologyRepository.cs
@@ -2,6 +2,7 @@
using Microsoft.EntityFrameworkCore;
using MythApi.Common.Database;
using MythApi.Common.Database.Models;
+using MythApi.Common.Models;
using MythApi.Mythologies.Interfaces;
namespace MythApi.Mythologies.DBRepositories;
@@ -19,4 +20,23 @@ public async Task> GetAllMythologiesAsync()
{
return await _context.Mythologies.ToListAsync();
}
+
+ public async Task> GetAllMythologiesAsync(PaginationParameters pagination)
+ {
+ var totalCount = await _context.Mythologies.CountAsync();
+
+ var mythologies = await _context.Mythologies
+ .OrderBy(m => m.Id)
+ .Skip(pagination.Skip)
+ .Take(pagination.PageSize)
+ .ToListAsync();
+
+ return new PagedResult
+ {
+ Items = mythologies,
+ Page = pagination.Page,
+ PageSize = pagination.PageSize,
+ TotalCount = totalCount
+ };
+ }
}
diff --git a/src/Mythologies/Interfaces/IMythologyRepository.cs b/src/Mythologies/Interfaces/IMythologyRepository.cs
index 1ab1858..b77656f 100644
--- a/src/Mythologies/Interfaces/IMythologyRepository.cs
+++ b/src/Mythologies/Interfaces/IMythologyRepository.cs
@@ -1,9 +1,12 @@
using MythApi.Common.Database.Models;
+using MythApi.Common.Models;
namespace MythApi.Mythologies.Interfaces;
public interface IMythologyRepository
{
public Task> GetAllMythologiesAsync();
+
+ public Task> GetAllMythologiesAsync(PaginationParameters pagination);
}
diff --git a/tests/IntegrationTests/PaginationTests.cs b/tests/IntegrationTests/PaginationTests.cs
new file mode 100644
index 0000000..359bfe6
--- /dev/null
+++ b/tests/IntegrationTests/PaginationTests.cs
@@ -0,0 +1,156 @@
+using System.Net.Http.Json;
+using MythApi.Common.Database.Models;
+using MythApi.Common.Models;
+
+namespace IntegrationTests;
+
+[TestFixture]
+public class PaginationTests
+{
+ private CustomWebApplicationFactory _factory;
+ private HttpClient _httpClient;
+
+ [SetUp]
+ public void SetUp()
+ {
+ _factory = new CustomWebApplicationFactory();
+ _httpClient = _factory.CreateClient();
+ }
+
+ [TearDown]
+ public void TearDown()
+ {
+ _httpClient.Dispose();
+ _factory.Dispose();
+ }
+
+ [Test]
+ public async Task GetAllGods_WithoutPagination_ShouldReturnList()
+ {
+ // Act
+ var response = await _httpClient.GetAsync("/api/v1/gods");
+
+ // Assert
+ Assert.That(response.IsSuccessStatusCode, Is.True);
+ var gods = await response.Content.ReadFromJsonAsync>();
+ Assert.That(gods, Is.Not.Null);
+ }
+
+ [Test]
+ public async Task GetAllGods_WithPagination_ShouldReturnPagedResult()
+ {
+ // Act
+ var response = await _httpClient.GetAsync("/api/v1/gods?page=1&pageSize=2");
+
+ // Assert
+ Assert.That(response.IsSuccessStatusCode, Is.True);
+ var pagedResult = await response.Content.ReadFromJsonAsync>();
+
+ Assert.That(pagedResult, Is.Not.Null);
+ Assert.That(pagedResult.Page, Is.EqualTo(1));
+ Assert.That(pagedResult.PageSize, Is.EqualTo(2));
+ Assert.That(pagedResult.Items.Count, Is.LessThanOrEqualTo(2));
+ Assert.That(pagedResult.TotalCount, Is.GreaterThan(0));
+ Assert.That(pagedResult.TotalPages, Is.GreaterThan(0));
+ }
+
+ [Test]
+ public async Task GetAllGods_WithLargePageSize_ShouldBeConstrainedToMaxSize()
+ {
+ // Act
+ var response = await _httpClient.GetAsync("/api/v1/gods?page=1&pageSize=200");
+
+ // Assert
+ Assert.That(response.IsSuccessStatusCode, Is.True);
+ var pagedResult = await response.Content.ReadFromJsonAsync>();
+
+ Assert.That(pagedResult, Is.Not.Null);
+ Assert.That(pagedResult.PageSize, Is.EqualTo(100), "PageSize should be constrained to max of 100");
+ }
+
+ [Test]
+ public async Task GetAllGods_WithInvalidPage_ShouldDefaultToPage1()
+ {
+ // Act
+ var response = await _httpClient.GetAsync("/api/v1/gods?page=0&pageSize=5");
+
+ // Assert
+ Assert.That(response.IsSuccessStatusCode, Is.True);
+ var pagedResult = await response.Content.ReadFromJsonAsync>();
+
+ Assert.That(pagedResult, Is.Not.Null);
+ Assert.That(pagedResult.Page, Is.EqualTo(1), "Page should default to 1 when invalid");
+ }
+
+ [Test]
+ public async Task GetAllMythologies_WithoutPagination_ShouldReturnList()
+ {
+ // Act
+ var response = await _httpClient.GetAsync("/api/v1/mythologies");
+
+ // Assert
+ Assert.That(response.IsSuccessStatusCode, Is.True);
+ var mythologies = await response.Content.ReadFromJsonAsync>();
+ Assert.That(mythologies, Is.Not.Null);
+ }
+
+ [Test]
+ public async Task GetAllMythologies_WithPagination_ShouldReturnPagedResult()
+ {
+ // Act
+ var response = await _httpClient.GetAsync("/api/v1/mythologies?page=1&pageSize=1");
+
+ // Assert
+ Assert.That(response.IsSuccessStatusCode, Is.True);
+ var pagedResult = await response.Content.ReadFromJsonAsync>();
+
+ Assert.That(pagedResult, Is.Not.Null);
+ Assert.That(pagedResult.Page, Is.EqualTo(1));
+ Assert.That(pagedResult.PageSize, Is.EqualTo(1));
+ Assert.That(pagedResult.Items.Count, Is.LessThanOrEqualTo(1));
+ Assert.That(pagedResult.TotalCount, Is.GreaterThan(0));
+ }
+
+ [Test]
+ public async Task GetAllGods_PaginationMetadata_ShouldBeCorrect()
+ {
+ // Get all gods first to know total count
+ var allResponse = await _httpClient.GetAsync("/api/v1/gods");
+ var allGods = await allResponse.Content.ReadFromJsonAsync>();
+ var totalCount = allGods?.Count ?? 0;
+
+ if (totalCount == 0)
+ {
+ Assert.Inconclusive("No gods in database to test pagination");
+ return;
+ }
+
+ // Act - Get first page with page size of 1
+ var response = await _httpClient.GetAsync("/api/v1/gods?page=1&pageSize=1");
+
+ // Assert
+ Assert.That(response.IsSuccessStatusCode, Is.True);
+ var pagedResult = await response.Content.ReadFromJsonAsync>();
+
+ Assert.That(pagedResult, Is.Not.Null);
+ Assert.That(pagedResult.TotalCount, Is.EqualTo(totalCount));
+ Assert.That(pagedResult.TotalPages, Is.EqualTo(totalCount));
+ Assert.That(pagedResult.HasNext, Is.EqualTo(totalCount > 1));
+ Assert.That(pagedResult.HasPrevious, Is.False);
+ }
+
+ [Test]
+ public async Task GetAllGods_PageBeyondTotalPages_ShouldReturnEmptyItems()
+ {
+ // Act - Request a page that's way beyond what exists
+ var response = await _httpClient.GetAsync("/api/v1/gods?page=999&pageSize=10");
+
+ // Assert
+ Assert.That(response.IsSuccessStatusCode, Is.True);
+ var pagedResult = await response.Content.ReadFromJsonAsync>();
+
+ Assert.That(pagedResult, Is.Not.Null);
+ Assert.That(pagedResult.Items.Count, Is.EqualTo(0));
+ Assert.That(pagedResult.HasNext, Is.False);
+ }
+}
diff --git a/tests/UnitTests/GodEndpointsTests.cs b/tests/UnitTests/GodEndpointsTests.cs
index 595ff47..fa1e908 100644
--- a/tests/UnitTests/GodEndpointsTests.cs
+++ b/tests/UnitTests/GodEndpointsTests.cs
@@ -1,6 +1,9 @@
+using Microsoft.AspNetCore.Http;
+using Microsoft.AspNetCore.Http.HttpResults;
using Microsoft.AspNetCore.Mvc;
using Moq;
using MythApi.Common.Database.Models;
+using MythApi.Common.Models;
using MythApi.Endpoints.v1;
using MythApi.Gods.Interfaces;
using MythApi.Gods.Models;
@@ -22,7 +25,7 @@ public void Setup()
}
[Test]
- public async Task GetAllGods_ShouldReturnAllGods()
+ public async Task GetAllGods_WithoutPagination_ShouldReturnAllGods()
{
var gods = new List
{
@@ -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>>());
+ var okResult = result as Ok>;
+ 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
+ {
+ Items = new List
+ {
+ new God { Name = "Zeus", MythologyId = 1, Description = "God of the sky" }
+ },
+ Page = 1,
+ PageSize = 1,
+ TotalCount = 2
+ };
+ _mockRepository.Setup(repo => repo.GetAllGodsAsync(It.IsAny()))
+ .ReturnsAsync(pagedResult);
+
+ var result = await MythApi.Endpoints.v1.Gods.GetAllGods(_mockRepository.Object, 1, 1);
+
+ Assert.That(result, Is.InstanceOf>>());
+ var okResult = result as Ok>;
+ 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));
}
[Test]