HTTP GET Patterns — Collections, Single Resources and Filtering

GET is the most common HTTP method in a REST API. A well-designed set of GET endpoints covers every data access pattern: listing resources with pagination, retrieving a specific resource by its identifier, filtering and searching, and navigating to related resources. Each pattern has established conventions that Angular clients and other consumers expect. Implementing them consistently makes the API predictable — developers learn the pattern once and apply it across all resources.

GET Patterns

[ApiController]
[Route("api/posts")]
public class PostsController(IPostService service) : ControllerBase
{
    // ── Paginated list with optional filtering ────────────────────────────
    // GET /api/posts?page=2&size=10&category=tech&search=dotnet
    [HttpGet]
    public async Task<ActionResult<PagedResult<PostSummaryDto>>> GetAll(
        [FromQuery] int    page     = 1,
        [FromQuery] int    size     = 10,
        [FromQuery] string category = "",
        [FromQuery] string search   = "",
        [FromQuery] string sort     = "newest",   // newest, oldest, popular
        CancellationToken ct = default)
    {
        var request = new GetPostsQuery(page, size, category, search, sort);
        return Ok(await service.GetPageAsync(request, ct));
    }

    // ── Get by integer ID ─────────────────────────────────────────────────
    // GET /api/posts/42
    [HttpGet("{id:int}", Name = "GetPostById")]
    public async Task<ActionResult<PostDto>> GetById(int id, CancellationToken ct)
    {
        var post = await service.GetByIdAsync(id, ct);
        return post is null ? NotFound() : Ok(post);
    }

    // ── Get by slug (alternative key) ─────────────────────────────────────
    // GET /api/posts/by-slug/my-post-title
    [HttpGet("by-slug/{slug}")]
    public async Task<ActionResult<PostDto>> GetBySlug(string slug, CancellationToken ct)
    {
        var post = await service.GetBySlugAsync(slug, ct);
        return post is null ? NotFound() : Ok(post);
    }

    // ── Get related resources — nested route ──────────────────────────────
    // GET /api/posts/42/comments?page=1&size=20
    [HttpGet("{id:int}/comments")]
    public async Task<ActionResult<PagedResult<CommentDto>>> GetComments(
        int id,
        [FromQuery] int page = 1,
        [FromQuery] int size = 20,
        CancellationToken ct = default)
    {
        var post = await service.GetByIdAsync(id, ct);
        if (post is null) return NotFound();
        return Ok(await service.GetCommentsAsync(id, page, size, ct));
    }
}
Note: The PagedResult<T> return type should include enough metadata for the Angular client to render pagination UI without additional API calls: { items: [...], page: 2, pageSize: 10, total: 150, totalPages: 15, hasNextPage: true, hasPreviousPage: true }. The client reads totalPages to render page buttons and hasNextPage/hasPreviousPage to enable/disable navigation arrows. Including this in the response is the standard pattern — never require the client to calculate total pages from total and pageSize.
Tip: For large datasets (millions of records), offset-based pagination (page=2&size=10 = skip 20) performs poorly because the database must skip all previous records. Cursor-based pagination uses the last item’s ID as the cursor: GET /api/posts?after=42&size=10 returns 10 posts after ID 42. The response includes a nextCursor field the client uses for the next page. Cursor pagination is stable (consistent results if data is inserted/deleted between pages) and fast (the database uses an indexed seek on the cursor value).
Warning: Never expose raw database IDs in URLs without considering sequential enumeration attacks. If post IDs are sequential integers (1, 2, 3…), an attacker can enumerate all posts by incrementing the ID. Mitigation strategies: use UUIDs/GUIDs as primary keys (non-guessable), add [Authorize] to restrict access to non-public resources, or use a separate public-facing slug that does not reveal the database structure.

Cursor-Based Pagination

// ── Cursor pagination response ─────────────────────────────────────────────
public record CursorPagedResult<T>(
    IReadOnlyList<T> Items,
    string?          NextCursor,    // null if last page
    string?          PrevCursor,    // null if first page
    bool             HasMore);

// GET /api/posts?after=42&size=10
[HttpGet]
public async Task<ActionResult<CursorPagedResult<PostSummaryDto>>> GetAll(
    [FromQuery] int? after  = null,   // cursor: last seen post ID
    [FromQuery] int  size   = 10,
    CancellationToken ct = default)
{
    var posts  = await service.GetAfterAsync(after, size + 1, ct);  // fetch one extra
    var hasMore = posts.Count > size;
    var items   = hasMore ? posts.Take(size).ToList() : posts;
    var nextCursor = hasMore ? items[^1].Id.ToString() : null;
    return Ok(new CursorPagedResult<PostSummaryDto>(items, nextCursor, null, hasMore));
}

Common Mistakes

Mistake 1 — Not validating pagination parameters (negative page, size=10000)

❌ Wrong — no validation; attacker requests size=100000 causing OOM or slow query:

public async Task<IActionResult> GetAll([FromQuery] int page, [FromQuery] int size) { }

✅ Correct — add [Range(1, 100)] int size = 10 with DataAnnotations; [ApiController] auto-validates.

Mistake 2 — Returning empty list vs 404 for collections (wrong semantics)

❌ Wrong — returning 404 when a filtered collection has no results (the collection exists, just empty).

✅ Correct — return 200 with an empty array for filtered collections with no matches; 404 only when the resource itself (e.g., the post for nested /comments) does not exist.

🧠 Test Yourself

GET /api/posts?category=nonexistent finds no posts for that category. Should it return 200 or 404?