Compare commits

...

3 commits

Author SHA1 Message Date
Justin Visser
1e1c40f103 feat: define the discovery api wire contract
Some checks are pending
ci / backend (push) Waiting to run
ci / frontend (push) Waiting to run
2026-08-10 10:55:26 +02:00
Justin Visser
e171dd6d80 feat: add Spotify client and auth routes
Some checks are pending
ci / backend (push) Waiting to run
ci / frontend (push) Waiting to run
2026-08-10 10:53:14 +02:00
Justin Visser
af3b77a7d0 feat: add Spotify authentication and sessions 2026-08-10 10:43:49 +02:00
17 changed files with 1191 additions and 9 deletions

View file

@ -1,4 +1,5 @@
# Copy to .env and adjust. Without a .env the app starts in demo mode.
# Credential variables get added here as the features that need them land.
APP_MODE=demo
# The redirect URI must exactly match the URI registered in the Spotify dashboard.
SPOTIFY_CLIENT_ID=

View file

@ -0,0 +1,137 @@
"""Spotify Authorization Code with PKCE helpers and token exchange."""
import base64
import hashlib
import secrets
import time
from dataclasses import dataclass
from urllib.parse import urlencode
import httpx2
from app.adapters.spotify.errors import SpotifyAuthenticationError
AUTHORIZE_URL = "https://accounts.spotify.com/authorize"
TOKEN_URL = "https://accounts.spotify.com/api/token"
SCOPES = (
"user-top-read user-library-read user-read-recently-played "
"playlist-modify-public playlist-modify-private "
"user-read-playback-state user-modify-playback-state"
)
TOKEN_EXPIRY_SKEW_SECONDS = 60.0
@dataclass(frozen=True)
class TokenSet:
"""An access token and its refresh credentials."""
access_token: str
refresh_token: str
expires_at: float
@property
def is_expired(self) -> bool:
"""Return whether the access token is expired within the safety skew."""
return time.monotonic() >= self.expires_at - TOKEN_EXPIRY_SKEW_SECONDS
def generate_code_verifier() -> str:
"""Create a high-entropy PKCE verifier within the allowed length."""
return secrets.token_urlsafe(64)
def derive_code_challenge(verifier: str) -> str:
"""Derive an unpadded base64url S256 challenge from a PKCE verifier."""
digest = hashlib.sha256(verifier.encode("ascii")).digest()
return base64.urlsafe_b64encode(digest).rstrip(b"=").decode("ascii")
def build_authorize_url(
client_id: str,
redirect_uri: str,
state: str,
code_challenge: str,
) -> str:
"""Build the Spotify consent URL for the required scopes."""
query = urlencode(
{
"client_id": client_id,
"response_type": "code",
"redirect_uri": redirect_uri,
"state": state,
"scope": SCOPES,
"code_challenge_method": "S256",
"code_challenge": code_challenge,
}
)
return f"{AUTHORIZE_URL}?{query}"
async def exchange_authorization_code(
http: httpx2.AsyncClient,
*,
client_id: str,
redirect_uri: str,
code: str,
code_verifier: str,
) -> TokenSet:
"""Exchange a Spotify authorization code for an initial token set."""
response = await http.post(
TOKEN_URL,
data={
"client_id": client_id,
"grant_type": "authorization_code",
"code": code,
"redirect_uri": redirect_uri,
"code_verifier": code_verifier,
},
)
return _parse_token_response(response, existing_refresh_token=None)
async def refresh_access_token(
http: httpx2.AsyncClient,
*,
client_id: str,
tokens: TokenSet,
) -> TokenSet:
"""Refresh an access token while retaining an omitted refresh token."""
response = await http.post(
TOKEN_URL,
data={
"client_id": client_id,
"grant_type": "refresh_token",
"refresh_token": tokens.refresh_token,
},
)
return _parse_token_response(response, existing_refresh_token=tokens.refresh_token)
def _parse_token_response(
response: httpx2.Response,
existing_refresh_token: str | None,
) -> TokenSet:
if not response.is_success:
raise SpotifyAuthenticationError(
f"Spotify token request failed with status {response.status_code}"
)
try:
payload = response.json()
access_token = payload["access_token"]
expires_in = payload["expires_in"]
refresh_token = payload.get("refresh_token", existing_refresh_token)
if (
not isinstance(access_token, str)
or not isinstance(expires_in, int | float)
or not isinstance(refresh_token, str)
):
raise TypeError
except (TypeError, KeyError, ValueError) as error:
raise SpotifyAuthenticationError("Spotify returned an invalid token response") from error
return TokenSet(
access_token=access_token,
refresh_token=refresh_token,
expires_at=time.monotonic() + float(expires_in),
)

View file

@ -0,0 +1,145 @@
"""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:
has_retried_authentication = False
has_retried_rate_limit = False
while True:
access_token = await self._access_token()
response = await self.http.request(
method,
f"{self.settings.spotify_api_base_url.rstrip('/')}{path}",
params=params,
json=json,
headers={"Authorization": f"Bearer {access_token}"},
)
if response.status_code == 401:
if has_retried_authentication:
raise SpotifyAuthenticationError("Spotify rejected refreshed authentication")
await self._refresh_if_current(access_token)
has_retried_authentication = True
continue
if response.status_code == 429:
retry_after_seconds = _parse_retry_after(response)
if (
method == "GET"
and not has_retried_rate_limit
and retry_after_seconds is not None
and retry_after_seconds <= self.settings.spotify_retry_after_cap_seconds
):
await asyncio.sleep(retry_after_seconds)
has_retried_rate_limit = True
continue
raise SpotifyRateLimitedError(retry_after_seconds)
if response.status_code >= 500:
raise SpotifyUnavailableError(
f"Spotify is unavailable with status {response.status_code}"
)
if response.status_code >= 400:
raise SpotifyRequestError(response.status_code)
return response
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

View file

@ -0,0 +1,31 @@
"""Typed failures raised by the Spotify adapter."""
class SpotifyError(Exception):
"""Base class for Spotify adapter failures."""
class SpotifyAuthenticationError(SpotifyError):
"""Spotify rejected authentication or token refresh."""
class SpotifyRateLimitedError(SpotifyError):
"""Spotify rate limited a request that could not be retried."""
def __init__(self, retry_after_seconds: float | None) -> None:
"""Record Spotify's requested delay without exposing request data."""
super().__init__("Spotify rate limit exceeded")
self.retry_after_seconds = retry_after_seconds
class SpotifyUnavailableError(SpotifyError):
"""Spotify returned a server-side failure."""
class SpotifyRequestError(SpotifyError):
"""Spotify rejected a non-authenticated API request."""
def __init__(self, status_code: int) -> None:
"""Record the response status without exposing response content."""
super().__init__(f"Spotify request failed with status {status_code}")
self.status_code = status_code

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

View file

@ -0,0 +1,65 @@
"""In-memory Spotify login and session state."""
import asyncio
import secrets
import time
from dataclasses import dataclass, field
from app.adapters.spotify.auth import TokenSet
PENDING_LOGIN_LIFETIME_SECONDS = 600.0
@dataclass
class SpotifySession:
"""Authenticated Spotify identity and refresh coordination state."""
tokens: TokenSet
account_id: str
display_name: str
refresh_lock: asyncio.Lock = field(default_factory=asyncio.Lock)
class SessionStore:
"""Store Spotify sessions behind opaque cookie-safe identifiers."""
def __init__(self) -> None:
"""Create an empty session store."""
self._sessions: dict[str, SpotifySession] = {}
def create(self, session: SpotifySession) -> str:
"""Store a session and return its opaque identifier."""
session_id = secrets.token_urlsafe(32)
self._sessions[session_id] = session
return session_id
def get(self, session_id: str) -> SpotifySession | None:
"""Return a session by identifier when present."""
return self._sessions.get(session_id)
def remove(self, session_id: str) -> None:
"""Remove a session if it exists."""
self._sessions.pop(session_id, None)
class PendingLogins:
"""Store short-lived, single-use PKCE verifiers by OAuth state."""
def __init__(self) -> None:
"""Create an empty pending-login store."""
self._entries: dict[str, tuple[str, float]] = {}
def add(self, state: str, code_verifier: str) -> None:
"""Store a PKCE verifier for a newly issued OAuth state."""
self._entries[state] = (code_verifier, time.monotonic())
def pop(self, state: str) -> str | None:
"""Consume a verifier unless its OAuth state is unknown or expired."""
entry = self._entries.pop(state, None)
if entry is None:
return None
code_verifier, created_at = entry
if time.monotonic() - created_at >= PENDING_LOGIN_LIFETIME_SECONDS:
return None
return code_verifier

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

@ -0,0 +1,129 @@
"""HTTP routes for Spotify login and session management."""
import secrets
from typing import cast
import httpx2
from fastapi import APIRouter, Request
from fastapi.responses import JSONResponse, RedirectResponse, Response
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
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)
state = secrets.token_urlsafe(32)
code_verifier = generate_code_verifier()
pending_logins.add(state, code_verifier)
authorize_url = build_authorize_url(
application_settings.spotify_client_id,
application_settings.spotify_redirect_uri,
state,
derive_code_challenge(code_verifier),
)
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()
pending_logins = cast(PendingLogins, request.app.state.pending_logins)
code_verifier = pending_logins.pop(state)
if code_verifier is None:
return _login_error_redirect()
application_settings = cast(Settings, request.app.state.settings)
http = cast(httpx2.AsyncClient, request.app.state.http)
try:
tokens = await exchange_authorization_code(
http,
client_id=application_settings.spotify_client_id,
redirect_uri=application_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,
application_settings,
).fetch_current_user()
except (SpotifyError, ValueError):
return _login_error_redirect()
session = SpotifySession(
tokens=bootstrap_session.tokens,
account_id=current_user.account_id,
display_name=current_user.display_name,
)
session_store = cast(SessionStore, request.app.state.session_store)
session_id = session_store.create(session)
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)

103
backend/app/api/schemas.py Normal file
View file

@ -0,0 +1,103 @@
"""Wire schemas for the discovery API: requests and streamed events."""
from typing import Literal
from pydantic import BaseModel, Field
NDJSON_CONTENT_TYPE = "application/x-ndjson"
class HistoryTurn(BaseModel):
"""One prior chat turn; conversation state lives client-side."""
role: Literal["user", "assistant"]
content: str = Field(min_length=1, max_length=2000)
class PriorRecommendation(BaseModel):
"""A track from an earlier response that follow-up turns can refer to."""
rank: int = Field(ge=1, le=50)
track_id: str
title: str
artists: list[str] = Field(max_length=10)
class RecommendationRequest(BaseModel):
"""A discovery query with bounded client-side conversation state."""
schema_version: Literal[1] = 1
query: str = Field(min_length=1, max_length=1000)
history: list[HistoryTurn] = Field(default_factory=list, max_length=12)
prior_recommendations: list[PriorRecommendation] = Field(default_factory=list, max_length=50)
class TrackCard(BaseModel):
"""The wire shape of one recommended track."""
id: str
uri: str
title: str
artists: list[str]
album_name: str
album_art_url: str | None
external_url: str | None
class MetadataEvent(BaseModel):
"""First stream event: how the query was understood."""
type: Literal["metadata"] = "metadata"
request_id: str
intent_summary: str
candidate_count: int
class TrackEvent(BaseModel):
"""One recommended track, streamed as soon as it validates."""
type: Literal["track"] = "track"
rank: int
track: TrackCard
justification: str
class WarningEvent(BaseModel):
"""A non-fatal degradation, surfaced honestly instead of hidden."""
type: Literal["warning"] = "warning"
code: str
message: str
class ErrorEvent(BaseModel):
"""A terminal failure; no further events follow it."""
type: Literal["error"] = "error"
code: str
message: str
class DoneEvent(BaseModel):
"""Final stream event with response-level counters."""
type: Literal["done"] = "done"
track_count: int
total_ms: int
StreamEvent = MetadataEvent | TrackEvent | WarningEvent | ErrorEvent | DoneEvent
class PlaylistCreateRequest(BaseModel):
"""A request to save recommended tracks as a real Spotify playlist."""
schema_version: Literal[1] = 1
name: str = Field(min_length=1, max_length=100)
track_uris: list[str] = Field(min_length=1, max_length=50)
class PlaylistCreateResponse(BaseModel):
"""The created playlist's public location."""
url: str

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

@ -0,0 +1,16 @@
"""Pure domain models shared across application boundaries."""
from dataclasses import dataclass
@dataclass(frozen=True)
class Track:
"""A Spotify track expressed without transport-specific data."""
id: str
uri: str
title: str
artists: tuple[str, ...]
album_name: str
album_art_url: str | None
external_url: str | None

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")

View file

@ -5,13 +5,13 @@ description = "LLM-driven music discovery on the Spotify Web API"
requires-python = "==3.13.*"
dependencies = [
"fastapi>=0.116",
"httpx2>=2.10",
"pydantic-settings>=2.10",
"uvicorn[standard]>=0.35",
]
[dependency-groups]
dev = [
"httpx2>=0.1",
"mypy>=1.17",
"pytest>=8.4",
"ruff>=0.12",

View file

@ -0,0 +1,74 @@
"""End-to-end route tests for the Spotify login surface."""
from urllib.parse import parse_qs, urlparse
import httpx2
from fastapi.testclient import TestClient
from app.config import AppMode, Settings
from app.main import create_app
def test_login_callback_cookie_and_current_user_flow() -> None:
async def spotify_handler(request: httpx2.Request) -> httpx2.Response:
if request.url.host == "accounts.spotify.com":
return httpx2.Response(
200,
json={
"access_token": "access",
"refresh_token": "refresh",
"expires_in": 3600,
},
)
assert request.url == "https://api.spotify.com/v1/me"
return httpx2.Response(
200,
json={"account_id": "stable-account", "display_name": "Ada Listener"},
)
app = create_app(
application_settings=_live_settings(),
http_transport=httpx2.MockTransport(spotify_handler),
)
with TestClient(app, follow_redirects=False) as client:
unauthenticated_response = client.get("/api/auth/me")
login_response = client.get("/api/auth/login")
authorize_url = urlparse(login_response.headers["location"])
authorize_query = parse_qs(authorize_url.query)
state = authorize_query["state"][0]
assert unauthenticated_response.status_code == 401
assert login_response.status_code == 307
assert authorize_url.netloc == "accounts.spotify.com"
assert authorize_url.path == "/authorize"
assert authorize_query["code_challenge"][0]
assert authorize_query["code_challenge_method"] == ["S256"]
callback_response = client.get("/callback", params={"code": "code", "state": state})
assert callback_response.status_code == 307
assert callback_response.headers["location"] == "/"
assert "discovery_session=" in callback_response.headers["set-cookie"]
assert "HttpOnly" in callback_response.headers["set-cookie"]
assert "SameSite=lax" in callback_response.headers["set-cookie"]
assert client.get("/api/auth/me").json() == {"display_name": "Ada Listener"}
def test_unknown_callback_state_redirects_to_login_error() -> None:
async def spotify_handler(request: httpx2.Request) -> httpx2.Response:
raise AssertionError("Spotify must not be called for an unknown state")
app = create_app(
application_settings=_live_settings(),
http_transport=httpx2.MockTransport(spotify_handler),
)
with TestClient(app, follow_redirects=False) as client:
response = client.get("/callback", params={"code": "code", "state": "unknown"})
assert response.status_code == 307
assert response.headers["location"] == "/?login=error"
def _live_settings() -> Settings:
return Settings(app_mode=AppMode.LIVE, spotify_client_id="client-id")

View file

@ -0,0 +1,45 @@
"""Tests for Spotify PKCE and token handling."""
import asyncio
import base64
import hashlib
import time
import httpx2
from app.adapters.spotify.auth import (
TokenSet,
derive_code_challenge,
refresh_access_token,
)
def test_code_challenge_is_unpadded_base64url_sha256() -> None:
verifier = "a-fixed-code-verifier"
expected = base64.urlsafe_b64encode(hashlib.sha256(verifier.encode()).digest())
challenge = derive_code_challenge(verifier)
assert challenge == expected.rstrip(b"=").decode()
assert "=" not in challenge
def test_refresh_keeps_existing_refresh_token_when_omitted() -> None:
async def run() -> None:
async def handler(request: httpx2.Request) -> httpx2.Response:
assert request.url == "https://accounts.spotify.com/api/token"
return httpx2.Response(200, json={"access_token": "new", "expires_in": 3600})
tokens = TokenSet("old", "keep-me", time.monotonic() + 3600)
async with httpx2.AsyncClient(transport=httpx2.MockTransport(handler)) as http:
refreshed = await refresh_access_token(http, client_id="client", tokens=tokens)
assert refreshed.access_token == "new"
assert refreshed.refresh_token == "keep-me"
asyncio.run(run())
def test_token_expiry_uses_sixty_second_skew() -> None:
assert TokenSet("access", "refresh", time.monotonic() + 59).is_expired
assert not TokenSet("access", "refresh", time.monotonic() + 61).is_expired

View file

@ -0,0 +1,189 @@
"""Transport-level tests for the Spotify API client."""
import asyncio
import time
from collections.abc import Callable, Coroutine
import httpx2
import pytest
from app.adapters.spotify.auth import TokenSet
from app.adapters.spotify.client import SpotifyClient
from app.adapters.spotify.errors import SpotifyRateLimitedError
from app.adapters.spotify.session import SpotifySession
from app.config import Settings
from app.domain.models import Track
TransportHandler = Callable[[httpx2.Request], Coroutine[None, None, httpx2.Response]]
def test_unauthorized_response_refreshes_once_and_returns_result() -> None:
async def run() -> None:
token_calls = 0
api_calls = 0
async def handler(request: httpx2.Request) -> httpx2.Response:
nonlocal api_calls, token_calls
if request.url.host == "accounts.spotify.com":
token_calls += 1
return _token_response()
api_calls += 1
if request.headers["Authorization"] == "Bearer old-access":
return httpx2.Response(401)
return httpx2.Response(200, json=_search_payload())
tracks = await _search_with_handler(handler)
assert [track.id for track in tracks] == ["track-1"]
assert token_calls == 1
assert api_calls == 2
asyncio.run(run())
def test_concurrent_unauthorized_responses_share_one_refresh() -> None:
async def run() -> None:
token_calls = 0
old_api_calls = 0
both_old_requests_arrived = asyncio.Event()
async def handler(request: httpx2.Request) -> httpx2.Response:
nonlocal old_api_calls, token_calls
if request.url.host == "accounts.spotify.com":
token_calls += 1
return _token_response()
if request.headers["Authorization"] == "Bearer old-access":
old_api_calls += 1
if old_api_calls == 2:
both_old_requests_arrived.set()
await asyncio.wait_for(both_old_requests_arrived.wait(), timeout=1)
return httpx2.Response(401)
return httpx2.Response(200, json=_search_payload())
async with httpx2.AsyncClient(transport=httpx2.MockTransport(handler)) as http:
client = _client(http)
first, second = await asyncio.gather(
client.search_tracks("first"),
client.search_tracks("second"),
)
assert first == second
assert token_calls == 1
asyncio.run(run())
def test_get_rate_limit_with_small_delay_retries_once() -> None:
async def run() -> None:
api_calls = 0
async def handler(request: httpx2.Request) -> httpx2.Response:
nonlocal api_calls
api_calls += 1
if api_calls == 1:
return httpx2.Response(429, headers={"Retry-After": "0"})
return httpx2.Response(200, json=_search_payload())
tracks = await _search_with_handler(handler)
assert len(tracks) == 1
assert api_calls == 2
asyncio.run(run())
def test_get_rate_limit_above_cap_raises_without_retry() -> None:
async def run() -> None:
api_calls = 0
async def handler(request: httpx2.Request) -> httpx2.Response:
nonlocal api_calls
api_calls += 1
return httpx2.Response(429, headers={"Retry-After": "6"})
with pytest.raises(SpotifyRateLimitedError) as error:
await _search_with_handler(handler)
assert error.value.retry_after_seconds == 6
assert api_calls == 1
asyncio.run(run())
def test_playlist_write_rate_limit_is_not_retried() -> None:
async def run() -> None:
api_calls = 0
async def handler(request: httpx2.Request) -> httpx2.Response:
nonlocal api_calls
api_calls += 1
assert request.method == "POST"
assert request.url.path == "/v1/playlists/playlist-1/items"
return httpx2.Response(429, headers={"Retry-After": "0"})
async with httpx2.AsyncClient(transport=httpx2.MockTransport(handler)) as http:
with pytest.raises(SpotifyRateLimitedError):
await _client(http).add_tracks_to_playlist("playlist-1", ["spotify:track:1"])
assert api_calls == 1
asyncio.run(run())
def test_search_maps_valid_fields_and_drops_malformed_item() -> None:
async def run() -> None:
async def handler(request: httpx2.Request) -> httpx2.Response:
return httpx2.Response(200, json=_search_payload())
tracks = await _search_with_handler(handler)
assert len(tracks) == 1
assert tracks[0].id == "track-1"
assert tracks[0].uri == "spotify:track:1"
assert tracks[0].title == "Mapped song"
assert tracks[0].artists == ("First artist", "Second artist")
assert tracks[0].album_name == "Mapped album"
assert tracks[0].album_art_url == "https://images.example/cover.jpg"
assert tracks[0].external_url == "https://open.spotify.com/track/track-1"
asyncio.run(run())
async def _search_with_handler(handler: TransportHandler) -> list[Track]:
async with httpx2.AsyncClient(transport=httpx2.MockTransport(handler)) as http:
return list(await _client(http).search_tracks("mapped"))
def _client(http: httpx2.AsyncClient) -> SpotifyClient:
session = SpotifySession(
tokens=TokenSet("old-access", "refresh", time.monotonic() + 3600),
account_id="account",
display_name="Listener",
)
return SpotifyClient(http, session, Settings(spotify_client_id="client"))
def _token_response() -> httpx2.Response:
return httpx2.Response(200, json={"access_token": "new-access", "expires_in": 3600})
def _search_payload() -> dict[str, object]:
return {
"tracks": {
"total": 0,
"items": [
{
"id": "track-1",
"uri": "spotify:track:1",
"name": "Mapped song",
"artists": [{"name": "First artist"}, {"name": "Second artist"}],
"album": {
"name": "Mapped album",
"images": [{"url": "https://images.example/cover.jpg"}],
},
"external_urls": {"spotify": "https://open.spotify.com/track/track-1"},
},
{"id": "missing-required-fields"},
],
}
}

4
backend/uv.lock generated
View file

@ -103,13 +103,13 @@ version = "0.1.0"
source = { virtual = "." }
dependencies = [
{ name = "fastapi" },
{ name = "httpx2" },
{ name = "pydantic-settings" },
{ name = "uvicorn", extra = ["standard"] },
]
[package.dev-dependencies]
dev = [
{ name = "httpx2" },
{ name = "mypy" },
{ name = "pytest" },
{ name = "ruff" },
@ -118,13 +118,13 @@ dev = [
[package.metadata]
requires-dist = [
{ name = "fastapi", specifier = ">=0.116" },
{ name = "httpx2", specifier = ">=2.10" },
{ name = "pydantic-settings", specifier = ">=2.10" },
{ name = "uvicorn", extras = ["standard"], specifier = ">=0.35" },
]
[package.metadata.requires-dev]
dev = [
{ name = "httpx2", specifier = ">=0.1" },
{ name = "mypy", specifier = ">=1.17" },
{ name = "pytest", specifier = ">=8.4" },
{ name = "ruff", specifier = ">=0.12" },

79
frontend/src/lib/types.ts Normal file
View file

@ -0,0 +1,79 @@
// Wire types mirrored against backend/app/api/schemas.py.
// Field names match the JSON payloads exactly, so they stay snake_case.
export interface HistoryTurn {
role: 'user' | 'assistant'
content: string
}
export interface PriorRecommendation {
rank: number
track_id: string
title: string
artists: string[]
}
export interface RecommendationRequest {
schema_version: 1
query: string
history: HistoryTurn[]
prior_recommendations: PriorRecommendation[]
}
export interface TrackCard {
id: string
uri: string
title: string
artists: string[]
album_name: string
album_art_url: string | null
external_url: string | null
}
export interface MetadataEvent {
type: 'metadata'
request_id: string
intent_summary: string
candidate_count: number
}
export interface TrackEvent {
type: 'track'
rank: number
track: TrackCard
justification: string
}
export interface WarningEvent {
type: 'warning'
code: string
message: string
}
export interface ErrorEvent {
type: 'error'
code: string
message: string
}
export interface DoneEvent {
type: 'done'
track_count: number
total_ms: number
}
export type StreamEvent = MetadataEvent | TrackEvent | WarningEvent | ErrorEvent | DoneEvent
export interface PlaylistCreateRequest {
schema_version: 1
name: string
track_uris: string[]
}
export interface PlaylistCreateResponse {
url: string
}
export interface CurrentUser {
display_name: string
}