feat: add Spotify client and auth routes

This commit is contained in:
Justin Visser 2026-08-10 11:31:06 +02:00
parent cdab1b4dd5
commit 6769833f7e
11 changed files with 934 additions and 9 deletions

View file

@ -0,0 +1,231 @@
"""Thin asynchronous client for the Spotify Web API."""
import asyncio
import httpx2
from app.adapters.spotify.auth import refresh_access_token
from app.adapters.spotify.errors import (
SpotifyAuthenticationError,
SpotifyRateLimitedError,
SpotifyRequestError,
SpotifyUnavailableError,
)
from app.adapters.spotify.mapping import (
CreatedPlaylist,
CurrentUser,
parse_created_playlist,
parse_current_user,
parse_search_tracks,
)
from app.adapters.spotify.session import SpotifySession
from app.config import Settings
from app.domain.models import Track
class SpotifyClient:
"""Call the supported Spotify endpoints with bounded retry behavior."""
def __init__(
self,
http: httpx2.AsyncClient,
session: SpotifySession,
settings: Settings,
) -> None:
"""Bind the shared transport to one authenticated Spotify session."""
self.http = http
self.session = session
self.settings = settings
async def search_tracks(self, query: str, limit: int = 10) -> list[Track]:
"""Search Spotify tracks and return only valid mapped results."""
response = await self._request(
"GET",
"/search",
params={"q": query, "type": "track", "limit": limit},
)
return parse_search_tracks(response.json())
async def fetch_current_user(self) -> CurrentUser:
"""Fetch the authenticated Spotify user's stable identity."""
response = await self._request("GET", "/me")
return parse_current_user(response.json())
async def create_playlist(self, name: str, description: str) -> CreatedPlaylist:
"""Create a private playlist for the authenticated user."""
response = await self._request(
"POST",
"/me/playlists",
json={"name": name, "description": description, "public": False},
)
return parse_created_playlist(response.json())
async def add_tracks_to_playlist(self, playlist_id: str, track_uris: list[str]) -> None:
"""Add tracks to a playlist without retrying an ambiguous write."""
await self._request(
"POST",
f"/playlists/{playlist_id}/items",
json={"uris": track_uris},
)
async def _request(
self,
method: str,
path: str,
*,
params: dict[str, str | int] | None = None,
json: dict[str, object] | None = None,
) -> httpx2.Response:
access_token = await self._access_token()
response = await self._send(
method,
path,
access_token,
params=params,
json=json,
)
response = await self._retry_once_if_unauthorized(
response,
method,
path,
access_token,
params=params,
json=json,
)
response = await self._retry_once_if_rate_limited(
response,
method,
path,
params=params,
json=json,
)
return self._raise_for_error(response)
async def _send(
self,
method: str,
path: str,
access_token: str,
*,
params: dict[str, str | int] | None,
json: dict[str, object] | None,
) -> httpx2.Response:
return await self.http.request(
method,
f"{self.settings.spotify_api_base_url.rstrip('/')}{path}",
params=params,
json=json,
headers={"Authorization": f"Bearer {access_token}"},
)
async def _retry_once_if_unauthorized(
self,
response: httpx2.Response,
method: str,
path: str,
access_token: str,
*,
params: dict[str, str | int] | None,
json: dict[str, object] | None,
) -> httpx2.Response:
if response.status_code != 401:
return response
await self._refresh_if_current(access_token)
return await self._send(
method,
path,
self.session.tokens.access_token,
params=params,
json=json,
)
async def _retry_once_if_rate_limited(
self,
response: httpx2.Response,
method: str,
path: str,
*,
params: dict[str, str | int] | None,
json: dict[str, object] | None,
) -> httpx2.Response:
if response.status_code != 429:
return response
retry_after_seconds = _parse_retry_after(response)
_, reason = _parse_error_details(response)
if (
method != "GET"
or reason == "QUOTA_EXCEEDED"
or retry_after_seconds is None
or retry_after_seconds > self.settings.spotify_retry_after_cap_seconds
):
return response
await asyncio.sleep(retry_after_seconds)
return await self._send(
method,
path,
self.session.tokens.access_token,
params=params,
json=json,
)
def _raise_for_error(self, response: httpx2.Response) -> httpx2.Response:
if response.status_code < 400:
return response
message, reason = _parse_error_details(response)
if response.status_code == 401:
raise SpotifyAuthenticationError("Spotify rejected refreshed authentication")
if response.status_code == 429:
raise SpotifyRateLimitedError(_parse_retry_after(response), reason)
if response.status_code >= 500:
raise SpotifyUnavailableError(response.status_code, message)
raise SpotifyRequestError(response.status_code, message)
async def _access_token(self) -> str:
access_token = self.session.tokens.access_token
if self.session.tokens.is_expired:
await self._refresh_if_current(access_token)
return self.session.tokens.access_token
async def _refresh_if_current(self, access_token: str) -> None:
async with self.session.refresh_lock:
if self.session.tokens.access_token != access_token:
return
self.session.tokens = await refresh_access_token(
self.http,
client_id=self.settings.spotify_client_id,
tokens=self.session.tokens,
)
def _parse_retry_after(response: httpx2.Response) -> float | None:
value = response.headers.get("Retry-After")
if value is None:
return None
try:
retry_after_seconds = float(value)
except ValueError:
return None
return retry_after_seconds if retry_after_seconds >= 0 else None
def _parse_error_details(response: httpx2.Response) -> tuple[str | None, str | None]:
try:
payload: object = response.json()
except ValueError:
return None, None
if not isinstance(payload, dict):
return None, None
error = payload.get("error")
if not isinstance(error, dict):
return None, None
message = error.get("message")
reason = error.get("reason")
return (
message if isinstance(message, str) else None,
reason if isinstance(reason, str) else None,
)

View file

@ -0,0 +1,66 @@
"""Spotify login workflow independent of HTTP routing."""
import secrets
import httpx2
from app.adapters.spotify.auth import (
build_authorize_url,
derive_code_challenge,
exchange_authorization_code,
generate_code_verifier,
)
from app.adapters.spotify.client import SpotifyClient
from app.adapters.spotify.errors import SpotifyError
from app.adapters.spotify.session import PendingLogins, SessionStore, SpotifySession
from app.config import Settings
def begin_login(settings: Settings, pending_logins: PendingLogins) -> str:
"""Store a pending PKCE login and return its Spotify authorization URL."""
state = secrets.token_urlsafe(32)
code_verifier = generate_code_verifier()
pending_logins.add(state, code_verifier)
return build_authorize_url(
settings.spotify_client_id,
settings.spotify_redirect_uri,
state,
derive_code_challenge(code_verifier),
)
async def complete_login(
http: httpx2.AsyncClient,
settings: Settings,
pending_logins: PendingLogins,
session_store: SessionStore,
code: str,
state: str,
) -> str | None:
"""Complete a pending Spotify login and return its application session ID."""
code_verifier = pending_logins.pop(state)
if code_verifier is None:
return None
try:
tokens = await exchange_authorization_code(
http,
client_id=settings.spotify_client_id,
redirect_uri=settings.spotify_redirect_uri,
code=code,
code_verifier=code_verifier,
)
bootstrap_session = SpotifySession(tokens=tokens, account_id="", display_name="")
current_user = await SpotifyClient(
http,
bootstrap_session,
settings,
).fetch_current_user()
session = SpotifySession(
tokens=bootstrap_session.tokens,
account_id=current_user.account_id,
display_name=current_user.display_name,
)
return session_store.create(session)
except (SpotifyError, ValueError):
return None

View file

@ -0,0 +1,132 @@
"""Map Spotify response data into application-owned models."""
from collections.abc import Mapping
from dataclasses import dataclass
from typing import cast
from app.domain.models import Track
@dataclass(frozen=True)
class CurrentUser:
"""The stable identity fields returned for the current Spotify user."""
account_id: str
display_name: str
@dataclass(frozen=True)
class CreatedPlaylist:
"""The application-owned result of creating a Spotify playlist."""
id: str
url: str
def parse_search_tracks(payload: object) -> list[Track]:
"""Map valid Spotify search items and discard malformed entries."""
root = _as_mapping(payload)
tracks = _as_mapping(root.get("tracks")) if root is not None else None
items = tracks.get("items") if tracks is not None else None
if not isinstance(items, list):
return []
parsed_tracks: list[Track] = []
for item in items:
parsed_track = _parse_track(item)
if parsed_track is not None:
parsed_tracks.append(parsed_track)
return parsed_tracks
def parse_current_user(payload: object) -> CurrentUser:
"""Map a Spotify current-user response into stable identity fields."""
root = _as_mapping(payload)
account_id = _required_string(root, "account_id")
display_name = _required_string(root, "display_name")
if account_id is None or display_name is None:
raise ValueError("Spotify returned an invalid current-user response")
return CurrentUser(account_id=account_id, display_name=display_name)
def parse_created_playlist(payload: object) -> CreatedPlaylist:
"""Map a Spotify playlist creation response."""
root = _as_mapping(payload)
playlist_id = _required_string(root, "id")
external_urls = _as_mapping(root.get("external_urls")) if root is not None else None
url = _required_string(external_urls, "spotify")
if playlist_id is None or url is None:
raise ValueError("Spotify returned an invalid playlist response")
return CreatedPlaylist(id=playlist_id, url=url)
def _parse_track(payload: object) -> Track | None:
item = _as_mapping(payload)
track_id = _required_string(item, "id")
uri = _required_string(item, "uri")
title = _required_string(item, "name")
album = _as_mapping(item.get("album")) if item is not None else None
album_name = _required_string(album, "name")
artists = _parse_artists(item.get("artists")) if item is not None else None
if (
item is None
or track_id is None
or uri is None
or title is None
or album_name is None
or artists is None
):
return None
return Track(
id=track_id,
uri=uri,
title=title,
artists=artists,
album_name=album_name,
album_art_url=_parse_album_art_url(album),
external_url=_parse_external_url(item),
)
def _parse_artists(payload: object) -> tuple[str, ...] | None:
if not isinstance(payload, list) or not payload:
return None
names: list[str] = []
for artist_payload in payload:
name = _required_string(_as_mapping(artist_payload), "name")
if name is None:
return None
names.append(name)
return tuple(names)
def _parse_album_art_url(album: Mapping[str, object] | None) -> str | None:
images = album.get("images") if album is not None else None
if not isinstance(images, list) or not images:
return None
return _optional_string(_as_mapping(images[0]), "url")
def _parse_external_url(item: Mapping[str, object]) -> str | None:
return _optional_string(_as_mapping(item.get("external_urls")), "spotify")
def _as_mapping(payload: object) -> Mapping[str, object] | None:
if not isinstance(payload, Mapping):
return None
return cast(Mapping[str, object], payload)
def _required_string(payload: Mapping[str, object] | None, key: str) -> str | None:
value = _optional_string(payload, key)
return value if value else None
def _optional_string(payload: Mapping[str, object] | None, key: str) -> str | None:
if payload is None:
return None
value = payload.get(key)
return value if isinstance(value, str) else None

97
backend/app/api/routes.py Normal file
View file

@ -0,0 +1,97 @@
"""HTTP routes for Spotify login and session management."""
from typing import cast
import httpx2
from fastapi import APIRouter, Request
from fastapi.responses import JSONResponse, RedirectResponse, Response
from app.adapters.spotify.login import begin_login, complete_login
from app.adapters.spotify.session import PendingLogins, SessionStore
from app.config import Settings
SESSION_COOKIE_NAME = "discovery_session"
router = APIRouter()
@router.get("/api/auth/login")
def login(request: Request) -> RedirectResponse:
"""Start Spotify Authorization Code with PKCE login."""
application_settings = cast(Settings, request.app.state.settings)
pending_logins = cast(PendingLogins, request.app.state.pending_logins)
authorize_url = begin_login(application_settings, pending_logins)
return RedirectResponse(authorize_url, status_code=307)
@router.get("/callback")
async def callback(
request: Request,
code: str | None = None,
state: str | None = None,
error: str | None = None,
) -> RedirectResponse:
"""Complete Spotify login and establish an opaque cookie session."""
if error is not None or code is None or state is None:
return _login_error_redirect()
application_settings = cast(Settings, request.app.state.settings)
http = cast(httpx2.AsyncClient, request.app.state.http)
pending_logins = cast(PendingLogins, request.app.state.pending_logins)
session_store = cast(SessionStore, request.app.state.session_store)
session_id = await complete_login(
http,
application_settings,
pending_logins,
session_store,
code,
state,
)
if session_id is None:
return _login_error_redirect()
response = RedirectResponse("/", status_code=307)
response.set_cookie(
SESSION_COOKIE_NAME,
session_id,
httponly=True,
samesite="lax",
path="/",
secure=application_settings.session_cookie_secure,
)
return response
@router.get("/api/auth/me")
def current_session(request: Request) -> JSONResponse:
"""Return the display name for a valid application session."""
session_id = request.cookies.get(SESSION_COOKIE_NAME)
session_store = cast(SessionStore, request.app.state.session_store)
session = session_store.get(session_id) if session_id is not None else None
if session is None:
return JSONResponse({"detail": "Not authenticated"}, status_code=401)
return JSONResponse({"display_name": session.display_name})
@router.post("/api/auth/logout", status_code=204)
def logout(request: Request) -> Response:
"""Remove the current application session and clear its cookie."""
session_id = request.cookies.get(SESSION_COOKIE_NAME)
session_store = cast(SessionStore, request.app.state.session_store)
if session_id is not None:
session_store.remove(session_id)
application_settings = cast(Settings, request.app.state.settings)
response = Response(status_code=204)
response.delete_cookie(
SESSION_COOKIE_NAME,
path="/",
secure=application_settings.session_cookie_secure,
httponly=True,
samesite="lax",
)
return response
def _login_error_redirect() -> RedirectResponse:
return RedirectResponse("/?login=error", status_code=307)

View file

@ -18,6 +18,12 @@ class Settings(BaseSettings):
model_config = SettingsConfigDict(env_file=".env", extra="ignore")
app_mode: AppMode = AppMode.DEMO
spotify_client_id: str = ""
spotify_redirect_uri: str = "http://127.0.0.1:8888/callback"
spotify_api_base_url: str = "https://api.spotify.com/v1"
spotify_timeout_seconds: float = 10.0
spotify_retry_after_cap_seconds: float = 5.0
session_cookie_secure: bool = False
settings = Settings()

View file

@ -1,23 +1,53 @@
"""Application factory and wiring. No logic lives here."""
from collections.abc import AsyncIterator
from contextlib import asynccontextmanager
from pathlib import Path
import httpx2
from fastapi import FastAPI
from fastapi.staticfiles import StaticFiles
from app.config import settings
from app.adapters.spotify.session import PendingLogins, SessionStore
from app.api.routes import router
from app.config import AppMode, Settings, settings
FRONTEND_DIST = Path(__file__).parent / "static"
def create_app() -> FastAPI:
def create_app(
application_settings: Settings | None = None,
http_transport: httpx2.AsyncBaseTransport | None = None,
) -> FastAPI:
"""Build the FastAPI app: API routes plus the built SPA on one port."""
app = FastAPI(title="discovery-by-llm", docs_url=None, redoc_url=None)
active_settings = application_settings or settings
@asynccontextmanager
async def lifespan(application: FastAPI) -> AsyncIterator[None]:
if active_settings.app_mode is AppMode.LIVE and not active_settings.spotify_client_id:
raise RuntimeError("SPOTIFY_CLIENT_ID is required in live mode")
async with httpx2.AsyncClient(
timeout=active_settings.spotify_timeout_seconds,
transport=http_transport,
) as http:
application.state.http = http
application.state.session_store = SessionStore()
application.state.pending_logins = PendingLogins()
application.state.settings = active_settings
yield
app = FastAPI(
title="discovery-by-llm",
docs_url=None,
redoc_url=None,
lifespan=lifespan,
)
@app.get("/api/health")
def health() -> dict[str, str]:
return {"status": "ok", "mode": settings.app_mode}
return {"status": "ok", "mode": active_settings.app_mode}
app.include_router(router)
if FRONTEND_DIST.is_dir():
app.mount("/", StaticFiles(directory=FRONTEND_DIST, html=True), name="spa")