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));
}
}
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.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).[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.