Skip to content

Latest commit

Β 

History

47 Commits

Folders and files

NameName
Last commit message
Last commit date
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 

Repository files navigation

TaskFlow API

A REST API for multi-user task management built with .NET 9, Clean Architecture, and PostgreSQL.

🎯 Objective

Demonstrate proficiency in:

  • REST API Design
  • Authentication with JWT
  • Entity Framework Core persistence
  • Clean Architecture patterns
  • Docker containerization
  • Unit and integration testing

πŸ— Architecture

The project follows Clean Architecture with four distinct layers:

TaskFlow.Domain/        β†’ Entities, Enums, Interfaces
TaskFlow.Application/  β†’ DTOs, Services, Business Logic
TaskFlow.Infrastructure/ β†’ EF Core, Repositories, JWT
TaskFlow.API/          β†’ Controllers, Endpoints

πŸ›  Tech Stack

Category Technology
Framework .NET 9.0
API ASP.NET Core Web API
Database PostgreSQL
ORM Entity Framework Core (migrations)
Authentication JWT Bearer
Documentation Swagger/OpenAPI (Development only)
Container Docker
Testing xUnit + FluentAssertions, SQLite in-memory for integration tests
CI GitHub Actions

πŸ“‘ API Endpoints

Authentication

Method Endpoint Description
POST /api/auth/register Register new user (201 Created with a token)
POST /api/auth/login Login and get JWT

Projects

Method Endpoint Description
GET /api/projects List user's projects (paged)
POST /api/projects Create project
GET /api/projects/{id} Get project details
PUT /api/projects/{id} Update project
DELETE /api/projects/{id} Soft delete project

Tasks

Method Endpoint Description
GET /api/projects/{id}/tasks List tasks (paged, filter by status)
POST /api/projects/{id}/tasks Create task
GET /api/projects/{id}/tasks/{taskId} Get task details
PUT /api/projects/{id}/tasks/{taskId} Update task
PATCH /api/projects/{id}/tasks/{taskId}/complete Mark task complete
DELETE /api/projects/{id}/tasks/{taskId} Soft delete task

Operations

Method Endpoint Description
GET /health/live Liveness: the process is up (no dependency checks)
GET /health/ready Readiness: the database is reachable (503 otherwise)

Both health endpoints are anonymous and are not rate limited.

πŸ“ API Behavior

Errors

Every error is an RFC 7807 application/problem+json response.

Status When
400 Validation failed (body is a ValidationProblemDetails with an errors map), invalid status value, invalid paging values
401 Missing/invalid token, a token whose user no longer exists, or wrong credentials (the message is the same for a wrong password and an unknown email)
404 Resource does not exist, is soft-deleted, or belongs to another user (never 403, so ids of other users' data are not revealed)
409 Email already registered (comparison is case-insensitive)
429 Auth rate limit exceeded (includes a Retry-After header)
500 Unexpected error. The body is generic outside Development; details are only written to the server log

Request validation

Field Rule
Email Required, valid address, max 254 characters. Stored lower-cased and trimmed
Password (register) 8 characters minimum, 72 UTF-8 bytes maximum (BCrypt ignores anything longer; multi-byte characters use several bytes)
Password (login) Max 128 characters (lenient so older accounts can still log in)
User name Optional, max 100 characters
Project name / description Name required, max 100; description max 1000. On update, a provided name cannot be blank
Task title / description Title required, max 200; description max 2000. On update, a provided title cannot be blank
Task status Exactly Pending, InProgress or Completed (case-insensitive). Numbers and unknown values are rejected with 400

Enums are written as their names in every response, the same names requests and filters accept: a task's status is "Pending", "InProgress" or "Completed" (never 0, 1, 2), and a user's role is "User" or "Admin". For example: {"id":1,"projectId":1,"title":"First Task","status":"Pending", ...}.

Filtering and pagination

  • GET /api/projects/{id}/tasks?status=pending,inprogress accepts one or more comma-separated statuses. An invalid value returns 400; it never falls back to "all tasks".
  • Both list endpoints accept page (default 1) and pageSize (default 50, maximum 100). The body stays a plain JSON array; the total number of items is in the X-Total-Count response header (exposed to CORS clients). Invalid values return 400.

πŸš€ Getting Started

Prerequisites

  • .NET 9.0 SDK
  • Docker & Docker Compose (for the container setup)
  • PostgreSQL (only for running the API outside Docker)

Environment Setup

  1. Copy environment template:

    cp .env.example .env
  2. Edit .env with your values:

    POSTGRES_PASSWORD=YourSecurePassword123!
    JWT_SECRET_KEY=YourSuperSecretKeyThatIsAtLeast32CharactersLong!
    ALLOWED_ORIGINS=http://localhost:3000,https://yourdomain.com
    # Optional, only when the API runs behind a reverse proxy (see "Reverse proxy")
    FORWARDED_HEADERS_KNOWN_PROXIES=
    FORWARDED_HEADERS_KNOWN_NETWORKS=

    .env.example in the repository may still lack POSTGRES_PASSWORD, JWT_SECRET_KEY and the two FORWARDED_HEADERS_* variables. Add them to it by hand.

  3. Generate a secure JWT key:

    # Linux/Mac
    openssl rand -base64 32
    
    # Windows (PowerShell)
    [Convert]::ToBase64String((1..32 | ForEach-Object { Get-Random -Maximum 256 }))

Run with Docker

# Build and start containers
docker compose up --build

# API: http://localhost:5000
# Health: http://localhost:5000/health/ready
  • docker compose refuses to start unless POSTGRES_PASSWORD and JWT_SECRET_KEY are set.
  • The compose file runs the API with ASPNETCORE_ENVIRONMENT=Production, so Swagger is not available in this setup.
  • PostgreSQL is reachable only from the API container: port 5432 is not published on the host. For debugging, add a loopback-only mapping (127.0.0.1:5432:5432) to the postgres service.
  • The API image runs as a non-root user and has a container health check on /health/live.

Run Locally

You need a reachable PostgreSQL database and two required environment variables.

# Linux/Mac
export JWT_SECRET_KEY="a-secret-of-at-least-32-bytes-................"
export DATABASE_CONNECTION_STRING="Host=localhost;Database=taskflow;Username=postgres;Password=..."

# Windows (PowerShell)
$env:JWT_SECRET_KEY="a-secret-of-at-least-32-bytes-................"
$env:DATABASE_CONNECTION_STRING="Host=localhost;Database=taskflow;Username=postgres;Password=..."

dotnet restore
dotnet run --project TaskFlow.API
  • With the bundled launch profile the environment is Development: the API listens on http://localhost:5219 and Swagger UI is at /swagger. Without a launch profile (and without ASPNETCORE_URLS) it listens on port 5000 in Production, without Swagger.
  • Swagger is registered only when the environment is Development.
  • Pending migrations are applied automatically at startup (see below); you do not need dotnet ef database update.

πŸ—„ Database Migrations

The API calls Database.MigrateAsync() at startup: it creates the database if needed and applies every pending EF Core migration. Earlier versions called EnsureCreated() instead.

A database created by that old code has all the tables but no __EFMigrationsHistory table, so the first MigrateAsync() fails with relation "Users" already exists. Pick one:

  1. Throw the data away (development): docker compose down -v deletes the postgres_data volume; the next start creates a fresh, migrated database.
  2. Keep the data (baseline): first check that the existing schema matches the InitialCreate migration, then mark it as applied and let startup apply the rest:
    CREATE TABLE IF NOT EXISTS "__EFMigrationsHistory" (
        "MigrationId" character varying(150) NOT NULL PRIMARY KEY,
        "ProductVersion" character varying(32) NOT NULL
    );
    INSERT INTO "__EFMigrationsHistory" ("MigrationId", "ProductVersion")
    VALUES ('20260225200821_InitialCreate', '9.0.0');

Creating a new migration: dotnet ef migrations add <Name> --project TaskFlow.Infrastructure --startup-project TaskFlow.API.

πŸ” Security

  • No hardcoded secrets - All sensitive data via environment variables.
  • JWT - HS256. Startup fails if JWT_SECRET_KEY is missing or shorter than 32 bytes. Token lifetime is configurable with JWT_EXPIRATION_MINUTES (default 1440, i.e. 24 hours; allowed range 1 to 43200, anything else falls back to the default with a warning). Issuer and audience are validated; clock skew is 1 minute.
  • Token revocation by user deletion - On every authenticated request the API checks that the user named in the token still exists, so the token of a deleted user is rejected with 401 immediately instead of staying valid until it expires. This costs one extra primary-key query (SELECT ... WHERE "Id" = ...) per authenticated request. Nothing else is revoked: there is no logout and no token blacklist, so a token stays valid until it expires as long as its user exists.
  • Password hashing - BCrypt. Login does the same amount of hashing work whether or not the email exists, and returns one generic 401.
  • Auth rate limiting - POST /api/auth/login and /api/auth/register share a per-client fixed-window limit (default 10 requests per 60 seconds; see the table below). Over the limit: 429 with Retry-After.
  • CORS protection - Configurable allowed origins (ALLOWED_ORIGINS).
  • Security headers - X-Content-Type-Options, X-Frame-Options, X-XSS-Protection, Referrer-Policy.
  • Ownership isolation - Every project and task query is scoped to the authenticated user; other users' data returns 404.
  • Soft delete - Projects and tasks are marked as deleted, not physically removed, and are excluded from every query.
  • Error handling - A global handler turns exceptions into ProblemDetails. Unexpected errors return a generic 500 body and are logged; the exception message is only included in the response in the Development environment.

Roles are not enforced. Users have a Role (User or Admin), and it is stored, returned in responses and included as a claim in the JWT. But no endpoint uses [Authorize(Roles = ...)], and registration always creates a User. Today every authenticated user has the same permissions: access is decided by ownership only.

Reverse proxy

Behind a proxy every request appears to come from the proxy's address, so all clients would share one rate-limit bucket. X-Forwarded-For is ignored unless you configure the proxies you trust (it is client-controlled, so trusting it blindly would let a client dodge the limit):

Setting Environment variable Meaning
ForwardedHeaders:KnownProxies FORWARDED_HEADERS_KNOWN_PROXIES (compose) / ForwardedHeaders__KnownProxies Comma-separated proxy IP addresses
ForwardedHeaders:KnownNetworks FORWARDED_HEADERS_KNOWN_NETWORKS (compose) / ForwardedHeaders__KnownNetworks Comma-separated CIDR ranges, e.g. 10.0.0.0/8

An invalid entry stops the API at startup.

βš™οΈ Configuration

Variable Required Description Example / default
JWT_SECRET_KEY Yes Signing key, at least 32 bytes (UTF-8) openssl rand -base64 32
DATABASE_CONNECTION_STRING Yes PostgreSQL connection string Host=postgres;Database=taskflow;Username=postgres;Password=...
POSTGRES_PASSWORD Docker Compose PostgreSQL password (also used to build the connection string in compose) YourSecurePassword123!
JWT_ISSUER No JWT issuer TaskFlowAPI
JWT_AUDIENCE No JWT audience TaskFlowAPI
JWT_EXPIRATION_MINUTES No Token lifetime in minutes 1440
ALLOWED_ORIGINS No Comma-separated CORS origins http://localhost:3000,http://localhost:5000,http://localhost:8080 (compose default: http://localhost:3000)
RateLimiting__Auth__PermitLimit No Auth requests allowed per window, per client 10
RateLimiting__Auth__WindowSeconds No Auth rate-limit window 60
FORWARDED_HEADERS_KNOWN_PROXIES No Trusted proxy IPs (compose variable) empty
FORWARDED_HEADERS_KNOWN_NETWORKS No Trusted proxy CIDR ranges (compose variable) empty
ASPNETCORE_ENVIRONMENT No Development enables Swagger and detailed 500 messages Production in compose

The rate limit and forwarded-header settings are ordinary ASP.NET Core configuration: they can also be set in appsettings.json (RateLimiting:Auth:PermitLimit, ...). Non-numeric or non-positive rate-limit values fall back to the defaults.

πŸ“ Project Structure

TaskFlowAPI/
β”œβ”€β”€ TaskFlow.Domain/           # Core business entities
β”‚   β”œβ”€β”€ Entities/              # User, Project, TaskItem
β”‚   β”œβ”€β”€ Enums/                 # Role, TaskStatus
β”‚   └── Interfaces/            # Repository and unit-of-work interfaces
β”œβ”€β”€ TaskFlow.Application/      # Business logic
β”‚   β”œβ”€β”€ DTOs/                  # Request/Response objects and validation
β”‚   └── Services/              # Auth, Project, Task services
β”œβ”€β”€ TaskFlow.Infrastructure/    # Data access
β”‚   β”œβ”€β”€ Data/                  # DbContext, unit of work
β”‚   β”œβ”€β”€ Configurations/        # EF configurations
β”‚   β”œβ”€β”€ Migrations/            # EF Core migrations
β”‚   β”œβ”€β”€ Repositories/          # Repository implementations
β”‚   └── Security/              # JWT and password hashing
β”œβ”€β”€ TaskFlow.API/              # Web API
β”‚   β”œβ”€β”€ Controllers/           # API controllers
β”‚   └── Program.cs             # App configuration
β”œβ”€β”€ TaskFlow.*.Tests/          # Unit and integration tests
β”œβ”€β”€ TaskFlow.TestSupport/      # Shared test helpers
β”œβ”€β”€ .github/workflows/         # CI pipeline and scheduled vulnerability check
β”œβ”€β”€ .github/dependabot.yml     # Weekly dependency updates
β”œβ”€β”€ docker-compose.yml         # Docker services
β”œβ”€β”€ .env.example               # Environment template
└── README.md

πŸ“ API Usage Example

# 1. Register (201 Created; the response already contains a token)
curl -X POST http://localhost:5000/api/auth/register \
  -H "Content-Type: application/json" \
  -d '{"email":"user@example.com","password":"Pass1234!","name":"User"}'

# 2. Login (copy token from response)
curl -X POST http://localhost:5000/api/auth/login \
  -H "Content-Type: application/json" \
  -d '{"email":"user@example.com","password":"Pass1234!"}'

# 3. Create Project (replace TOKEN)
curl -X POST http://localhost:5000/api/projects \
  -H "Authorization: Bearer TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"name":"My Project"}'

# 4. Create Task
curl -X POST http://localhost:5000/api/projects/1/tasks \
  -H "Authorization: Bearer TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"title":"First Task"}'

# 5. List pending tasks, second page of 10 (total count is in the X-Total-Count header)
curl -i "http://localhost:5000/api/projects/1/tasks?status=pending&page=2&pageSize=10" \
  -H "Authorization: Bearer TOKEN"

βœ… Testing

# Run all tests
dotnet test

# Run with coverage
dotnet test --collect:"XPlat Code Coverage"
Project Covers
TaskFlow.Domain.Tests Entity rules and guards, email normalization, paging values
TaskFlow.Application.Tests Services (ownership, status parsing, auth flow) against in-memory fakes; request validation rules
TaskFlow.Infrastructure.Tests Repositories, soft delete, unit of work and BCrypt/JWT settings against SQLite in-memory
TaskFlow.API.Tests Error handler, paging, rate limiting, forwarded headers and health checks in small hosts, plus integration tests (Integration/) that boot the real application through WebApplicationFactory<Program> and drive it over HTTP: register/login, token validation (including deleted users), validation, ownership isolation, filters, pagination, soft delete, health, rate limiting

The integration tests need no Docker or PostgreSQL. They replace the database with SQLite in-memory and set Database:MigrateOnStartup=false so the schema is created from the model: the migrations contain PostgreSQL-specific SQL (for example the lower("Email") index) that SQLite cannot run. The migrations themselves are therefore not exercised by the test suite.

Continuous integration

.github/workflows/ci.yml runs on every push and pull request: restore, build and test in Release (job build-test), and a docker build of the API image (job docker-build).

ci.yml deliberately downgrades the NuGet audit warnings (NU1901-NU1904) so that a newly published advisory does not break every build. Known-vulnerable packages are reported by a separate scheduled workflow, .github/workflows/vulnerabilities.yml (Mondays 06:00 UTC, or run it manually with workflow_dispatch): it runs dotnet list package --vulnerable --include-transitive and fails when the output contains any advisory. Dependabot (.github/dependabot.yml) opens weekly update pull requests for NuGet, GitHub Actions and the Docker base images.

⬆️ Upgrading / breaking changes

If you run a version from before the hardening work, note:

  • Breaking response contract: enums are now serialized as strings. A task's status was a number (0, 1, 2) and is now "Pending", "InProgress" or "Completed"; a user's role (in the user object of the auth responses) was a number (0, 1) and is now "User" or "Admin". Clients that read the numeric values must be updated. Requests are unchanged: they already used the names, and numbers are still rejected with 400.

  • POST /api/auth/register returns 201 Created (it used to return 200), and an already registered email returns 409 Conflict (it used to be a 400 with a { "message": ... } body).

  • Errors are RFC 7807 ProblemDetails (application/problem+json) instead of ad-hoc bodies; validation failures are 400.

  • Requests are validated (lengths, email format, password 8 to 72 bytes) and task status must be one of Pending, InProgress, Completed. An invalid status filter is now a 400 instead of returning every task.

  • List endpoints are paged: at most 100 items per request (default 50). The total is in X-Total-Count.

  • Emails are stored lower-cased and matched case-insensitively.

  • JWT_SECRET_KEY must be at least 32 bytes; the API refuses to start otherwise. Clock skew changed from the framework default of 5 minutes to 1 minute.

  • A token whose user no longer exists in the database is rejected with 401. Every authenticated request now runs one extra query to check this.

  • login and register are rate limited (default 10 per minute per client address), so scripts and load tests may see 429.

  • docker-compose.yml requires POSTGRES_PASSWORD and JWT_SECRET_KEY, no longer publishes port 5432, and runs the API as a non-root user.

  • Startup applies migrations instead of EnsureCreated(). A database created by an older version needs a one-time reset or baseline (see "Database Migrations").

  • New endpoints: /health/live and /health/ready.

☁️ Production Deployment

  1. Set secure environment variables (JWT_SECRET_KEY, POSTGRES_PASSWORD, DATABASE_CONNECTION_STRING)
  2. Use HTTPS (configure a reverse proxy like Nginx) and list the proxy in FORWARDED_HEADERS_KNOWN_PROXIES / FORWARDED_HEADERS_KNOWN_NETWORKS
  3. Set ASPNETCORE_ENVIRONMENT=Production (Swagger stays off and 500 responses stay generic)
  4. Configure your domain in ALLOWED_ORIGINS
  5. Use a strong JWT_SECRET_KEY (64+ characters recommended)
  6. Enable auto-restart with restart: unless-stopped
  7. Probe /health/ready for readiness and /health/live for liveness

πŸ“„ License

MIT License

About

No description, website, or topics provided.

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages