feat: add Spotify client and auth routes
This commit is contained in:
parent
cdab1b4dd5
commit
6769833f7e
11 changed files with 934 additions and 9 deletions
231
backend/app/adapters/spotify/client.py
Normal file
231
backend/app/adapters/spotify/client.py
Normal 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,
|
||||
)
|
||||
66
backend/app/adapters/spotify/login.py
Normal file
66
backend/app/adapters/spotify/login.py
Normal 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
|
||||
132
backend/app/adapters/spotify/mapping.py
Normal file
132
backend/app/adapters/spotify/mapping.py
Normal 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
97
backend/app/api/routes.py
Normal 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)
|
||||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue