Skip to content

Commit 85a0db2

Browse files
authored
Merge pull request #6 from guix77/dev
better DB
2 parents 2eea8ee + 0ef25ce commit 85a0db2

14 files changed

Lines changed: 505 additions & 141 deletions

File tree

.github/workflows/test.yml

Lines changed: 18 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -1,35 +1,29 @@
1-
name: Test
1+
name: Tests
22

33
on:
44
pull_request:
5-
branches: [ "main" ]
5+
branches: [ main ]
66

77
jobs:
88
test:
9-
name: Run tests
109
runs-on: ubuntu-latest
11-
steps:
12-
- name: Checkout repository
13-
uses: actions/checkout@v4
10+
strategy:
11+
matrix:
12+
python-version: ["3.11"]
1413

15-
- name: Set up Python
16-
uses: actions/setup-python@v4
17-
with:
18-
python-version: '3.11'
14+
steps:
15+
- uses: actions/checkout@v4
1916

20-
- name: Restore pip cache
21-
uses: actions/cache@v4
22-
with:
23-
path: ~/.cache/pip
24-
key: ${{ runner.os }}-pip-${{ hashFiles('**/requirements.txt') }}
25-
restore-keys: |
26-
${{ runner.os }}-pip-
17+
- name: Set up Python ${{ matrix.python-version }}
18+
uses: actions/setup-python@v5
19+
with:
20+
python-version: ${{ matrix.python-version }}
2721

28-
- name: Install dependencies
29-
run: |
30-
python -m pip install --upgrade pip
31-
if [ -f requirements-dev.txt ]; then pip install -r requirements-dev.txt; fi
22+
- name: Install dependencies
23+
run: |
24+
python -m pip install --upgrade pip
25+
pip install -e ".[dev]"
3226
33-
- name: Run pytest
34-
run: |
35-
pytest -q
27+
- name: Run tests
28+
run: |
29+
pytest -v

.python-version

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
3.11.14

app/routers/domain.py

Lines changed: 43 additions & 73 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22
from pydantic import BaseModel
33
from typing import Optional
44
from app.services.cache import WhoisCache
5-
from app.services.whois import WhoisService
5+
from app.services.whois import WhoisService, parse_whois
66
from app.services.rate_limiter import RateLimiter
77
import logging
88

@@ -46,80 +46,33 @@ async def get_whois(
4646
tld = parts[-1]
4747

4848
# 2. Cache
49-
def parse_whois(raw: str, tld: str):
50-
"""Extract statut, creation_date, registrar, pendingDelete, redemptionPeriod for all TLDs.
51-
52-
This is heuristic: we search common WHOIS labels case-insensitively.
53-
Returns a dict with keys 'statut', 'creation_date', 'registrar', 'pendingDelete', 'redemptionPeriod'.
54-
"""
55-
if not raw:
56-
return {
57-
"statut": None,
58-
"creation_date": None,
59-
"registrar": None,
60-
"pendingDelete": False,
61-
"redemptionPeriod": False,
62-
}
63-
64-
raw_lines = [l.strip() for l in raw.splitlines() if l.strip()]
65-
lower = raw.lower()
66-
67-
statut = None
68-
creation_date = None
69-
registrar = None
70-
pendingDelete = False
71-
redemptionPeriod = False
72-
73-
import re
74-
75-
# Common patterns (now generalized for all TLDs)
76-
for line in raw_lines:
77-
l = line.lower()
78-
# Registrar: (ignore Registrar WHOIS Server and Registrar URL)
79-
if registrar is None and l.startswith("registrar:") and not ("whois server" in l or "url" in l):
80-
parts = line.split(":", 1)
81-
if len(parts) == 2:
82-
registrar = parts[1].strip()
83-
continue
84-
# Creation date
85-
if creation_date is None and ("creation date" in l or "created on" in l or "created:" in l or "creation:" in l or "registered on" in l):
86-
parts = line.split(":", 1)
87-
if len(parts) == 2:
88-
creation_date = parts[1].strip()
89-
continue
90-
# Status lines (can have multiple)
91-
if "status:" in l or l.startswith("domain status"):
92-
if statut is None:
93-
parts = line.split(":", 1)
94-
if len(parts) == 2:
95-
statut = parts[1].strip()
96-
# Check for pendingDelete and redemptionPeriod in any status line
97-
if "pendingdelete" in l:
98-
pendingDelete = True
99-
if "redemptionperiod" in l:
100-
redemptionPeriod = True
101-
continue
102-
103-
# Fallback regex for Registrar lines like 'Registrar Name' without colon
104-
if registrar is None:
105-
m = re.search(r"registrar\s+([\w\-\. ]{3,})", raw, re.IGNORECASE)
106-
if m:
107-
registrar = m.group(1).strip()
108-
109-
return {
110-
"statut": statut,
111-
"creation_date": creation_date,
112-
"registrar": registrar,
113-
"pendingDelete": pendingDelete,
114-
"redemptionPeriod": redemptionPeriod,
115-
}
49+
# parser is provided by app.services.whois.parse_whois
11650

11751
if force != 1:
11852
cached_data = cache.get(domain)
11953
if cached_data:
120-
# enrich from raw before removing it
121-
parsed = parse_whois(cached_data.get("raw"), tld)
122-
# ne pas exposer le champ raw dans la réponse JSON
54+
# Prefer parsed fields persisted in DB. Only fallback to parsing raw if fields are missing.
55+
parsed = {
56+
"statut": cached_data.get("statut"),
57+
"creation_date": cached_data.get("creation_date"),
58+
"registrar": cached_data.get("registrar"),
59+
"pendingDelete": cached_data.get("pendingDelete"),
60+
"redemptionPeriod": cached_data.get("redemptionPeriod"),
61+
}
62+
# If any key is missing/None, parse raw as fallback
63+
if not any(v is not None for v in parsed.values()):
64+
parsed = parse_whois(cached_data.get("raw"), tld)
65+
else:
66+
# ensure booleans normalized (could be stored as 0/1)
67+
try:
68+
parsed["pendingDelete"] = bool(int(parsed["pendingDelete"])) if parsed["pendingDelete"] is not None else False
69+
except Exception:
70+
parsed["pendingDelete"] = bool(parsed.get("pendingDelete"))
71+
try:
72+
parsed["redemptionPeriod"] = bool(int(parsed["redemptionPeriod"])) if parsed["redemptionPeriod"] is not None else False
73+
except Exception:
74+
parsed["redemptionPeriod"] = bool(parsed.get("redemptionPeriod"))
75+
# do not expose raw in responses
12376
cached_data.pop("raw", None)
12477
# inject parsed fields so response_model includes them
12578
cached_data.update(parsed)
@@ -158,8 +111,25 @@ def parse_whois(raw: str, tld: str):
158111
cached_data = cache.get(domain)
159112
if not cached_data:
160113
raise HTTPException(status_code=500, detail="Failed to retrieve data from cache after save")
161-
# enrich from raw before removing it (comme pour le cache hit)
162-
parsed = parse_whois(cached_data.get("raw"), tld)
114+
# Prefer parsed fields persisted in DB. Only fallback to parsing raw if fields are missing.
115+
parsed = {
116+
"statut": cached_data.get("statut"),
117+
"creation_date": cached_data.get("creation_date"),
118+
"registrar": cached_data.get("registrar"),
119+
"pendingDelete": cached_data.get("pendingDelete"),
120+
"redemptionPeriod": cached_data.get("redemptionPeriod"),
121+
}
122+
if not any(v is not None for v in parsed.values()):
123+
parsed = parse_whois(cached_data.get("raw"), tld)
124+
else:
125+
try:
126+
parsed["pendingDelete"] = bool(int(parsed["pendingDelete"])) if parsed["pendingDelete"] is not None else False
127+
except Exception:
128+
parsed["pendingDelete"] = bool(parsed.get("pendingDelete"))
129+
try:
130+
parsed["redemptionPeriod"] = bool(int(parsed["redemptionPeriod"])) if parsed["redemptionPeriod"] is not None else False
131+
except Exception:
132+
parsed["redemptionPeriod"] = bool(parsed.get("redemptionPeriod"))
163133
cached_data.pop("raw", None)
164134
cached_data.update(parsed)
165135
# ensure coherence: if pendingDelete or redemptionPeriod, available must be False

app/services/cache.py

Lines changed: 103 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -7,20 +7,32 @@
77
logger = logging.getLogger(__name__)
88

99
class WhoisCache:
10-
def __init__(self):
11-
self.db_path = "data/whois_cache.db"
10+
def __init__(self, db_path: str = None):
11+
# Allow overriding DB path for tests or alternate deployments
12+
self.db_path = db_path or "data/whois_cache.db"
1213
os.makedirs(os.path.dirname(self.db_path), exist_ok=True)
1314
self._init_db()
15+
# Run lightweight migrations/backfill if needed (safe to call on every start)
16+
try:
17+
self._migrate_if_needed()
18+
except Exception:
19+
logger.exception("Migration failed during cache init. Continuing without migration.")
1420

1521
def _init_db(self):
1622
with sqlite3.connect(self.db_path) as conn:
23+
# Create table with parsed fields. For older DBs, migration script will add missing columns.
1724
conn.execute("""
1825
CREATE TABLE IF NOT EXISTS whois_cache (
1926
domain TEXT PRIMARY KEY,
2027
tld TEXT,
2128
available BOOLEAN,
2229
checked_at TEXT,
23-
raw TEXT
30+
raw TEXT,
31+
statut TEXT,
32+
creation_date TEXT,
33+
registrar TEXT,
34+
pendingDelete BOOLEAN,
35+
redemptionPeriod BOOLEAN
2436
)
2537
""")
2638

@@ -39,15 +51,100 @@ def get(self, domain: str) -> Optional[Dict[str, Any]]:
3951
logger.error(f"Cache error on get({domain}): {e}")
4052
return None
4153
return None
54+
def _ensure_bool(self, val):
55+
# SQLite stores booleans as 0/1 or NULL. Normalize to Python bool where appropriate.
56+
if val is None:
57+
return False
58+
try:
59+
return bool(int(val))
60+
except Exception:
61+
return bool(val)
4262

4363
def set(self, domain: str, tld: str, available: bool, raw: str):
4464
checked_at = datetime.now(timezone.utc).isoformat().replace("+00:00", "Z")
65+
# parse raw to extract fields to persist
66+
try:
67+
from app.services.whois import parse_whois
68+
parsed = parse_whois(raw, tld)
69+
except Exception:
70+
parsed = {"statut": None, "creation_date": None, "registrar": None, "pendingDelete": False, "redemptionPeriod": False}
71+
4572
try:
4673
with sqlite3.connect(self.db_path) as conn:
4774
conn.execute("""
48-
INSERT OR REPLACE INTO whois_cache (domain, tld, available, checked_at, raw)
49-
VALUES (?, ?, ?, ?, ?)
50-
""", (domain, tld, available, checked_at, raw))
75+
INSERT OR REPLACE INTO whois_cache
76+
(domain, tld, available, checked_at, raw, statut, creation_date, registrar, pendingDelete, redemptionPeriod)
77+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
78+
""",
79+
(
80+
domain,
81+
tld,
82+
int(bool(available)),
83+
checked_at,
84+
raw,
85+
parsed.get("statut"),
86+
parsed.get("creation_date"),
87+
parsed.get("registrar"),
88+
int(bool(parsed.get("pendingDelete"))),
89+
int(bool(parsed.get("redemptionPeriod")))
90+
))
5191
logger.debug(f"Cache SET for domain: {domain} (checked_at: {checked_at})")
5292
except sqlite3.Error as e:
5393
logger.error(f"Cache error on set({domain}): {e}")
94+
def _migrate_if_needed(self):
95+
"""Detect missing expected columns, add them, and backfill parsed fields from raw."""
96+
EXPECTED = {
97+
"statut": "TEXT",
98+
"creation_date": "TEXT",
99+
"registrar": "TEXT",
100+
"pendingDelete": "BOOLEAN",
101+
"redemptionPeriod": "BOOLEAN",
102+
}
103+
104+
try:
105+
with sqlite3.connect(self.db_path) as conn:
106+
cur = conn.execute("PRAGMA table_info('whois_cache')")
107+
existing = {row[1] for row in cur.fetchall()} # column names
108+
to_add = [(n, t) for n, t in EXPECTED.items() if n not in existing]
109+
if to_add:
110+
logger.info(f"Cache migration: adding columns: {[n for n, _ in to_add]}")
111+
for name, coltype in to_add:
112+
try:
113+
conn.execute(f"ALTER TABLE whois_cache ADD COLUMN {name} {coltype}")
114+
except sqlite3.Error:
115+
logger.exception(f"Failed to add column {name}; continuing")
116+
conn.commit()
117+
118+
# Backfill parsed fields for rows where raw is present and parsed columns are NULL/empty
119+
sel = "SELECT domain, raw, tld FROM whois_cache WHERE raw IS NOT NULL AND (statut IS NULL OR creation_date IS NULL OR registrar IS NULL OR pendingDelete IS NULL OR redemptionPeriod IS NULL)"
120+
rows = conn.execute(sel).fetchall()
121+
if rows:
122+
logger.info(f"Cache migration: backfilling parsed fields for {len(rows)} rows")
123+
# Import parser locally to avoid circular issues
124+
try:
125+
from app.services.whois import parse_whois
126+
except Exception:
127+
logger.exception("Could not import parse_whois for migration; skipping backfill")
128+
return
129+
130+
upd = "UPDATE whois_cache SET statut = ?, creation_date = ?, registrar = ?, pendingDelete = ?, redemptionPeriod = ? WHERE domain = ?"
131+
updated = 0
132+
for domain, raw, tld in rows:
133+
try:
134+
parsed = parse_whois(raw, tld)
135+
conn.execute(upd, (
136+
parsed.get("statut"),
137+
parsed.get("creation_date"),
138+
parsed.get("registrar"),
139+
int(bool(parsed.get("pendingDelete"))),
140+
int(bool(parsed.get("redemptionPeriod"))),
141+
domain,
142+
))
143+
updated += 1
144+
except Exception:
145+
logger.exception(f"Failed to backfill domain {domain}; skipping")
146+
if updated:
147+
conn.commit()
148+
logger.info(f"Cache migration: backfilled {updated} rows")
149+
except sqlite3.Error:
150+
logger.exception("SQLite error during cache migration")

0 commit comments

Comments
 (0)