Day 1: Define RESTful Endpoints for Task Creation & Retrieval

What we will build today (and why)

Today we lay the cornerstone of the entire book: two REST endpoints for our Task resource.

  • POST /api/v1/tasks — create a task
  • GET /api/v1/tasks — retrieve a list of tasks

This sounds simple, but it’s where many APIs drift from REST and become hard to evolve. We’ll keep endpoints predictable, stateless, and correctly modeled so everything we add later (EF Core, JWT, CQRS, OpenTelemetry, Redis, gRPC/GraphQL) snaps cleanly into place.

You’ll learn:

  • How REST principles shape good endpoint design for Tasks
  • Resource URIs, verbs, status codes, content negotiation, and basic versioning
  • A production-leaning starting point (Minimal APIs) that we’ll evolve in later days

1) REST, but tailored to “Tasks”

1.1 Resource-first thinking

A RESTful API is about resources and representations. In our domain:

  • Resource: Task
  • Collection: /api/v1/tasks
  • Item: /api/v1/tasks/{taskId}

We avoid RPC naming like /createTask or /listAllTasks. Clients shouldn’t guess; they should recognize idioms:

  • POST /api/v1/tasks → create
  • GET /api/v1/tasks → list
  • GET /api/v1/tasks/{id} → fetch one
  • PUT /api/v1/tasks/{id} / PATCH /api/v1/tasks/{id} → update
  • DELETE /api/v1/tasks/{id} → delete

1.2 Statelessness

Each request carries everything needed (auth headers later, body for POST/PUT/PATCH, query params for filters later). No server-side session. This is key for horizontal scaling in containers/K8s.

1.3 Uniform interface & standard HTTP

We’ll follow these strictly:

  • HTTP methods for intent
  • Status codes for outcomes
  • Headers for negotiation & caching (later)
  • Body for representations (JSON via application/json)

1.4 Layered system & cacheability (preview)

Later we’ll add API gateway (YARP), CDN for static assets, and response/output caching. Today we’ll simply return proper cache-friendly semantics (e.g., 201 Created + Location).


2) Project setup (capstone-scoped)

Create the project:

dotnet new webapi -n TaskManagementAPI
cd TaskManagementAPI
# We’ll keep Swagger that the template adds (handy for testing),<br># and we’ll remove WeatherForecast scaffold.<br>

Delete the WeatherForecast controller & model to start clean.

Optional (keeping today minimal, but we’ll use them soon):

dotnet add package Serilog.AspNetCore
dotnet add package Swashbuckle.AspNetCore<br>

Routing & versioning approach:
We’ll bake versioning into the route (/api/v1) from day one (header-based versioning is coming in Chapter 4/Day 50). This avoids breaking contracts later.


3) Modeling a Task (request vs response)

A frequent mistake is exposing the exact DB entity on Day 1. Instead, define request/response contracts that you control. We’ll keep it simple and immutable for now.

// Models/TaskContracts.cs
namespace TaskManagementAPI.Models;

// What clients send to create a task
public record CreateTaskRequest(
    string Title,
    string? Description,
    DateTime DueDateUtc // prefer *Utc for backend contracts
);

// What the API returns
public record TaskResponse(
    Guid Id,
    string Title,
    string? Description,
    DateTime DueDateUtc,
    bool IsCompleted
);

Why split request vs response?

  • You own the wire contract (safe to evolve your DB without breaking clients).
  • You can validate incoming data separately (Day 8 with filters/FluentValidation).
  • You can shape responses for clients and evolve them across versions.

Time: Use Utc consistently. Localize on the client. This prevents timezone bugs and simplifies storage & comparisons.


4) Minimal APIs: clean, fast, and perfect for Day 1

We’ll start with Minimal APIs to keep the initial surface lean; later we’ll layer controllers/filters/CQRS. We’ll also simulate persistence with an in-memory list (EF Core arrives in Chapter 2).

// Program.cs
using TaskManagementAPI.Models;

var builder = WebApplication.CreateBuilder(args);
var app = builder.Build();

// In-memory store for Day 1 only
var _tasks = new List<TaskResponse>();

// POST /api/v1/tasks -> create a task
app.MapPost("/api/v1/tasks", (CreateTaskRequest input) =>
{
    // (Day 8 we’ll do proper validation; for now, basic guardrails)
    if (string.IsNullOrWhiteSpace(input.Title))
        return Results.BadRequest(new
        {
            error = "Title is required.",
            field = "title"
        });

    var task = new TaskResponse(
        Id: Guid.NewGuid(),
        Title: input.Title.Trim(),
        Description: string.IsNullOrWhiteSpace(input.Description) ? null : input.Description!.Trim(),
        DueDateUtc: input.DueDateUtc,
        IsCompleted: false
    );

    _tasks.Add(task);
    return Results.Created($"/api/v1/tasks/{task.Id}", task);
})
.WithName("CreateTask")
.WithTags("Tasks")
.Produces<TaskResponse>(StatusCodes.Status201Created)
.Produces(StatusCodes.Status400BadRequest);

// GET /api/v1/tasks -> list tasks
app.MapGet("/api/v1/tasks", () =>
{
    // Later we’ll add pagination/sorting/filtering
    return Results.Ok(_tasks);
})
.WithName("GetTasks")
.WithTags("Tasks")
.Produces<IEnumerable<TaskResponse>>(StatusCodes.Status200OK);

app.Run();

Why this is production-leaning (even for Day 1)

  • Explicit version in the route (/api/v1)—future-proofing.
  • Separate DTOs for request/response.
  • 201 + Location header on create.
  • Content negotiation honored by Minimal APIs (default JSON; we’ll configure later).
  • Tags & Produces metadata help Swagger and readers.

5) HTTP semantics done right (with examples)

5.1 Create a task

Request

POST /api/v1/tasks
Content-Type: application/json

{
  "title": "Kickoff Chapter 1",
  "description": "Write Day 1 content",
  "dueDateUtc": "2025-09-07T18:30:00Z"
}

Response

201 Created
Location: /api/v1/tasks/9f4c7f92-3d2a-4fbe-8c2b-9b869a48b0f0
Content-Type: application/json

{
  "id": "9f4c7f92-3d2a-4fbe-8c2b-9b869a48b0f0",
  "title": "Kickoff Chapter 1",
  "description": "Write Day 1 content",
  "dueDateUtc": "2025-09-07T18:30:00Z",
  "isCompleted": false
}

Why this matters

  • 201 Created says a new resource now exists.
  • Location identifies where.
  • Body returns the canonical representation.

5.2 Retrieve tasks

Request

GET /api/v1/tasks
Accept: application/json

Response

200 OK
Content-Type: application/json

[
  {
    "id": "9f4c7f92-3d2a-4fbe-8c2b-9b869a48b0f0",
    "title": "Kickoff Chapter 1",
    "description": "Write Day 1 content",
    "dueDateUtc": "2025-09-07T18:30:00Z",
    "isCompleted": false
  }
]

Later (Chapter 2/Day 18) we’ll add pagination:
GET /api/v1/tasks?page=1&pageSize=20&sort=dueDateUtc&status=open


6) Routes, nouns, and relationships (for Tasks)

6.1 Plural, resource-centric URIs

  • Good: /api/v1/tasks
  • Avoid: /api/v1/createTask or /api/v1/taskList

6.2 Nested resources (preview)

We’ll have comments/assignees soon. Use nesting only if the child is scoped by the parent:

  • List comments for a task:
    GET /api/v1/tasks/{taskId}/comments
  • A specific comment for a task:
    GET /api/v1/tasks/{taskId}/comments/{commentId}

If a child can stand alone (e.g., Users), keep its own top-level resource:

  • GET /api/v1/users/{id}
  • GET /api/v1/tasks?assigneeId={userId} (filtering rather than nesting)

7) Path vs query parameters (applied to Task filtering)

  • Path identifies a resource: /api/v1/tasks/{id}
  • Query refines a collection request:
    • /api/v1/tasks?status=completed
    • /api/v1/tasks?dueBefore=2025-09-30T00:00:00Z

We’ll add these in Chapter 2/Day 18; for today, it’s a clean, unfiltered list.


8) Content negotiation & JSON shape

By default, ASP.NET Core uses System.Text.Json with camelCase. Our DTOs use PascalCase property names; serialization will output camelCase JSON by default:

{"id":"...", "title":"...", "dueDateUtc":"..."}

Later (Chapter 1, Day 13) we’ll document our shapes via Swagger/OpenAPI and (if needed) support additional media types.


9) Controller-based equivalent (optional reference)

If you prefer Controllers (we’ll use them more when we need filters & attributes), here’s the same behavior:

// Controllers/TasksController.cs
using Microsoft.AspNetCore.Mvc;
using TaskManagementAPI.Models;

[ApiController]
[Route("api/v1/[controller]")]
public class TasksController : ControllerBase
{
    private static readonly List<TaskResponse> _tasks = new();

    [HttpPost]
    [ProducesResponseType(typeof(TaskResponse), StatusCodes.Status201Created)]
    [ProducesResponseType(StatusCodes.Status400BadRequest)]
    public ActionResult<TaskResponse> CreateTask([FromBody] CreateTaskRequest input)
    {
        if (string.IsNullOrWhiteSpace(input.Title))
            return BadRequest(new { error = "Title is required.", field = "title" });

        var task = new TaskResponse(Guid.NewGuid(), input.Title.Trim(),
            string.IsNullOrWhiteSpace(input.Description) ? null : input.Description!.Trim(),
            input.DueDateUtc, false);

        _tasks.Add(task);
        return CreatedAtAction(nameof(GetTaskById), new { id = task.Id }, task);
    }

    [HttpGet]
    [ProducesResponseType(typeof(IEnumerable<TaskResponse>), StatusCodes.Status200OK)]
    public ActionResult<IEnumerable<TaskResponse>> GetTasks() => Ok(_tasks);

    [HttpGet("{id:guid}")]
    [ProducesResponseType(typeof(TaskResponse), StatusCodes.Status200OK)]
    [ProducesResponseType(StatusCodes.Status404NotFound)]
    public ActionResult<TaskResponse> GetTaskById(Guid id)
    {
        var t = _tasks.FirstOrDefault(x => x.Id == id);
        return t is null ? NotFound() : Ok(t);
    }
}

Use case: Controllers shine once we add filters (Day 8), versioning attributes (Day 50), and richer conventions. For today, Minimal APIs keep us nimble.


10) Testing: Swagger, curl, and Postman

Swagger UI
Run the app and open /swagger. You’ll see POST /api/v1/tasks and GET /api/v1/tasks. Try a create; then call the list.

# Create
curl -s -X POST https://localhost:5001/api/v1/tasks \
  -H "Content-Type: application/json" \
  -d '{"title":"Draft outline","dueDateUtc":"2025-09-10T09:00:00Z"}' | jq

# List
curl -s https://localhost:5001/api/v1/tasks | jq

11) Senior developer best practices

  1. Never trust client IDs (we generate server-side).
  2. Use UTC across contracts; convert at the edge (client/UI).
  3. Shape contracts deliberately (separate request/response DTOs).
  4. Don’t overshare fields (e.g., internal flags).
  5. Return correct codes (201 Created + Location, 200 OK)—this is how REST stays predictable.
  6. Plan versioning now (route includes v1 from Day 1).
  7. Keep it stateless (no sessions)—tomorrow’s scale will thank you.
  8. Avoid premature generalization (attachments/comments/assignees come later; keep today focused).

12) Common pitfalls and how we dodge them

  • “Just one controller file with everything.”
    We start small, but we already separated DTOs and (tomorrow) will extract services.
  • “Return 200 on create.”
    We use 201 + Location properly—small detail, big interoperability win.
  • “Expose DB entity directly.”
    We use DTOs, so we can evolve DB without breaking clients.
  • “Mix timezones.”
    We chose DueDateUtc and stick with it across the backend.

13) Where we go next

  • Day 2: Clean code refinements—consistent naming, modular structure, prep for controllers.
  • Day 3: Custom middleware to log request metadata (endpoint, method, correlation id, user later).
  • Day 4: Extract a TaskService and inject via DI so endpoints stay thin.
  • Day 5: Service lifetimes (transient/scoped) + the groundwork for EF Core.

By end of Day 5, this won’t feel like a toy—it’ll feel like the spine of a real API.


14) Quick checklist

  • Task resource modeled with CreateTaskRequest and TaskResponse
  • POST /api/v1/tasks returns 201 + Location
  • GET /api/v1/tasks returns 200 with a list
  • Minimal APIs wired; controller variant shown
  • Route includes v1 (versioned from day one)
  • UTC in contracts
  • Request validation placeholder (real validation arrives Day 8)

SiteLock