app: FastAPI API + serve CLI (Phase 2)
- create_app(db_path, img_dir, state_dir) factory; every route + payload
from app.state — selfcheck runs the real router on a temp DB copy
- /api/health (ok/db/counts), /api/stats (live/new/likes/maybes/nopes/
last_import), /api/deck (undecided semantics, score/first_seen/id DESC,
opaque '<score>|<first_seen>|<id>' cursor, strictly-after tuple page,
remaining count), /api/swipe upsert + DELETE (idempotent), /api/list
(decision/sort/q/limit/offset, total), /api/listing/{id}, /api/import
(importer, HTTP 200 even when gates fail), /api/badge/seen, /img/{id}
(cached file -> 302 img_url -> 404), static routes (/, manifest, sw.js,
icons, apple-touch; no-cache on index + sw.js)
- frozen payload contract incl. img=/img/{id} when img_url or cached file
- serve.py: argparse (--db/--img-dir/--state-dir/--host/--port, defaults
127.0.0.1:9453) -> uvicorn.run
- verified: 560-deep deck walks clean with zero page overlap, swipe ->
list -> edit -> delete -> back on deck, badge roundtrip (new 560 -> 0);
real uvicorn returns true 302 for the img fallback (TestClient in this
fastapi/httpx pair mangles RedirectResponse into a 404 — framework
artifact, not app behavior)
This commit is contained in:
405
app/api.py
Normal file
405
app/api.py
Normal file
@@ -0,0 +1,405 @@
|
||||
"""Bike-Tinder API (Phase 2) — FastAPI app factory.
|
||||
|
||||
create_app(db_path, img_dir, state_dir=None) -> FastAPI
|
||||
|
||||
The factory keeps every path injectable so the selfcheck can run the real
|
||||
router against a temp copy of the DB (`fastapi.testclient.TestClient`).
|
||||
Serve via app/serve.py (uvicorn) — see docs/DESIGN.md for the surface.
|
||||
|
||||
Payload contract (deck/list/listing items — exact field names the FE relies
|
||||
on; frozen in the task brief):
|
||||
{id, title, price_display, price_num, km, location, region ('ns'|'nb'),
|
||||
url, img ("/img/{id}" if img_url or cached file exists else null), desc,
|
||||
family, score, year, seller_type, extras (array of strings),
|
||||
decision (null|'like'|'maybe'|'nope'), first_seen}
|
||||
"""
|
||||
import json
|
||||
import os
|
||||
|
||||
from fastapi import FastAPI, HTTPException, Query
|
||||
from fastapi.responses import FileResponse, JSONResponse, RedirectResponse
|
||||
|
||||
from . import db as dbmod
|
||||
from . import importer as imp
|
||||
|
||||
ACTIONS = ('like', 'nope', 'maybe')
|
||||
|
||||
# --- SQL ------------------------------------------------------------------
|
||||
|
||||
DECK_WHERE = (
|
||||
"l.status = 'active'"
|
||||
" AND NOT EXISTS (SELECT 1 FROM swipes s WHERE s.listing_id = l.id"
|
||||
" AND s.action = 'nope')"
|
||||
" AND NOT EXISTS (SELECT 1 FROM swipes s WHERE s.listing_id = l.id"
|
||||
" AND s.action = 'like')"
|
||||
" AND NOT EXISTS (SELECT 1 FROM swipes s WHERE s.listing_id = l.id"
|
||||
" AND s.action = 'maybe'"
|
||||
" AND COALESCE(s.updated_at, s.created_at)"
|
||||
" > datetime('now', '-7 days'))"
|
||||
)
|
||||
|
||||
DECK_SELECT = (
|
||||
"SELECT l.*, s.action AS decision"
|
||||
" FROM listings l LEFT JOIN swipes s ON s.listing_id = l.id"
|
||||
" WHERE " + DECK_WHERE
|
||||
)
|
||||
|
||||
DECK_ORDER = " ORDER BY l.score DESC, COALESCE(l.first_seen,'') DESC, l.id DESC"
|
||||
|
||||
LIST_SORTS = {
|
||||
'score': 'l.score DESC, COALESCE(l.first_seen,\'\') DESC, l.id DESC',
|
||||
'price_asc': 'l.price_num IS NULL ASC, l.price_num ASC, l.id DESC',
|
||||
'price_desc': 'l.price_num IS NULL ASC, l.price_num DESC, l.id DESC',
|
||||
'newest': 'COALESCE(l.first_seen,\'\') DESC, l.id DESC',
|
||||
'km': 'l.km IS NULL ASC, l.km ASC, l.id DESC',
|
||||
}
|
||||
|
||||
LIST_DECISIONS = {
|
||||
'all': '1=1',
|
||||
'undecided': 's.action IS NULL',
|
||||
'like': "s.action = 'like'",
|
||||
'maybe': "s.action = 'maybe'",
|
||||
'nope': "s.action = 'nope'",
|
||||
}
|
||||
|
||||
REGION_NAMES = {'ns': 'NS', 'nb': 'NB'}
|
||||
|
||||
|
||||
# --- helpers --------------------------------------------------------------
|
||||
|
||||
def get_conn(app):
|
||||
conn = dbmod.connect(app.state.db_path)
|
||||
dbmod.init_db(conn)
|
||||
return conn
|
||||
|
||||
|
||||
def set_pref(conn, key, value):
|
||||
conn.execute(
|
||||
"INSERT INTO prefs (key, value) VALUES (?, ?) "
|
||||
"ON CONFLICT(key) DO UPDATE SET value=excluded.value", (key, value))
|
||||
|
||||
|
||||
def get_pref(conn, key, default=None):
|
||||
row = conn.execute('SELECT value FROM prefs WHERE key=?', (key,)).fetchone()
|
||||
return row['value'] if row else default
|
||||
|
||||
|
||||
def parse_cursor(cursor):
|
||||
"""Opaque '<score>|<first_seen>|<id>' -> (int, str, int) or None."""
|
||||
if not cursor:
|
||||
return None
|
||||
try:
|
||||
score, first_seen, listing_id = cursor.split('|', 2)
|
||||
return int(score), first_seen, int(listing_id)
|
||||
except (ValueError, TypeError):
|
||||
raise HTTPException(status_code=400, detail='bad cursor')
|
||||
|
||||
|
||||
def cursor_clause():
|
||||
"""Strictly-after in (score, first_seen, id) DESC tuple order."""
|
||||
return (" AND (l.score < ? OR"
|
||||
" (l.score = ? AND (COALESCE(l.first_seen,'') < ? OR"
|
||||
" (COALESCE(l.first_seen,'') = ? AND l.id < ?))))")
|
||||
|
||||
|
||||
def count_deck_after(app, score, first_seen, listing_id):
|
||||
conn = get_conn(app)
|
||||
try:
|
||||
n = conn.execute(
|
||||
'SELECT COUNT(*) AS n FROM listings l'
|
||||
' LEFT JOIN swipes s ON s.listing_id = l.id'
|
||||
' WHERE ' + DECK_WHERE + cursor_clause(),
|
||||
(score, score, first_seen, first_seen, listing_id)).fetchone()['n']
|
||||
return n
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
def build_payload(img_dir):
|
||||
def _payload(conn, row):
|
||||
img = None
|
||||
if row['img_url'] or os.path.exists(
|
||||
os.path.join(img_dir, '%d.webp' % row['id'])):
|
||||
img = '/img/%d' % row['id']
|
||||
extras = row['extras']
|
||||
if isinstance(extras, str):
|
||||
try:
|
||||
extras = json.loads(extras)
|
||||
except ValueError:
|
||||
extras = []
|
||||
return {
|
||||
'id': row['id'],
|
||||
'title': row['title'],
|
||||
'price_display': row['price_display'],
|
||||
'price_num': row['price_num'],
|
||||
'km': row['km'],
|
||||
'location': row['location'],
|
||||
'region': row['region'],
|
||||
'url': row['url'],
|
||||
'img': img,
|
||||
'desc': row['desc'],
|
||||
'family': row['family'],
|
||||
'score': row['score'],
|
||||
'year': row['year'],
|
||||
'seller_type': row['seller_type'],
|
||||
'extras': extras or [],
|
||||
'decision': row['decision'] if 'decision' in row.keys() else None,
|
||||
'first_seen': row['first_seen'],
|
||||
}
|
||||
return _payload
|
||||
|
||||
|
||||
# --- app factory ----------------------------------------------------------
|
||||
|
||||
def create_app(db_path, img_dir, state_dir=None):
|
||||
if state_dir is None:
|
||||
state_dir = os.path.expanduser('~/.hermes/scripts/motorcycle-hunt.d')
|
||||
os.makedirs(os.path.dirname(os.path.abspath(db_path)), exist_ok=True)
|
||||
os.makedirs(os.path.abspath(img_dir), exist_ok=True)
|
||||
static_dir = os.path.join(os.path.dirname(__file__), 'static')
|
||||
icons_dir = os.path.join(static_dir, 'icons')
|
||||
|
||||
app = FastAPI(title='Bike-Tinder')
|
||||
app.state.db_path = os.path.abspath(db_path)
|
||||
app.state.img_dir = os.path.abspath(img_dir)
|
||||
app.state.state_dir = state_dir
|
||||
app.state.static_dir = static_dir
|
||||
app.state.icons_dir = icons_dir
|
||||
payload = build_payload(app.state.img_dir)
|
||||
|
||||
# -- meta -----------------------------------------------------------
|
||||
|
||||
@app.get('/api/health')
|
||||
def health():
|
||||
conn = get_conn(app)
|
||||
try:
|
||||
counts = {}
|
||||
for status in ('active', 'gone', 'dup'):
|
||||
counts[status] = conn.execute(
|
||||
'SELECT COUNT(*) AS n FROM listings WHERE status=?',
|
||||
(status,)).fetchone()['n']
|
||||
counts['total'] = sum(counts.values())
|
||||
return {'ok': True,
|
||||
'db': os.path.basename(app.state.db_path),
|
||||
'counts': counts}
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
@app.get('/api/stats')
|
||||
def stats():
|
||||
conn = get_conn(app)
|
||||
try:
|
||||
live = conn.execute(
|
||||
"SELECT COUNT(*) AS n FROM listings WHERE status='active'"
|
||||
).fetchone()['n']
|
||||
badge = get_pref(conn, 'badge_seen_at') or ''
|
||||
new = conn.execute(
|
||||
"SELECT COUNT(*) AS n FROM listings"
|
||||
" WHERE status='active' AND COALESCE(first_seen,'') > ?",
|
||||
(badge,)).fetchone()['n']
|
||||
per = {}
|
||||
for action in ('like', 'maybe', 'nope'):
|
||||
per[action] = conn.execute(
|
||||
'SELECT COUNT(*) AS n FROM swipes WHERE action=?',
|
||||
(action,)).fetchone()['n']
|
||||
last = conn.execute(
|
||||
'SELECT started_at, ok FROM import_runs'
|
||||
' ORDER BY id DESC LIMIT 1').fetchone()
|
||||
return {
|
||||
'live': live,
|
||||
'new': new,
|
||||
'likes': per['like'],
|
||||
'maybes': per['maybe'],
|
||||
'nopes': per['nope'],
|
||||
'last_import': {'at': last['started_at'], 'ok': bool(last['ok'])}
|
||||
if last else None,
|
||||
}
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
# -- deck -----------------------------------------------------------
|
||||
|
||||
@app.get('/api/deck')
|
||||
def deck(limit: int = Query(40, ge=1, le=100),
|
||||
cursor: str = Query(None)):
|
||||
cur = parse_cursor(cursor)
|
||||
conn = get_conn(app)
|
||||
try:
|
||||
if cur is None:
|
||||
rows = conn.execute(DECK_SELECT + DECK_ORDER + ' LIMIT ?',
|
||||
(limit,)).fetchall()
|
||||
else:
|
||||
rows = conn.execute(
|
||||
DECK_SELECT + cursor_clause() + DECK_ORDER + ' LIMIT ?',
|
||||
(cur[0], cur[0], cur[1], cur[1], cur[2], limit)).fetchall()
|
||||
items = [payload(conn, r) for r in rows]
|
||||
next_cursor = None
|
||||
remaining = 0
|
||||
if rows:
|
||||
last = rows[-1]
|
||||
if len(rows) == limit:
|
||||
nc = (last['score'], last['first_seen'] or '',
|
||||
last['id'])
|
||||
next_cursor = '%s|%s|%s' % nc
|
||||
remaining = count_deck_after(app, *nc)
|
||||
return {'items': items, 'next_cursor': next_cursor,
|
||||
'remaining': remaining}
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
# -- swipes ---------------------------------------------------------
|
||||
|
||||
@app.post('/api/swipe')
|
||||
def swipe(body: dict):
|
||||
listing_id = body.get('listing_id')
|
||||
action = body.get('action')
|
||||
if not isinstance(listing_id, int) or action not in ACTIONS:
|
||||
raise HTTPException(status_code=400,
|
||||
detail='body must be {listing_id:int, action:like|nope|maybe}')
|
||||
conn = get_conn(app)
|
||||
try:
|
||||
row = conn.execute('SELECT id FROM listings WHERE id=?',
|
||||
(listing_id,)).fetchone()
|
||||
if not row:
|
||||
raise HTTPException(status_code=404, detail='unknown listing')
|
||||
now = imp.now_iso()
|
||||
conn.execute(
|
||||
'''INSERT INTO swipes (listing_id, action, created_at, updated_at)
|
||||
VALUES (?,?,?,?)
|
||||
ON CONFLICT(listing_id) DO UPDATE SET
|
||||
action=excluded.action, updated_at=excluded.updated_at''',
|
||||
(listing_id, action, now, now))
|
||||
conn.commit()
|
||||
stored = conn.execute(
|
||||
'SELECT listing_id, action, created_at, updated_at'
|
||||
' FROM swipes WHERE listing_id=?', (listing_id,)).fetchone()
|
||||
return dict(stored)
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
@app.delete('/api/swipe/{listing_id}')
|
||||
def swipe_clear(listing_id: int):
|
||||
conn = get_conn(app)
|
||||
try:
|
||||
cur = conn.execute('DELETE FROM swipes WHERE listing_id=?',
|
||||
(listing_id,))
|
||||
conn.commit()
|
||||
return {'listing_id': listing_id, 'cleared': cur.rowcount > 0}
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
# -- list + listing -------------------------------------------------
|
||||
|
||||
@app.get('/api/list')
|
||||
def list_(decision: str = Query('all'), sort: str = Query('score'),
|
||||
q: str = Query(None), limit: int = Query(100, ge=1, le=500),
|
||||
offset: int = Query(0, ge=0)):
|
||||
if decision not in LIST_DECISIONS:
|
||||
raise HTTPException(status_code=400, detail='bad decision filter')
|
||||
if sort not in LIST_SORTS:
|
||||
raise HTTPException(status_code=400, detail='bad sort')
|
||||
where = ("l.status = 'active' AND " + LIST_DECISIONS[decision])
|
||||
params = []
|
||||
if q:
|
||||
escaped = (q.replace('\\', '\\\\').replace('%', '\\%')
|
||||
.replace('_', '\\_'))
|
||||
where += " AND l.title LIKE ? ESCAPE '\\'"
|
||||
params.append('%' + escaped + '%')
|
||||
conn = get_conn(app)
|
||||
try:
|
||||
total = conn.execute(
|
||||
'SELECT COUNT(*) AS n FROM listings l'
|
||||
' LEFT JOIN swipes s ON s.listing_id = l.id'
|
||||
' WHERE ' + where, params).fetchone()['n']
|
||||
rows = conn.execute(
|
||||
'SELECT l.*, s.action AS decision FROM listings l'
|
||||
' LEFT JOIN swipes s ON s.listing_id = l.id'
|
||||
' WHERE ' + where + ' ORDER BY ' + LIST_SORTS[sort] +
|
||||
' LIMIT ? OFFSET ?', params + [limit, offset]).fetchall()
|
||||
return {'items': [payload(conn, r) for r in rows], 'total': total}
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
@app.get('/api/listing/{listing_id}')
|
||||
def listing(listing_id: int):
|
||||
conn = get_conn(app)
|
||||
try:
|
||||
row = conn.execute(
|
||||
'SELECT l.*, s.action AS decision FROM listings l'
|
||||
' LEFT JOIN swipes s ON s.listing_id = l.id'
|
||||
' WHERE l.id=?', (listing_id,)).fetchone()
|
||||
if not row:
|
||||
raise HTTPException(status_code=404, detail='unknown listing')
|
||||
return payload(conn, row)
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
# -- import ---------------------------------------------------------
|
||||
|
||||
@app.post('/api/import')
|
||||
def run_import_endpoint():
|
||||
try:
|
||||
result = imp.run_import(app.state.state_dir, app.state.db_path)
|
||||
except Exception as e: # noqa: BLE001 — surface anything to the caller
|
||||
result = {'ok': False, 'errors': {'exception': str(e)}}
|
||||
return JSONResponse(status_code=200, content=result)
|
||||
|
||||
# -- badge ----------------------------------------------------------
|
||||
|
||||
@app.post('/api/badge/seen')
|
||||
def badge_seen():
|
||||
conn = get_conn(app)
|
||||
try:
|
||||
now = imp.now_iso()
|
||||
set_pref(conn, 'badge_seen_at', now)
|
||||
conn.commit()
|
||||
return {'badge_seen_at': now}
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
# -- images + static -------------------------------------------------
|
||||
|
||||
@app.get('/img/{listing_id}')
|
||||
def img(listing_id: int):
|
||||
fpath = os.path.join(app.state.img_dir, '%d.webp' % listing_id)
|
||||
if os.path.exists(fpath):
|
||||
return FileResponse(fpath, media_type='image/webp')
|
||||
conn = get_conn(app)
|
||||
try:
|
||||
row = conn.execute('SELECT img_url FROM listings WHERE id=?',
|
||||
(listing_id,)).fetchone()
|
||||
finally:
|
||||
conn.close()
|
||||
if row and row['img_url']:
|
||||
return RedirectResponse(url=row['img_url'], status_code=302)
|
||||
raise HTTPException(status_code=404, detail='no image')
|
||||
|
||||
@app.get('/')
|
||||
def index():
|
||||
return FileResponse(
|
||||
os.path.join(static_dir, 'index.html'),
|
||||
headers={'Cache-Control': 'no-cache'})
|
||||
|
||||
@app.get('/manifest.json')
|
||||
def manifest():
|
||||
return FileResponse(os.path.join(static_dir, 'manifest.json'))
|
||||
|
||||
@app.get('/apple-touch-icon.png')
|
||||
def apple_touch():
|
||||
return FileResponse(os.path.join(icons_dir, 'apple-touch-icon.png'))
|
||||
|
||||
@app.get('/sw.js')
|
||||
def sw():
|
||||
return FileResponse(os.path.join(static_dir, 'sw.js'),
|
||||
headers={'Cache-Control': 'no-cache'})
|
||||
|
||||
@app.get('/icons/{name}')
|
||||
def icon(name: str):
|
||||
if '/' in name or '..' in name:
|
||||
raise HTTPException(status_code=404, detail='no such icon')
|
||||
fpath = os.path.join(icons_dir, name)
|
||||
if not os.path.exists(fpath):
|
||||
raise HTTPException(status_code=404, detail='no such icon')
|
||||
return FileResponse(fpath)
|
||||
|
||||
return app
|
||||
38
app/serve.py
Normal file
38
app/serve.py
Normal file
@@ -0,0 +1,38 @@
|
||||
"""Serve the Bike-Tinder app (FastAPI + uvicorn).
|
||||
|
||||
.venv/bin/python -m app.serve --db data/bike-tinder.sqlite3 --port 9453
|
||||
|
||||
Defaults per the task brief: host 127.0.0.1, port 9453, DB data/bike-tinder.sqlite3,
|
||||
images data/img. The import endpoint points at the hunt state dir
|
||||
(~/.hermes/scripts/motorcycle-hunt.d) unless --state-dir overrides it.
|
||||
"""
|
||||
import argparse
|
||||
import os
|
||||
import sys
|
||||
|
||||
from .api import create_app
|
||||
|
||||
|
||||
def default_state_dir():
|
||||
return os.path.expanduser('~/.hermes/scripts/motorcycle-hunt.d')
|
||||
|
||||
|
||||
def main(argv=None):
|
||||
ap = argparse.ArgumentParser(description='Bike-Tinder server')
|
||||
ap.add_argument('--db', default='data/bike-tinder.sqlite3')
|
||||
ap.add_argument('--img-dir', default='data/img')
|
||||
ap.add_argument('--state-dir', default=None,
|
||||
help='hunt state dir for POST /api/import'
|
||||
' (default: ~/.hermes/scripts/motorcycle-hunt.d)')
|
||||
ap.add_argument('--host', default='127.0.0.1')
|
||||
ap.add_argument('--port', type=int, default=9453)
|
||||
args = ap.parse_args(argv)
|
||||
|
||||
import uvicorn
|
||||
app = create_app(args.db, args.img_dir,
|
||||
args.state_dir or default_state_dir())
|
||||
uvicorn.run(app, host=args.host, port=args.port)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
sys.exit(main())
|
||||
4
requirements.txt
Normal file
4
requirements.txt
Normal file
@@ -0,0 +1,4 @@
|
||||
# Bike-Tinder runtime deps (plain uvicorn — no [standard] extras needed).
|
||||
fastapi
|
||||
uvicorn
|
||||
httpx
|
||||
Reference in New Issue
Block a user