Backend API for a raid composition application. The service is written in Rust with Actix Web, uses PostgreSQL through SQLx, uses Redis for cache/session-ready infrastructure, and implements Discord OAuth authentication with server-side sessions.
- Rust 2024 edition
- Actix Web
- SQLx
0.8.6with PostgreSQL - Redis with the
redisRust crate - Discord OAuth with HTTP-only server-side sessions
- Docker and Docker Compose for local development
- GitHub Actions for clippy, tests, and Docker image builds
src/
main.rs # Actix server bootstrap
config.rs # Environment-driven application config
db.rs # PostgreSQL connection setup
auth/ # Auth service, Discord client, crypto, and GeoIP helpers
api/
routes/ # Versioned route registration
v1/
auth/ # Authentication route modules
health/ # App, PostgreSQL, and Redis health route modules
error.rs # Structured JSON API errors
migrations/
*.sql # Embedded SQLx migrations
Dockerfile # Production image build
local.Dockerfile # Compose development image with cargo-watch and sqlx-cli
docker-compose.yml # Local PostgreSQL, Redis, and API services
The application reads required configuration from environment variables. During local development, dotenv loads values from .env once during startup.
Create your local environment file from the example:
cp .env.example .envRequired variables:
| Variable | Description |
|---|---|
APP_PORT |
Port the Actix server binds to. Use 8000 with the current Docker Compose setup. |
FRONTEND_BASE_URL |
Frontend base URL used for credentialed CORS origin checks. |
DB_HOST |
PostgreSQL host. Use localhost for native local runs or postgres inside Docker Compose. |
DB_PORT |
PostgreSQL port. |
DB_USER |
PostgreSQL user. |
DB_PASSWORD |
PostgreSQL password. |
DB_NAME |
PostgreSQL database name. |
REDIS_HOST |
Redis host. Use localhost for native local runs or redis inside Docker Compose. The Compose API container overrides this to redis. |
REDIS_PORT |
Redis port. |
REDIS_PASSWORD |
Redis password. Redis starts with --requirepass; Docker Compose defaults this to password if unset. |
DISCORD_CLIENT_ID |
Discord OAuth application client ID. |
DISCORD_CLIENT_SECRET |
Discord OAuth application client secret. |
DISCORD_REDIRECT_URL |
Exact Discord OAuth redirect URL sent during authorization and token exchange. |
DISCORD_TOKEN_ENCRYPTION_KEY |
32-byte Discord token encryption key, encoded as hex, standard base64, or unpadded URL-safe base64. |
SESSION_HMAC_SECRET |
Secret used to HMAC session, CSRF, and OAuth state tokens. Must be at least 32 bytes. |
GEOIP_DATABASE_PATH |
Path to a MaxMind GeoLite2 City .mmdb file. Missing files disable GeoIP lookup without failing startup. |
COOKIE_DOMAIN |
Cookie domain for auth/session cookies. |
WCL_CLIENT_ID |
Warcraft Logs API client ID. |
WCL_CLIENT_SECRET |
Warcraft Logs API client secret. |
BNET_CLIENT_ID |
Battle.net API client ID. |
BNET_CLIENT_SECRET |
Battle.net API client secret. |
Optional cookie variables:
| Variable | Default | Description |
|---|---|---|
COOKIE_SECURE |
true |
Whether auth cookies require HTTPS. Use false for local plain HTTP. |
COOKIE_SAME_SITE |
Lax |
Cookie SameSite policy: Lax, Strict, or None. |
SESSION_COOKIE_NAME |
session |
HTTP-only session cookie name. |
CSRF_COOKIE_NAME |
csrf |
Readable CSRF cookie name. |
Optional Warcraft Logs variables:
| Variable | Default | Description |
|---|---|---|
WCL_OAUTH_TOKEN_URL |
https://www.warcraftlogs.com/oauth/token |
Warcraft Logs OAuth client-credentials token URL. |
WCL_ENDPOINT |
https://fresh.warcraftlogs.com/api/v2/client |
Warcraft Logs v2 GraphQL client endpoint. |
WCL_ZONE_ID |
unset | Optional zone ID used to filter character rankings. |
WCL_SKIP_GEAR |
false |
Set to true to skip CombatantInfo gear fetching during refresh. |
WCL_API_DELAY_MS |
300 |
Delay between Warcraft Logs API calls within one refresh. |
Optional Battle.net variables:
| Variable | Default | Description |
|---|---|---|
BNET_GAME_VERSION |
classic |
Battle.net profile namespace flavor: retail, classic1x, or classic anniversary-style default. |
Optional seed variables:
| Variable | Default | Description |
|---|---|---|
RUN_DB_SEEDS |
false |
Runs embedded SQL seed files from seeds/ after migrations during API startup. |
Battle.net and Warcraft Logs refreshes read the character's server and region from the database row. There are no separate realm or region environment variables for those refresh paths.
The API requires every required variable above to be present, non-empty, and valid at startup. Ports must be non-zero u16 values. Missing, malformed, or weak auth configuration stops the server before it binds an HTTP port.
The application runtime uses the DB_* variables above and does not require DATABASE_URL. DATABASE_URL is only needed when running manual SQLx CLI commands.
Example Docker Compose-oriented values:
APP_PORT=8000
FRONTEND_BASE_URL=http://localhost:4200
DB_HOST=postgres
DB_PORT=5432
DB_USER=user
DB_PASSWORD=password
DB_NAME=app_db
REDIS_HOST=redis
REDIS_PORT=6379
REDIS_PASSWORD=password
DISCORD_CLIENT_ID=your_discord_client_id
DISCORD_CLIENT_SECRET=your_discord_client_secret
DISCORD_REDIRECT_URL=http://localhost:4200/auth/discord/callback
DISCORD_TOKEN_ENCRYPTION_KEY=0000000000000000000000000000000000000000000000000000000000000000
SESSION_HMAC_SECRET=replace-with-at-least-32-random-bytes
GEOIP_DATABASE_PATH=/app/local/GeoLite2-City.mmdb
COOKIE_DOMAIN=localhost
COOKIE_SECURE=false
COOKIE_SAME_SITE=Lax
WCL_CLIENT_ID=your_warcraftlogs_client_id
WCL_CLIENT_SECRET=your_warcraftlogs_client_secret
BNET_CLIENT_ID=your_battlenet_client_id
BNET_CLIENT_SECRET=your_battlenet_client_secretGenerate local development secrets with:
openssl rand -hex 32Use a different generated value for DISCORD_TOKEN_ENCRYPTION_KEY and SESSION_HMAC_SECRET.
Start PostgreSQL and Redis with Docker Compose:
docker compose up postgres redisUse DB_HOST=localhost and REDIS_HOST=localhost in .env when running the API directly on the host. Keep REDIS_PASSWORD aligned with the password used by the Redis container, then start the server:
cargo runThe API connects to PostgreSQL, runs embedded SQLx migrations, creates the Redis client, then binds to 0.0.0.0:${APP_PORT}. If migrations fail, startup stops before the HTTP port is bound.
GeoIP lookup is best-effort. To enable it locally, download a MaxMind GeoLite2 City database and set GEOIP_DATABASE_PATH to the .mmdb file path visible to the API process. If the file is missing, GeoIP is disabled and location fields remain null.
Seed SQL files live in seeds/ next to migrations/ and are embedded into the API binary. Set RUN_DB_SEEDS=true to run unapplied seed files after migrations during API startup:
RUN_DB_SEEDS=trueApplied seeds are tracked in the seed_history table by name and checksum, so repeated container starts do not reapply unchanged seeds. If a seed file changes after it was applied, startup fails instead of silently applying drifted data.
The current character roster seed targets existing guild a1f1bcf2-47c2-4d57-bf9e-24edd3a6dcae. It embeds the roster data directly in SQL, skips players with empty Discord IDs, skips characters missing a Battle.net race, creates or reuses users by Discord ID, creates or restores guild memberships as raider, and upserts core character rows only. It does not write Battle.net or Warcraft Logs snapshot tables.
Seed rollback SQL is provided for manual use as *.down.sql. Startup only applies *.up.sql seeds.
Run the API, PostgreSQL, and Redis together:
docker compose up --buildThe local Docker image uses cargo-watch. Changes under src/ are synced into the container. Dependency or manifest changes in Cargo.toml require rebuilding/recreating the API container so the container sees the updated manifest and lockfile:
docker compose up -d --build apiEnvironment changes in .env require recreating the container, not just restarting it:
docker compose up -d --force-recreate apiWith the current docker-compose.yml, keep APP_PORT=8000 in .env because the API service exposes container port 8000. Redis is exposed on ${REDIS_PORT:-6379} and requires REDIS_PASSWORD for clients. Compose still uses shell defaults for local infrastructure convenience, but the API runtime itself requires explicit environment values.
docker-compose.yml builds the API from local.Dockerfile. The local image includes cargo-watch for the development loop and sqlx-cli for migration commands. The production Dockerfile is separate and builds a locked release binary for a minimal runtime image.
SQLx migrations are embedded into the application binary and run automatically on every API startup. Startup order is PostgreSQL pool creation, migration execution, Redis client creation, then HTTP server bind. If migration execution fails, startup stops with Failed to run database migrations and the HTTP server does not bind.
Migration files live in migrations/. Existing migrations may be single .sql files. For new schema changes, prefer reversible .up.sql and .down.sql pairs:
migrations/
20260430120000_create_some_table.up.sql
20260430120000_create_some_table.down.sql
Do not edit migrations after they have been applied in a shared environment. SQLx records checksums, so changing applied files can create version or checksum conflicts. Treat a migration as irreversible if it destroys data, transforms data non-bijectively, depends on external state, or would require guessing to revert. Irreversible migrations must include a .down.sql file that fails explicitly with a clear reason.
Migration file changes are not watched by Docker Compose. Restart or rebuild the API container after adding or changing migrations so the embedded migration set is compiled into the binary.
The local API image includes sqlx-cli pinned to the same SQLx version used by the application. Use the Docker network host name postgres for CLI commands run through Compose:
export DATABASE_URL=postgres://user:password@postgres:5432/app_db
docker compose run --rm -e DATABASE_URL api sqlx migrate add -r create_some_table
docker compose run --rm -e DATABASE_URL api sqlx migrate run
docker compose run --rm -e DATABASE_URL api sqlx migrate revertThe -r flag creates the required reversible .up.sql and .down.sql files. DATABASE_URL is passed to the one-off container for SQLx CLI use only; the application runtime still reads DB_HOST, DB_PORT, DB_USER, DB_PASSWORD, and DB_NAME.
Host CLI usage is optional. Install the matching CLI version with PostgreSQL support:
cargo install sqlx-cli --version 0.8.6 --no-default-features --features postgresUse localhost for the database host when running SQLx from the host against the Compose PostgreSQL port mapping:
export DATABASE_URL=postgres://user:password@localhost:5432/app_db
sqlx migrate add -r create_some_table
sqlx migrate run
sqlx migrate revertBase path: /api/v1
| Method | Path | Status |
|---|---|---|
GET |
/auth/discord/url |
Creates a server-side OAuth state and returns a Discord OAuth authorization URL. |
POST |
/auth/discord/callback |
Exchanges Discord code, stores identity/profile/encrypted tokens, creates session and CSRF cookies, and returns 204 No Content. |
GET |
/auth/session |
Returns the current authenticated user and session. |
GET |
/auth/sessions |
Lists active sessions for the current user. |
POST |
/auth/logout |
Revokes the current session and clears auth cookies. |
POST |
/auth/logout-all-other-sessions |
Revokes all active sessions except the current session. |
DELETE |
/auth/sessions/{session_id} |
Revokes another active session owned by the current user. |
GET |
/auth/csrf |
Refreshes the readable CSRF cookie for the current session. |
POST |
/guilds |
Creates an app-local guild and assigns the current user as guild admin. |
GET |
/guilds |
Lists active guilds where the current user has membership. |
GET |
/guilds/{guild_id} |
Returns an active guild where the current user has membership. |
PATCH |
/guilds/{guild_id} |
Updates a guild. Requires guild admin membership. |
DELETE |
/guilds/{guild_id} |
Soft-deletes a guild. Requires guild admin membership. |
POST |
/guilds/{guild_id}/invites |
Rotates and returns a guild invite code. Requires guild admin membership. |
POST |
/guild-invites/{invite_code}/accept |
Accepts an invite and adds the current user as an applicant. |
GET |
/guilds/{guild_id}/members |
Lists members for a guild where the current user has membership. |
PATCH |
/guilds/{guild_id}/members/{user_id} |
Promotes an applicant to raider or officer. Requires guild admin membership. |
POST |
/characters/{character_id}/warcraftlogs/refresh |
Refreshes the authenticated user's Warcraft Logs snapshot for a character. Requires CSRF. |
POST |
/characters/{character_id}/battlenet/refresh |
Refreshes the authenticated user's Battle.net snapshot for a character. Requires CSRF. |
GET |
/health |
Checks application liveness. |
GET |
/health/postgres |
Checks PostgreSQL connectivity with SELECT 1. |
GET |
/health/redis |
Checks Redis connectivity with an authenticated PING. |
Guild create and update payloads use name, realm, region, faction, and game_version. Supported game_version values are classic1x, classic, and classicann.
Single-guild responses include the guild's active invite URL only when the requesting member is an admin or officer:
{
"guild": {
"id": "00000000-0000-0000-0000-000000000000",
"name": "Raid Team",
"realm": "Draenor",
"region": "eu",
"faction": "horde",
"game_version": "classic",
"invite_url": "http://localhost:4200/guild-invites/invite-code",
"membership_role": "admin"
}
}GET /guilds/{guild_id}/members includes Discord identity data for each member:
{
"members": [
{
"user_id": "00000000-0000-0000-0000-000000000000",
"role": "raider",
"discord": {
"id": "123456789",
"username": "discord_name",
"global_name": "Display Name",
"avatar_url": "https://cdn.discordapp.com/avatars/123456789/avatar.png?size=128"
}
}
]
}Example:
curl http://localhost:8000/api/v1/auth/discord/url
curl http://localhost:8000/api/v1/health
curl http://localhost:8000/api/v1/health/postgres
curl http://localhost:8000/api/v1/health/redisAuthenticated mutating requests use cookie authentication and require the readable CSRF cookie value in the X-CSRF-Token header. Frontend requests must include credentials:
fetch("http://localhost:8000/api/v1/auth/logout", {
method: "POST",
credentials: "include",
headers: { "X-CSRF-Token": csrfToken },
});cargo check
cargo test --workspace --locked --all-features
cargo clippy --workspace --all-targets --all-features -- -D warnings
docker compose build apiDockerfile builds a locked release binary in a Rust Alpine builder image and copies it into a scratch runtime image.
The production runtime image does not include sqlx-cli or migration files on disk. Migrations are embedded during the builder stage while migrations/ is present in the build context, so the final image can remain FROM scratch.
Production also runs migrations on startup from the application binary. For future multi-replica Kubernetes deployments, this may move to a dedicated pre-deploy step or Kubernetes Job before application pods roll out.
The GitHub Actions workflow runs on pushes and pull requests to master, plus published GitHub releases. It performs clippy and tests, builds the production Docker image for linux/amd64, and publishes to GHCR and Docker Hub when publishing is allowed:
- Pushes to
masterpublishlatest,edge-master, andsha-*tags. - Published releases require a
vMAJOR.MINOR.PATCHorMAJOR.MINOR.PATCHtag and publish semver tags. - Pull requests from the same repository publish temporary
pr-*tags. - Closed same-repository pull requests trigger cleanup of the temporary PR image tags.
The Docker build enables provenance and SBOM output. Docker Scout reports high vulnerabilities and fails the workflow on critical vulnerabilities for published images.
- Database and Redis dependencies are initialized during startup and injected into routes through shared application state.
- Health endpoints use the shared PostgreSQL pool and Redis client.
- Auth sessions are durable in PostgreSQL. Redis is available in application state for future session caching but is not used as the session source of truth yet.
- Discord OAuth requests the
identifyscope only. Email login, passwords, roles, and permissions are not implemented. - GeoIP lookup is optional. Missing local MaxMind databases leave session location fields as
null.