DocFlow is a Spring Boot backend for asynchronous document-processing jobs. It provides a production-shaped foundation for managing users, projects, API keys, and job queues.
- Users register and log in (JWT authentication)
- Users create projects to group their work
- Projects generate API keys for programmatic access
- Projects submit jobs that are queued for later processing
- Jobs have controlled status transitions and full lifecycle tracking
| Layer | Technology |
|---|---|
| Language | Java 21 |
| Framework | Spring Boot 3 |
| Build | Maven |
| Database | PostgreSQL 16 |
| ORM | Spring Data JPA / Hibernate |
| Security | Spring Security + JWT (jjwt) |
| Migrations | Flyway |
| Containers | Docker Compose |
| Validation | Bean Validation (jakarta) |
src/main/java/com/docflow/
├── DocFlowApplication.java
├── audit/ # BaseEntity with timestamps
├── auth/ # Registration, login, JWT, Spring Security config
├── project/ # Project CRUD
├── apikey/ # API key generation and revocation
├── job/ # Job submission and lifecycle
└── common/ # Global exception handler, shared exceptions
Prerequisites: Docker & Docker Compose installed.
# 1. Clone the repository
git clone https://github.com/enlorik/doc-flow.git
cd doc-flow
# 2. Start all services (PostgreSQL + app) — the image is built automatically
docker-compose up --buildThe API will be available at http://localhost:8080.
# 1. Create the database
createdb docflow
# 2. Configure credentials (or export env vars)
# DOCFLOW_JWT_SECRET is required — generate a random 32+ character string for local use
export SPRING_DATASOURCE_URL=jdbc:postgresql://localhost:5432/docflow
export SPRING_DATASOURCE_USERNAME=docflow
export SPRING_DATASOURCE_PASSWORD=docflow
export DOCFLOW_JWT_SECRET=your-256-bit-secret-key-here
# 3. Run
mvn spring-boot:run| Method | Path | Description |
|---|---|---|
| POST | /api/auth/register |
Register a new user |
| POST | /api/auth/login |
Login and receive JWT |
| Method | Path | Description |
|---|---|---|
| POST | /api/projects |
Create a project |
| GET | /api/projects |
List your projects |
| GET | /api/projects/{id} |
Get project details |
| Method | Path | Description |
|---|---|---|
| POST | /api/projects/{id}/keys |
Generate an API key (raw key shown once) |
| GET | /api/projects/{id}/keys |
List API keys |
| DELETE | /api/projects/{id}/keys/{keyId} |
Revoke an API key |
| Method | Path | Description |
|---|---|---|
| POST | /api/projects/{id}/jobs |
Submit a job |
| GET | /api/projects/{id}/jobs |
List jobs |
| GET | /api/projects/{id}/jobs/{jobId} |
Get job details |
| POST | /api/projects/{id}/jobs/{jobId}/cancel |
Cancel a queued/running job |
# Register
curl -X POST http://localhost:8080/api/auth/register \
-H 'Content-Type: application/json' \
-d '{"email":"user@example.com","password":"password123"}'
# Login
TOKEN=$(curl -s -X POST http://localhost:8080/api/auth/login \
-H 'Content-Type: application/json' \
-d '{"email":"user@example.com","password":"password123"}' | jq -r .token)
# Create a project
curl -X POST http://localhost:8080/api/projects \
-H "Authorization: Bearer $TOKEN" \
-H 'Content-Type: application/json' \
-d '{"name":"My Project","description":"Test project"}'
# Submit a job (replace PROJECT_ID)
curl -X POST http://localhost:8080/api/projects/PROJECT_ID/jobs \
-H "Authorization: Bearer $TOKEN" \
-H 'Content-Type: application/json' \
-d '{"type":"PDF_CONVERT","inputJson":"{\"url\":\"https://example.com\"}","maxAttempts":3}'| Status | Description |
|---|---|
QUEUED |
Submitted, waiting to be picked up |
RUNNING |
Currently being processed |
SUCCEEDED |
Completed successfully |
FAILED |
Failed, may be retried |
RETRY_SCHEDULED |
Scheduled for retry |
DEAD_LETTER |
Exhausted max attempts |
CANCELLED |
Cancelled by user |
Key environment variables:
| Variable | Default | Description |
|---|---|---|
SPRING_DATASOURCE_URL |
jdbc:postgresql://localhost:5432/docflow |
DB connection URL |
SPRING_DATASOURCE_USERNAME |
docflow |
DB username |
SPRING_DATASOURCE_PASSWORD |
docflow |
DB password |
DOCFLOW_JWT_SECRET |
dev default | JWT signing secret (min 256-bit) |
DOCFLOW_JWT_EXPIRATION_MS |
86400000 |
JWT expiry (ms), default 24h |
mvn testTests use an H2 in-memory database (Flyway disabled for tests).
Why JWT instead of sessions — DocFlow is a machine-facing REST API. Clients attach a Bearer token explicitly; the server validates the signature and moves on with no session store and no sticky routing requirement. The tradeoff is revocability: a stolen token is valid until expiry, so the mitigation is short TTLs. Sessions would give instant revocation but at the cost of shared session state and sticky routing — the right call depends on whether the client is a browser or a machine.
Why tenant access requires two checks — Every operation first calls loadAndVerifyOwnership to confirm the caller owns the project in the URL. For jobs and API keys, a second check then confirms the resource actually belongs to that project (e.g. job.project.id == projectId). The first check prevents cross-tenant project access; the second prevents IDOR within a tenant's own projects — both are required, and omitting either reopens a vulnerability.
Why the DB constraint is the real idempotency guarantee — There's a fast-path check (look up the idempotency key before inserting), but two concurrent requests can both pass that check before either commits. The UNIQUE(project_id, idempotency_key) constraint is what actually serialises them at the database level. The intended recovery: one insert wins, the other catches DataIntegrityViolationException and reads the winner's row — though the precise boundary depends on flush timing; save() rather than saveAndFlush() means the exception may surface during commit rather than inside the try/catch, so the read-back path should be verified under the actual transaction configuration.
Why CSRF is explicitly disabled — CSRF exploits browsers automatically attaching cookies to cross-origin requests. Bearer tokens require explicit attachment by client code, so the attack surface doesn't exist. Disabling it here is correct; a session-cookie-based app would need it enabled. The reasoning should be explicit, not accidental.
Why UUID primary keys — Sequential IDs expose enumeration: a user could walk project/1, project/2, and so on. UUIDs eliminate that, are safe to expose in URLs, and merge cleanly across environments. The cost is larger indexes and no natural ordering, so created_at handles anything that needs to be sorted.