API CRUD Endpoints — Posts Controller with Validation and Authorization

The Posts CRUD API is the backbone of the BlogApp — it serves the public blog listing, drives the admin management interface, and demonstrates all the patterns established in Parts 1–6 working together. The controller is thin (parameter binding and response shaping), the service handles business logic (ownership checks, slug uniqueness, soft delete cascade), and EF Core handles the database operations with proper projections to avoid over-fetching.

Posts Controller — Full CRUD

[ApiController, Route("api/posts")]
public class PostsController : ControllerBase
{
    private readonly IPostsService _posts;
    private readonly ICurrentUserService _currentUser;

    // ── GET /api/posts — public, paginated ────────────────────────────────
    [HttpGet]
    public async Task<ActionResult<PagedResult<PostSummaryDto>>> GetPublished(
        [FromQuery] int    page     = 1,
        [FromQuery] int    size     = 10,
        [FromQuery] string? category = null,
        [FromQuery] string? search   = null,
        CancellationToken ct = default)
    {
        var result = await _posts.GetPublishedAsync(page, size, category, search, ct);
        return Ok(result);
    }

    // ── GET /api/posts/{slug} — public detail ─────────────────────────────
    [HttpGet("{slug}")]
    public async Task<ActionResult<PostDto>> GetBySlug(string slug, CancellationToken ct)
    {
        var post = await _posts.GetBySlugAsync(slug, ct);
        return post is null ? NotFound() : Ok(post);
    }

    // ── POST /api/posts — create [Authorize] ──────────────────────────────
    [HttpPost, Authorize]
    public async Task<ActionResult<PostDto>> Create(
        CreatePostRequest request, CancellationToken ct)
    {
        request = request with { AuthorId = _currentUser.UserId! };
        var created = await _posts.CreateAsync(request, ct);
        return CreatedAtAction(nameof(GetBySlug),
            new { slug = created.Slug }, created);
        // Response: 201 Created
        // Location: /api/posts/my-new-post
    }

    // ── PUT /api/posts/{id} — update [Authorize] ──────────────────────────
    [HttpPut("{id:int}"), Authorize]
    public async Task<ActionResult<PostDto>> Update(
        int id, UpdatePostRequest request,
        [FromHeader(Name = "If-Match")] string? ifMatch,   // optimistic concurrency ETag
        CancellationToken ct)
    {
        if (!await _posts.IsAuthorOrAdminAsync(id, _currentUser.UserId!, ct))
            return Forbid();

        try
        {
            var updated = await _posts.UpdateAsync(id, request, ifMatch, ct);
            Response.Headers.ETag = Convert.ToBase64String(updated.RowVersion);
            return Ok(updated);
        }
        catch (ConcurrencyConflictException)
        {
            return StatusCode(412, new { message = "Post was modified by another user." });
        }
    }

    // ── DELETE /api/posts/{id} — soft delete [Authorize] ─────────────────
    [HttpDelete("{id:int}"), Authorize]
    public async Task<IActionResult> Delete(int id, CancellationToken ct)
    {
        if (!await _posts.IsAuthorOrAdminAsync(id, _currentUser.UserId!, ct))
            return Forbid();

        await _posts.SoftDeleteAsync(id, ct);
        return NoContent();  // 204 No Content
    }

    // ── GET /api/posts/{id}/related ────────────────────────────────────────
    [HttpGet("{id:int}/related")]
    public async Task<ActionResult<IReadOnlyList<PostSummaryDto>>> GetRelated(
        int id, CancellationToken ct)
        => Ok(await _posts.GetRelatedAsync(id, 5, ct));
}

// ── PagedResult envelope ───────────────────────────────────────────────────
public record PagedResult<T>(
    IReadOnlyList<T> Items,
    int Total,
    int Page,
    int PageSize,
    bool HasNextPage,
    bool HasPrevPage
) {
    public static PagedResult<T> Create(IReadOnlyList<T> items, int total,
                                         int page, int pageSize) =>
        new(items, total, page, pageSize,
            page * pageSize < total,
            page > 1);
}
Note: CreatedAtAction(nameof(GetBySlug), new { slug = created.Slug }, created) returns HTTP 201 Created with a Location header pointing to the new resource (/api/posts/my-new-post). This is the correct REST response for a successful POST — the client knows exactly where to find the created resource without needing to search. The nameof(GetBySlug) ensures the Location URL stays correct even if the route template changes — it uses the route from the action method rather than a hardcoded string.
Tip: Return the ETag header on all GET and PUT responses for resources that support optimistic concurrency. The Angular client stores this ETag and sends it back as If-Match on subsequent PUT requests. This enables the full HTTP conditional request pattern — the server can return 412 Precondition Failed when the resource has changed since the client last read it. Include the ETag on GET responses too (not just PUT) so clients that fetch a post for editing already have the ETag without needing a separate request.
Warning: Always validate ownership before allowing updates or deletes. A JWT with an authenticated user ID does not guarantee the user owns the resource. The IsAuthorOrAdminAsync() check queries the database to confirm the post’s AuthorId matches the current user’s ID (or the user has the Admin role). Without this check, any authenticated user could delete or modify any other user’s posts — an insecure direct object reference (IDOR) vulnerability, one of the OWASP Top 10.

Common Mistakes

Mistake 1 — No ownership check on update/delete (IDOR vulnerability)

❌ Wrong — [Authorize] only; any authenticated user can update/delete any post by ID.

✅ Correct — check IsAuthorOrAdminAsync(); return 403 Forbid if the user doesn’t own the resource.

Mistake 2 — Returning 200 instead of 201 for POST (incorrect REST semantics)

❌ Wrong — return Ok(created) from a POST endpoint; should be 201 with Location header.

✅ Correct — return CreatedAtAction(...) generates 201 Created with the correct Location header.

🧠 Test Yourself

The DELETE endpoint returns 204 No Content. Why doesn’t it return 200 OK with the deleted post data?