Skip to content

Commit ba6ac89

Browse files
feat(api): allow choosing MySQL or PostgreSQL via DB_ENGINE (#151)
* feat(api): allow choosing MySQL or PostgreSQL via DB_ENGINE Developers can now pick sqlite, mysql, or postgres at startup through a single DB_ENGINE setting, with Docker Compose profiles and local dev containers aligned on the same configuration. Co-authored-by: Cursor <cursoragent@cursor.com> * fix(api): support emoji and punycode domains in analysis tasks (#150) * fix(api): support emoji and punycode domains in analysis tasks WebPage validation stores URLs as Unicode, which breaks requests for emoji domains like xn--3s8h30f.ws. Use AnyHttpUrl for punycode conversion and pass the encoded URL to the worker queue. Fixes cnumr/EcoIndex#416 Co-authored-by: Cursor <cursoragent@cursor.com> * fix(api): include request error details when URL pre-check fails Expose SSL, timeout, and DNS errors in the unreachable URL response instead of empty parentheses. Co-authored-by: Cursor <cursoragent@cursor.com> --------- Co-authored-by: Cursor <cursoragent@cursor.com> * fix(api): keep DATABASE_URL typed as str for ty compatibility Use an empty default and resolve it in the model validator so type checkers accept Settings().DATABASE_URL where a str is required. Co-authored-by: Cursor <cursoragent@cursor.com> * fix(api): run alembic migrations from the api project directory Alembic needs projects/ecoindex_api as the working directory so it can find alembic.ini and load the local .env during init-dev-project. Co-authored-by: Cursor <cursoragent@cursor.com> * fix(api): start dev DB before running alembic migrations Ensure mysql/postgres containers are up before migration-upgrade and document which DB_HOST to use for local dev versus Docker Compose. Co-authored-by: Cursor <cursoragent@cursor.com> --------- Co-authored-by: Cursor <cursoragent@cursor.com>
1 parent cdf9bd6 commit ba6ac89

13 files changed

Lines changed: 396 additions & 27 deletions

File tree

components/ecoindex/config/settings.py

Lines changed: 50 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,35 @@
1+
from typing import Literal
2+
from urllib.parse import quote_plus
3+
4+
from pydantic import model_validator
15
from pydantic_settings import BaseSettings, SettingsConfigDict
26

7+
DbEngine = Literal["sqlite", "mysql", "postgres"]
8+
9+
10+
def build_database_url(
11+
*,
12+
engine: DbEngine = "sqlite",
13+
host: str = "localhost",
14+
port: int | None = None,
15+
user: str = "ecoindex",
16+
password: str = "ecoindex",
17+
name: str = "ecoindex",
18+
) -> str:
19+
if engine == "sqlite":
20+
return "sqlite+aiosqlite:///db.sqlite3"
21+
22+
credentials = f"{quote_plus(user)}:{quote_plus(password)}"
23+
24+
if engine == "mysql":
25+
db_port = port or 3306
26+
return (
27+
f"mysql+aiomysql://{credentials}@{host}:{db_port}/{name}?charset=utf8mb4"
28+
)
29+
30+
db_port = port or 5432
31+
return f"postgresql+asyncpg://{credentials}@{host}:{db_port}/{name}"
32+
333

434
class Settings(BaseSettings):
535
model_config = SettingsConfigDict(env_file=".env")
@@ -12,7 +42,13 @@ class Settings(BaseSettings):
1242
CORS_ALLOWED_METHODS: list = ["*"]
1343
CORS_ALLOWED_ORIGINS: list = ["*"]
1444
DAILY_LIMIT_PER_HOST: int = 0
15-
DATABASE_URL: str = "sqlite+aiosqlite:///db.sqlite3"
45+
DATABASE_URL: str = ""
46+
DB_ENGINE: DbEngine = "sqlite"
47+
DB_HOST: str = "localhost"
48+
DB_PORT: int | None = None
49+
DB_USER: str = "ecoindex"
50+
DB_PASSWORD: str = "ecoindex"
51+
DB_NAME: str = "ecoindex"
1652
DEBUG: bool = False
1753
DOCKER_CONTAINER: bool = False
1854
ENABLE_SCREENSHOT: bool = False
@@ -40,3 +76,16 @@ class Settings(BaseSettings):
4076
TZ: str = "Europe/Paris"
4177
WAIT_AFTER_SCROLL: int = 3
4278
WAIT_BEFORE_SCROLL: int = 3
79+
80+
@model_validator(mode="after")
81+
def resolve_database_url(self) -> "Settings":
82+
if not self.DATABASE_URL:
83+
self.DATABASE_URL = build_database_url(
84+
engine=self.DB_ENGINE,
85+
host=self.DB_HOST,
86+
port=self.DB_PORT,
87+
user=self.DB_USER,
88+
password=self.DB_PASSWORD,
89+
name=self.DB_NAME,
90+
)
91+
return self

projects/ecoindex_api/.env.template

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,20 @@
11
# API_PORT=8001
22
# API_VERSION=latest
33
# DAILY_LIMIT_PER_HOST=10
4-
# DB_HOST=db
4+
# DB_ENGINE=sqlite
5+
# DB_ENGINE=mysql
6+
# DB_ENGINE=postgres
7+
# Local dev (task api:start-dev): DB_HOST=localhost
8+
# Docker Compose: DB_HOST=db-mysql or DB_HOST=db-postgres
9+
# DB_HOST=localhost
10+
# DB_HOST=db-mysql
11+
# DB_HOST=db-postgres
512
# DB_NAME=ecoindex
613
# DB_PASSWORD=ecoindex
714
# DB_PORT=3306
15+
# DB_PORT=5432
816
# DB_USER=ecoindex
17+
# DATABASE_URL=
918
# DEBUG=1
1019
# ENABLE_SCREENSHOT=1
1120
# EXCLUDED_HOSTS='["localhost","127.0.0.1"]'

projects/ecoindex_api/README.md

Lines changed: 25 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,7 @@ The API specification can be found in the [documentation](projects/ecoindex_api/
3030

3131
With this docker setup you get 5 services running that are enough to make it all work:
3232

33-
- `db`: A MySQL instance
33+
- `db-mysql` or `db-postgres`: database instance (selected with `DB_ENGINE`)
3434
- `api`: The API instance running FastAPI application
3535
- `worker`: The RQ task worker that runs ecoindex analysis
3636
- `valkey`: The [Valkey](https://valkey.io/) instance (Redis-compatible) used by the RQ worker and API cache
@@ -40,7 +40,16 @@ With this docker setup you get 5 services running that are enough to make it all
4040

4141
```bash
4242
cp docker-compose.yml.template docker-compose.yml && \
43-
docker compose up -d
43+
cp .env.template .env && \
44+
task api:docker-up-mysql -- -d
45+
```
46+
47+
For PostgreSQL instead:
48+
49+
```bash
50+
cp docker-compose.yml.template docker-compose.yml && \
51+
cp .env.template .env && \
52+
task api:docker-up-postgres -- -d
4453
```
4554

4655
Every services should start normaly, then you can go to:
@@ -62,7 +71,13 @@ Here are the environment variables you can configure in your `.env` file:
6271
| API | `CORS_ALLOWED_ORIGINS` | `*` | See [MDN web doc](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Access-Control-Allow-Origin) |
6372
| API | `EXCLUDED_HOSTS` | `["localhost", "127.0.0.1"]` | You can configure a list of hosts that will be excluded from the analysis. |
6473
| API, Worker | `DAILY_LIMIT_PER_HOST` | 0 | When this variable is set, it won't be possible for a same host to make more request than defined in the same day to avoid overload. If the variable is set, you will get a header `x-remaining-daily-requests: 6` in your response. It is used for the POST methods. If you reach your authorized request quota for the day, the next requests will give you a 429 response. If the variable is set to 0, no limit is set |
65-
| API, Worker | `DATABASE_URL` | `sqlite+aiosqlite:///./sql_app.db` | If you run your mysql instance on a dedicated server, you can configure it with your credentials. By default, it uses an sqlite database when running in local | |
74+
| API, Worker | `DB_ENGINE` | `sqlite` | Database backend: `sqlite`, `mysql` or `postgres`. Used to build `DATABASE_URL` when it is not set explicitly. |
75+
| API, Worker | `DB_HOST` | `localhost` | Database host. Use `db-mysql` or `db-postgres` in Docker Compose. |
76+
| API, Worker | `DB_PORT` | `3306` / `5432` | Database port. Defaults to the standard port of the selected engine when omitted. |
77+
| API, Worker | `DB_USER` | `ecoindex` | Database user. |
78+
| API, Worker | `DB_PASSWORD` | `ecoindex` | Database password. |
79+
| API, Worker | `DB_NAME` | `ecoindex` | Database name. |
80+
| API, Worker | `DATABASE_URL` | built from `DB_ENGINE` | Optional explicit SQLAlchemy URL. When set, it overrides `DB_ENGINE` and related variables. Examples: `sqlite+aiosqlite:///db.sqlite3`, `mysql+aiomysql://user:pass@host/db?charset=utf8mb4`, `postgresql+asyncpg://user:pass@host:5432/db` |
6681
| API, Worker | `SENTRY_DSN` | `` | If you want to use [Sentry](https://sentry.io/) to monitor your application, set this variable with your project DSN. |
6782
| API, Worker | `SENTRY_ENVIRONMENT` | `` | Optional Sentry environment name (e.g. `production`, `staging`). If not set, defaults to `development` when `DEBUG=True`, otherwise `production`. |
6883
| API, Worker | `SENTRY_TRACES_SAMPLE_RATE`| `0.0` | Fraction of transactions to send to Sentry for performance monitoring (0.0 to 1.0). Set to `0.1` in production to sample 10% of requests. |
@@ -132,7 +147,13 @@ task api:init-dev-project # Initialize API dev environment (Playwright, .env, mi
132147

133148
### Run the API locally
134149

135-
Valkey and RustFS are started automatically via Docker. Then run:
150+
Valkey and RustFS are started automatically via Docker. Set `DB_ENGINE` in `.env` to choose the database:
151+
152+
- `sqlite` (default): no database container, file stored locally
153+
- `mysql`: starts a local MySQL container on port 3306
154+
- `postgres`: starts a local PostgreSQL container on port 5432
155+
156+
Then run:
136157

137158
```bash
138159
task api:start-dev

projects/ecoindex_api/Taskfile.yml

Lines changed: 20 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -121,18 +121,32 @@ tasks:
121121
silent: true
122122

123123
docker-up:
124-
desc: Start the docker-compose API
124+
desc: Start the docker-compose API (uses DB_ENGINE from .env, default mysql)
125125
deps: [init-env, init-docker-compose]
126126
cmds:
127-
- docker compose up {{.CLI_ARGS}}
127+
- bash scripts/docker_compose_up.sh {{.CLI_ARGS}}
128+
silent: true
129+
130+
docker-up-mysql:
131+
desc: Start the docker-compose API with MySQL
132+
deps: [init-env, init-docker-compose]
133+
cmds:
134+
- DB_ENGINE=mysql DB_HOST=db-mysql DB_PORT=3306 bash scripts/docker_compose_up.sh {{.CLI_ARGS}}
135+
silent: true
136+
137+
docker-up-postgres:
138+
desc: Start the docker-compose API with PostgreSQL
139+
deps: [init-env, init-docker-compose]
140+
cmds:
141+
- DB_ENGINE=postgres DB_HOST=db-postgres DB_PORT=5432 bash scripts/docker_compose_up.sh {{.CLI_ARGS}}
128142
silent: true
129143

130144
docker-down:
131145
desc: Stop the docker-compose API
132146
preconditions:
133147
- test -f docker-compose.yml
134148
cmds:
135-
- docker compose down {{.CLI_ARGS}}
149+
- docker compose --profile mysql --profile postgres down {{.CLI_ARGS}}
136150
silent: true
137151

138152
docker-exec:
@@ -156,23 +170,19 @@ tasks:
156170
desc: Create a new alembic migration
157171
cmds:
158172
- uv run --package ecoindex_api alembic revision --autogenerate -m "{{.CLI_ARGS}}"
159-
dir: ../..
160173
silent: true
161174

162175
migration-upgrade:
163176
desc: Upgrade the database to the last migration
177+
deps: [start-dev-infra]
164178
cmds:
165179
- uv run --package ecoindex_api alembic upgrade head
166-
dir: ../..
167180
silent: true
168181

169182
start-dev-infra:
170183
internal: true
171184
cmds:
172185
- bash scripts/start_dev_infra.sh
173-
status:
174-
- docker inspect ecoindex-dev-valkey --format '{{.State.Running}}' 2>/dev/null | grep -q true
175-
- docker inspect ecoindex-dev-rustfs --format '{{.State.Running}}' 2>/dev/null | grep -q true
176186
silent: true
177187

178188
start-worker:
@@ -226,7 +236,7 @@ tasks:
226236

227237
start-dev:
228238
deps: [start-backend, start-worker, start-rq-dashboard]
229-
desc: Start the backend, the worker and the RQ dashboard
239+
desc: Start the backend, the worker and the RQ dashboard (set DB_ENGINE in .env)
230240
cmds:
231241
- echo "Starting the backend, worker and RQ dashboard (http://localhost:{{.RQ_DASHBOARD_PORT}})"
232242
silent: true
@@ -254,7 +264,7 @@ tasks:
254264
pkill -f "uvicorn ecoindex.backend.main:app" 2>/dev/null || true
255265
256266
echo "Stopping Docker containers..."
257-
docker rm -f ecoindex-dev-valkey ecoindex-dev-rustfs 2>/dev/null || true
267+
docker rm -f ecoindex-dev-valkey ecoindex-dev-rustfs ecoindex-dev-mysql ecoindex-dev-postgres 2>/dev/null || true
258268
echo "Local development environment stopped."
259269
silent: true
260270

projects/ecoindex_api/docker-compose.yml.template

Lines changed: 46 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,10 @@
11
services:
2-
db:
3-
image: mysql
2+
db-mysql:
3+
profiles: ["mysql"]
4+
image: mysql:8
45
restart: always
56
volumes:
6-
- db:/var/lib/mysql
7+
- db-mysql:/var/lib/mysql
78
environment:
89
MYSQL_DATABASE: ${DB_NAME:-ecoindex}
910
MYSQL_USER: ${DB_USER:-ecoindex}
@@ -17,6 +18,24 @@ services:
1718
retries: 10
1819
interval: 2s
1920

21+
db-postgres:
22+
profiles: ["postgres"]
23+
image: postgres:16-alpine
24+
restart: always
25+
volumes:
26+
- db-postgres:/var/lib/postgresql/data
27+
environment:
28+
POSTGRES_DB: ${DB_NAME:-ecoindex}
29+
POSTGRES_USER: ${DB_USER:-ecoindex}
30+
POSTGRES_PASSWORD: ${DB_PASSWORD:-ecoindex}
31+
ports:
32+
- "${DB_PORT:-5432}:5432"
33+
healthcheck:
34+
test: ["CMD-SHELL", "pg_isready -U $$POSTGRES_USER -d $$POSTGRES_DB"]
35+
timeout: 5s
36+
retries: 10
37+
interval: 2s
38+
2039
backend:
2140
image: vvatelot/ecoindex-api-backend:${API_VERSION:-latest}
2241
restart: always
@@ -25,7 +44,12 @@ services:
2544
ports:
2645
- "${API_PORT:-8001}:8000"
2746
environment:
28-
DATABASE_URL: mysql+aiomysql://${DB_USER:-ecoindex}:${DB_PASSWORD:-ecoindex}@${DB_HOST:-db}/${DB_NAME:-ecoindex}?charset=utf8mb4
47+
DB_ENGINE: ${DB_ENGINE:-mysql}
48+
DB_HOST: ${DB_HOST:-db-mysql}
49+
DB_PORT: ${DB_PORT:-}
50+
DB_USER: ${DB_USER:-ecoindex}
51+
DB_PASSWORD: ${DB_PASSWORD:-ecoindex}
52+
DB_NAME: ${DB_NAME:-ecoindex}
2953
DEBUG: ${DEBUG:-0}
3054
REDIS_CACHE_HOST: ${REDIS_CACHE_HOST:-valkey}
3155
SCREENSHOT_FILESYSTEM_PATH: ${SCREENSHOT_FILESYSTEM_PATH:-/code/screenshots}
@@ -39,8 +63,12 @@ services:
3963
SCREENSHOT_S3_SECRET_ACCESS_KEY: ${SCREENSHOT_S3_SECRET_ACCESS_KEY:-ecoindex-secret-key-change-me}
4064
TZ: ${TZ:-Europe/Paris}
4165
depends_on:
42-
db:
66+
db-mysql:
4367
condition: service_healthy
68+
required: false
69+
db-postgres:
70+
condition: service_healthy
71+
required: false
4472
rustfs-init:
4573
condition: service_completed_successfully
4674
valkey:
@@ -54,7 +82,12 @@ services:
5482
env_file:
5583
- .env
5684
environment:
57-
DATABASE_URL: mysql+aiomysql://${DB_USER:-ecoindex}:${DB_PASSWORD:-ecoindex}@${DB_HOST:-db}/${DB_NAME:-ecoindex}?charset=utf8mb4
85+
DB_ENGINE: ${DB_ENGINE:-mysql}
86+
DB_HOST: ${DB_HOST:-db-mysql}
87+
DB_PORT: ${DB_PORT:-}
88+
DB_USER: ${DB_USER:-ecoindex}
89+
DB_PASSWORD: ${DB_PASSWORD:-ecoindex}
90+
DB_NAME: ${DB_NAME:-ecoindex}
5891
DEBUG: ${DEBUG:-0}
5992
REDIS_CACHE_HOST: ${REDIS_CACHE_HOST:-valkey}
6093
RQ_WORKERS: ${RQ_WORKERS:-3}
@@ -70,8 +103,12 @@ services:
70103
TZ: ${TZ:-Europe/Paris}
71104
ENABLE_SCREENSHOT: ${ENABLE_SCREENSHOT:-0}
72105
depends_on:
73-
db:
106+
db-mysql:
107+
condition: service_healthy
108+
required: false
109+
db-postgres:
74110
condition: service_healthy
111+
required: false
75112
rustfs-init:
76113
condition: service_completed_successfully
77114
valkey:
@@ -120,6 +157,7 @@ services:
120157
restart: "no"
121158

122159
volumes:
123-
db:
160+
db-mysql:
161+
db-postgres:
124162
valkey:
125163
rustfs_data:

projects/ecoindex_api/docker/backend/dockerfile

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,7 @@ RUN pip install --no-cache-dir --upgrade -r /code/requirements.txt
2525

2626
COPY projects/ecoindex_api/dist/$wheel $wheel
2727
RUN pip install --no-cache-dir $wheel
28-
RUN pip install --no-cache-dir aiomysql gunicorn
28+
RUN pip install --no-cache-dir aiomysql asyncpg gunicorn
2929

3030
RUN rm -rf $wheel requirements.txt /tmp/dist /var/lib/{apt,dpkg,cache,log}/
3131

projects/ecoindex_api/docker/worker/dockerfile

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,7 @@ RUN pip install --no-cache-dir --upgrade -r /code/requirements.txt
2222

2323
COPY projects/ecoindex_api/dist/$wheel $wheel
2424
RUN pip install --no-cache-dir $wheel
25-
RUN pip install --no-cache-dir aiomysql
25+
RUN pip install --no-cache-dir aiomysql asyncpg
2626

2727
RUN playwright install chromium --with-deps
2828

projects/ecoindex_api/pyproject.toml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,7 +46,9 @@ worker = [
4646
"playwright-stealth>=1.0.6",
4747
]
4848
dev = [
49+
"aiomysql>=0.2.0",
4950
"aiosqlite>=0.19.0",
51+
"asyncpg>=0.29.0",
5052
"rq-dashboard",
5153
"typing-extensions>=4.8.0",
5254
"watchdog>=6.0.0",
Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
#!/usr/bin/env bash
2+
set -euo pipefail
3+
4+
API_DIR="$(cd "$(dirname "$0")/.." && pwd)"
5+
cd "$API_DIR"
6+
7+
if [ -f .env ]; then
8+
set -a
9+
# shellcheck disable=SC1091
10+
. ./.env
11+
set +a
12+
fi
13+
14+
DB_ENGINE="${DB_ENGINE:-mysql}"
15+
16+
case "$DB_ENGINE" in
17+
mysql)
18+
export DB_ENGINE
19+
export DB_HOST="${DB_HOST:-db-mysql}"
20+
export DB_PORT="${DB_PORT:-3306}"
21+
docker compose --profile mysql up "$@"
22+
;;
23+
postgres)
24+
export DB_ENGINE
25+
export DB_HOST="${DB_HOST:-db-postgres}"
26+
export DB_PORT="${DB_PORT:-5432}"
27+
docker compose --profile postgres up "$@"
28+
;;
29+
sqlite)
30+
echo "DB_ENGINE=sqlite is not supported in Docker Compose. Use mysql or postgres." >&2
31+
exit 1
32+
;;
33+
*)
34+
echo "Unknown DB_ENGINE: $DB_ENGINE" >&2
35+
exit 1
36+
;;
37+
esac

0 commit comments

Comments
 (0)