SwiftPaste is a FastAPI-based snippet manager with snippet versioning, JWT auth, Redis read-through cache, and PostgreSQL persistence.
- Python 3.13
- FastAPI + Uvicorn
- SQLAlchemy (async) + Alembic
- PostgreSQL 16
- Redis 7
- NGINX (reverse proxy)
- Docker Compose
- Prometheus-compatible metrics via
prometheus_client - Redis-backed background jobs (queue + worker + scheduler)
flowchart LR
C[Client] --> N[NGINX :80]
C --> A[FastAPI :8000]
N --> A
A --> M[Middlewares\nRequest Logging\nRequest Metrics\nCORS]
M --> R[API Routers]
R --> S[Snippet Service]
R --> H[Health Service]
R --> U[FastAPI Users Auth]
S --> RD[(Redis)]
S --> PG[(PostgreSQL)]
H --> RD
H --> PG
U --> PG
A --> P["/v1/api/health/metrics"]
The main read path (GET /v1/api/snippet/{short_id}) is cache-first:
sequenceDiagram
participant Client
participant API as FastAPI Route
participant Service as snippet_service.get_snippet_cached
participant Redis
participant DB as PostgreSQL
Client->>API: GET /v1/api/snippet/{short_id}?version=
API->>Service: get_snippet_cached(short_id, version, user)
Service->>Redis: GET snippet:{short_id}:v{version|latest}
alt Cache hit
Redis-->>Service: cached snippet JSON
Service-->>API: SnippetResponse
API-->>Client: 200
else Cache miss or Redis error
Service->>DB: query snippet + requested/latest version
Service->>Service: enforce visibility + expiry rules
alt snippet visibility is public
Service->>Redis: SETEX cache key (TTL)
end
Service-->>API: SnippetResponse
API-->>Client: 200/4xx
end
Every API request follows this runtime path:
- Client sends request to NGINX (
:80) or directly to FastAPI (:8000). - NGINX forwards request to
api:8000. - FastAPI processes request through middleware:
- request ID + structured request logging
- request/response metrics capture
- CORS checks
- Router validates auth requirements (
current_useroroptional_user). - Service layer runs business logic and accesses Redis/PostgreSQL.
- Errors are translated by centralized exception handlers.
- Response returns with
X-Request-Idfor traceability.
sequenceDiagram
participant Client
participant API as FastAPI /auth routes
participant UM as UserManager
participant DB as users table
participant JWT as JWT strategy
Client->>API: POST /auth/register
API->>UM: create user
UM->>DB: INSERT users
DB-->>UM: user row
UM-->>API: user created
API-->>Client: 201
Client->>API: POST /auth/jwt/login
API->>DB: validate credentials
DB-->>API: user found + password valid
API->>JWT: create token (lifetime = JWT_LIFETIME_SECONDS)
JWT-->>API: bearer access token
API-->>Client: 200 {access_token, token_type}
Client->>API: POST /v1/api/snippet/ (Authorization: Bearer ...)
API->>API: current_user(active=True)
API-->>Client: 201 or 401
Google OAuth routes are mounted under /auth/google via fastapi-users OAuth router.
sequenceDiagram
participant Client
participant API as /auth/google/*
participant Google
participant DB as users table
Client->>API: GET /auth/google/authorize
API-->>Client: redirect/auth URL
Client->>Google: consent
Google-->>API: callback with code/state
API->>Google: exchange code for profile
API->>DB: create user or link by email (associate_by_email=True)
API-->>Client: auth response (token/session payload)
users table (from FastAPI Users base + custom field):
id(UUID, PK)email(unique)username(unique)hashed_passwordis_active,is_superuser,is_verified
snippets table:
id(UUID, PK)short_id(unique, length = 8)titleauthor_id(FK ->users.id)version_counter(latest version number)views(total snippet views)created_at,deleted_at(soft delete)
snippet_versions table:
id(UUID, PK)snippet_id(FK ->snippets.id)version(unique per snippet via composite unique constraint)contentvisibility(PUBLICorPRIVATE)expires_at,created_at
Relationship summary:
- One user has many snippets.
- One snippet has many immutable versions.
- Reads exclude soft-deleted rows by default through SQLAlchemy event filtering.
Create (POST /v1/api/snippet/):
- Authenticated user is required.
- Service generates
short_id(8 chars) and retries on collision. - Inserts into
snippetsand first row insnippet_versions(version = 1). - Returns full snippet response with current version.
Update (PUT /v1/api/snippet/{id}):
- Confirms snippet ownership.
- Updates snippet title when provided.
- If content changed, increments
version_counterand inserts a new version row. - Updates cache for latest key
snippet:{short_id}:vlatest.
Share URL (POST /v1/api/snippet/{id}/share):
- Confirms ownership and selected version.
- Computes expiry (
ttl_secondsor default TTL). - Stores expiry in
snippet_versions.expires_at. - Returns
share_urlwith?v={version}.
Read (GET /v1/api/snippet/{short_id}):
- Attempts Redis cache read first.
- On miss/error, loads from DB.
- Enforces private visibility and expiry rules.
- Caches public payloads only.
Delete (DELETE /v1/api/snippet/{id}):
- Ownership check.
- Soft delete is applied through DB event listeners.
- Client:
redis.asyncio(from_url) with tight network timeouts:socket_connect_timeout=0.1socket_timeout=0.1
- Key format:
snippet:{short_id}:v{version_or_latest} - Read path behavior:
- 2 retry attempts on Redis
GETwith short backoff (50ms) - increments
cache_hit_total,cache_miss_total,cache_error_total
- 2 retry attempts on Redis
- Write path behavior:
SETEXwithCACHE_TTL_SECONDS- only public snippets are cached for shared read path
- Degradation strategy:
- Redis failure does not fail request; service falls back to PostgreSQL
- Async SQLAlchemy engine with:
- connect timeout (
DB_CONNECTION_TIMEOUT) - statement timeout (
DB_QUERY_TIMEOUT, passed to PostgreSQL)
- connect timeout (
- Per-request async DB session via dependency injection.
- Alembic migrations are used for schema management.
- Query-level metrics are captured via SQLAlchemy engine events.
Centralized handlers in app/core/exception_handlers.py map failures to a consistent JSON envelope:
{
"error": {
"code": "STRING_CODE",
"message": "Human readable message",
"details": "optional details",
"requestId": "trace id"
}
}Handled categories:
AppError-> business errors (404,403,423, etc.)RequestValidationError->422StarletteHTTPException-> mapped HTTP error envelopeDBAPIError->503database timeout/failure envelope- uncaught
Exception->500
- Redis outage: read path falls back to DB and increments
cache_error_total. - Redis health check timeout is isolated and short.
- DB and Redis health are independently checked and exposed on readiness endpoint.
short_idcollision handling retries before returning failure.- NGINX reverse proxy provides a stable ingress and supports API scaling (
--scale api=N). - Soft delete prevents immediate hard data removal from normal query paths.
Metrics endpoint:
GET /v1/api/health/metrics
Key metric groups:
- HTTP:
http_requests_totalhttp_request_errors_totalhttp_request_latency_secondshttp_request_size_byteshttp_response_size_bytes
- DB:
db_query_latency_secondsdb_slow_queries_total
- Cache:
cache_hit_totalcache_miss_totalcache_error_total
Logging and traceability:
- JSON structured logs to stdout.
- Request logs include method, path, status, latency, request ID.
X-Request-Idis echoed back in response headers.
- Snippet versioning with immutable historical rows.
- Visibility controls (
public,private) at version level. - Expiring shared links via TTL-based
expires_atmanagement. - User-scoped snippet listing with pagination.
- Redis token-bucket rate limiting on snippet routes.
- Full-text snippet search with PostgreSQL ranking and Redis-backed token indexing.
- Background job processing with worker/scheduler for indexing, cleanup, and retries.
- Buffered snippet view counting with scheduled flush to PostgreSQL.
- Health endpoints for liveness/readiness and dependency status.
Base API prefix is /v1/api (from settings.V1_API_PREFIX).
POST /v1/api/snippet/create snippet (auth required)PUT /v1/api/snippet/{id}update snippet by UUID (auth required)DELETE /v1/api/snippet/{id}soft-delete snippet by UUID (auth required)POST /v1/api/snippet/{id}/sharecreate/extend share URL (auth required)GET /v1/api/snippet/{short_id}read shared snippet (optional auth)GET /v1/api/snippet/list current user snippets (paginated, auth required)GET /v1/api/snippet/search?query=...search snippets (optional auth)
GET /v1/api/health/combined health checkGET /v1/api/health/readyreadiness checkGET /v1/api/health/healthliveness checkGET /v1/api/health/metricsPrometheus metrics
POST /auth/jwt/loginPOST /auth/jwt/logoutPOST /auth/registerGET /users/me- Google OAuth routes under
/auth/google
- Copy env file.
Copy-Item .env.example .envcp .env.example .env- Start the stack.
docker compose up --build -d- Run migrations.
docker compose run --rm migrate- Verify services.
curl http://localhost/v1/api/health/readyUseful URLs:
- API docs:
http://localhost/docs - ReDoc:
http://localhost/redoc - Metrics:
http://localhost/v1/api/health/metrics - PgAdmin:
http://localhost:5050
- Start only dependencies.
docker compose up -d db redis- Set local host-based connection values (important: use
localhost, notdb).
$env:DATABASE_URL="postgresql+asyncpg://postgres:naveenpranesh@localhost:5432/swiftpaste"
$env:DATABASE_SYNC_URL="postgresql+psycopg://postgres:naveenpranesh@localhost:5432/swiftpaste"
$env:REDIS_URL="redis://localhost:6379/0"- Install dependencies and run migrations.
uv sync --dev
uv run alembic upgrade head- Start the API.
uv run uvicorn app.main:app --host 0.0.0.0 --port 8000 --reloadAlternative local entrypoint:
uv run python main.pyDocs URL in this mode:
http://localhost:8000/docs
Current automated test modules:
tests/test_snippet_service.py— service-layer unit tests (cache key, serialization, Redis retry/fallback/cache behavior)tests/test_jobs.py— background job task unit tests (cleanup and stuck-job recovery rescheduling)tests/test_snippets.py— API + DB integration tests for snippet create/update/read/delete paths
uv run python -m unittest discover -s tests -p "test_*.py" -vService unit tests:
uv run python -m unittest tests.test_snippet_service -vJob task unit tests:
uv run python -m unittest tests.test_jobs -vSnippet API integration tests:
uv run python -m unittest tests.test_snippets -vIntegration test notes:
- PostgreSQL must be reachable via
DATABASE_URL - The integration suite attempts configured host first and falls back from
dbtolocalhostwhen running from host - Each run uses a temporary PostgreSQL schema and drops it during teardown for isolation
Provide a public snippet short_id and optional version:
k6 run tests/load.js -e BASE_URL=http://localhost:8000 -e SHORT_ID=<short_id> -e VERSION=<version>- The k6 scenario ramps to 20 VUs, checks response status/content, and enforces failure/latency thresholds.
If VERSION is omitted, the script targets the latest snippet version.
- Start all services:
docker compose up -d - Rebuild and start:
docker compose up --build -d - View logs:
docker compose logs -f api nginx - Run one-off migration:
docker compose run --rm migrate - Scale API containers:
docker compose up -d --scale api=3 - Stop and keep volumes:
docker compose down - Stop and remove volumes:
docker compose down -v
usersauth users (FastAPI Users)snippetssnippet metadata +short_id+version_countersnippet_versionsimmutable content versions per snippet
Notes:
short_idlength is fixed to 8 characters.- Read queries apply a soft-delete filter via SQLAlchemy events.
- Cache key format is
snippet:{short_id}:v{version_or_latest}.
- Request logging middleware adds
X-Request-Idand structured logs. - Request and DB latency metrics are recorded with Prometheus histograms.
- Cache counters exposed:
cache_hit_totalcache_miss_totalcache_error_total
- Default compose API command runs in reload mode:
uv run uvicorn app.main:app --host 0.0.0.0 --port 8000 --reload
- Alembic reads
DATABASE_SYNC_URLfrom settings viaalembic/env.py. - Current migration set includes initial schema in
alembic/versions/d07059efe000_initial_migration.py.