367 lines
7.3 KiB
Markdown
367 lines
7.3 KiB
Markdown
# 03 — API Contract
|
||
|
||
> **Purpose:** REST and WebSocket endpoint reference with exact Zod schemas. Concatenate with 00-project-overview.md and 99-current-task.md.
|
||
> **Last updated:** 2026-05-15
|
||
> **Depends on:** 00-project-overview.md, 02-data-model.md
|
||
|
||
---
|
||
|
||
## REST Endpoints
|
||
|
||
### Health
|
||
|
||
```
|
||
GET /api/v1/health
|
||
```
|
||
|
||
**Response:** `{ "status": "ok" }`
|
||
|
||
**Auth:** None (public)
|
||
|
||
---
|
||
|
||
### Game — Start Session
|
||
|
||
```
|
||
POST /api/v1/game/start
|
||
```
|
||
|
||
**Request body** (GameRequestSchema):
|
||
|
||
```typescript
|
||
{
|
||
source_language: SupportedLanguageCode, // "en" | "it" | "de" | "es" | "fr"
|
||
target_language: SupportedLanguageCode,
|
||
pos: SupportedPos, // "noun" | "verb" | "adjective" | "adverb"
|
||
difficulty: DifficultyLevel, // "easy" | "intermediate" | "hard"
|
||
rounds: GameRounds // "3" | "10" (string enum, converted to number in service)
|
||
}
|
||
```
|
||
|
||
**Validation rules:**
|
||
|
||
- `source_language` !== `target_language`
|
||
- Both languages in `SUPPORTED_LANGUAGE_CODES`
|
||
- `pos` in `SUPPORTED_POS`
|
||
- `difficulty` in `DIFFICULTY_LEVELS`
|
||
- `rounds` in `GAME_ROUNDS`
|
||
|
||
**Response** (GameSessionSchema):
|
||
|
||
```typescript
|
||
{
|
||
sessionId: string, // UUID
|
||
questions: GameQuestion[]
|
||
}
|
||
```
|
||
|
||
**GameQuestionSchema:**
|
||
|
||
```typescript
|
||
{
|
||
questionId: string, // UUID
|
||
prompt: string, // Word in source language
|
||
gloss: string | null, // Definition (falls back to English if target lang gloss missing)
|
||
options: AnswerOption[] // 4 items, shuffled
|
||
}
|
||
```
|
||
|
||
**AnswerOptionSchema:**
|
||
|
||
```typescript
|
||
{
|
||
optionId: number, // 0–3
|
||
text: string // Translation in target language
|
||
}
|
||
```
|
||
|
||
**Note:** The correct answer is NOT included in the response. The frontend only sees `optionId` and `text`. The server stores `questionId → correctOptionId` mapping in the GameSessionStore.
|
||
|
||
**Auth:** Required (session middleware)
|
||
|
||
---
|
||
|
||
### Game — Submit Answer
|
||
|
||
```
|
||
POST /api/v1/game/answer
|
||
```
|
||
|
||
**Request body** (AnswerSubmissionSchema):
|
||
|
||
```typescript
|
||
{
|
||
sessionId: string, // UUID
|
||
questionId: string, // UUID
|
||
selectedOptionId: number // 0–3
|
||
}
|
||
```
|
||
|
||
**Response** (AnswerResultSchema):
|
||
|
||
```typescript
|
||
{
|
||
questionId: string,
|
||
isCorrect: boolean,
|
||
correctOptionId: number, // 0–3
|
||
selectedOptionId: number // 0–3
|
||
}
|
||
```
|
||
|
||
**Error cases:**
|
||
|
||
- Session not found → 404 NotFoundError
|
||
- Question not in session → 404 NotFoundError
|
||
- Invalid optionId → 400 ValidationError
|
||
|
||
**Auth:** Required
|
||
|
||
---
|
||
|
||
### Lobby — Create
|
||
|
||
```
|
||
POST /api/v1/lobbies
|
||
```
|
||
|
||
**Request body:** None (host's auth session determines host_id)
|
||
|
||
**Response:**
|
||
|
||
```typescript
|
||
{
|
||
id: string, // UUID
|
||
code: string, // Human-readable room code (e.g. "WOLF-42")
|
||
host_id: string,
|
||
status: "waiting",
|
||
max_players: number,
|
||
settings: object | null,
|
||
created_at: string
|
||
}
|
||
```
|
||
|
||
**Auth:** Required
|
||
|
||
---
|
||
|
||
### Lobby — Join
|
||
|
||
```
|
||
POST /api/v1/lobbies/:code/join
|
||
```
|
||
|
||
**Path param:** `code` — room code (e.g. "WOLF-42")
|
||
|
||
**Response:** Same as create (the lobby object)
|
||
|
||
**Error cases:**
|
||
|
||
- Lobby not found → 404
|
||
- Lobby full → 400
|
||
- Already joined → 200 (idempotent)
|
||
|
||
**Auth:** Required
|
||
|
||
---
|
||
|
||
### Auth
|
||
|
||
```
|
||
ALL /api/auth/* — Better Auth handlers (public)
|
||
```
|
||
|
||
Better Auth mounts its own router at `/api/auth/*`. Handles:
|
||
|
||
- `/api/auth/signin/social` — initiate social login
|
||
- `/api/auth/callback/:provider` — OAuth callback
|
||
- `/api/auth/signout` — clear session
|
||
- `/api/auth/session` — get current session
|
||
|
||
**Auth:** Mixed (some public, some require valid session)
|
||
|
||
---
|
||
|
||
## WebSocket Protocol
|
||
|
||
All WS messages are JSON objects with a `type` field. The `type` is a discriminated union — the router validates the payload against the schema for that type.
|
||
|
||
### Connection
|
||
|
||
1. Client opens WebSocket to `wss://api.lilastudy.com/ws`
|
||
2. Server validates Better Auth session from cookie on upgrade
|
||
3. Connection established
|
||
|
||
### Client → Server Messages
|
||
|
||
#### `lobby:join`
|
||
|
||
```typescript
|
||
{
|
||
type: "lobby:join",
|
||
payload: {
|
||
code: string // Room code (e.g. "WOLF-42")
|
||
}
|
||
}
|
||
```
|
||
|
||
#### `lobby:leave`
|
||
|
||
```typescript
|
||
{
|
||
type: "lobby:leave",
|
||
payload: {
|
||
code: string
|
||
}
|
||
}
|
||
```
|
||
|
||
#### `lobby:start`
|
||
|
||
```typescript
|
||
{
|
||
type: "lobby:start",
|
||
payload: {
|
||
code: string
|
||
}
|
||
}
|
||
```
|
||
|
||
Only the host can send this. Triggers game start.
|
||
|
||
#### `game:answer`
|
||
|
||
```typescript
|
||
{
|
||
type: "game:answer",
|
||
payload: {
|
||
code: string,
|
||
questionId: string,
|
||
optionId: number // 0–3
|
||
}
|
||
}
|
||
```
|
||
|
||
Must be sent within the 15-second server timer.
|
||
|
||
---
|
||
|
||
### Server → Client Messages
|
||
|
||
#### `lobby:state`
|
||
|
||
```typescript
|
||
{
|
||
type: "lobby:state",
|
||
payload: {
|
||
code: string,
|
||
players: {
|
||
id: string,
|
||
display_name: string,
|
||
is_host: boolean
|
||
}[],
|
||
status: "waiting" | "in_progress" | "finished",
|
||
settings: object | null
|
||
}
|
||
}
|
||
```
|
||
|
||
Broadcast to all players in the lobby on any membership change.
|
||
|
||
#### `game:question`
|
||
|
||
```typescript
|
||
{
|
||
type: "game:question",
|
||
payload: {
|
||
questionId: string,
|
||
prompt: string,
|
||
gloss: string | null,
|
||
options: { optionId: number, text: string }[],
|
||
timeLimit: number // seconds (15)
|
||
}
|
||
}
|
||
```
|
||
|
||
Broadcast when the game starts or a new round begins.
|
||
|
||
#### `game:answer_result`
|
||
|
||
```typescript
|
||
{
|
||
type: "game:answer_result",
|
||
payload: {
|
||
questionId: string,
|
||
results: {
|
||
playerId: string,
|
||
displayName: string,
|
||
isCorrect: boolean,
|
||
selectedOptionId: number,
|
||
score: number
|
||
}[]
|
||
}
|
||
}
|
||
```
|
||
|
||
Broadcast after all players answer or timer expires.
|
||
|
||
#### `game:finished`
|
||
|
||
```typescript
|
||
{
|
||
type: "game:finished",
|
||
payload: {
|
||
finalScores: {
|
||
playerId: string,
|
||
displayName: string,
|
||
score: number
|
||
}[],
|
||
winner: {
|
||
playerId: string,
|
||
displayName: string
|
||
} | null // null for ties
|
||
}
|
||
}
|
||
```
|
||
|
||
Broadcast after all rounds complete.
|
||
|
||
---
|
||
|
||
## Zod Schema Locations
|
||
|
||
All schemas live in `packages/shared/src/schemas/`:
|
||
|
||
| Schema | File | Used by |
|
||
| ---------------------- | ---------- | --------------------------------------- |
|
||
| GameRequestSchema | `game.ts` | API controller, frontend GameSetup |
|
||
| GameSessionSchema | `game.ts` | API service, frontend quiz flow |
|
||
| GameQuestionSchema | `game.ts` | API service, frontend QuestionCard |
|
||
| AnswerOptionSchema | `game.ts` | API service, frontend OptionButton |
|
||
| AnswerSubmissionSchema | `game.ts` | API controller, frontend submit handler |
|
||
| AnswerResultSchema | `game.ts` | API controller, frontend ScoreScreen |
|
||
| LobbyCreateSchema | `lobby.ts` | API controller |
|
||
| LobbyJoinSchema | `lobby.ts` | API controller |
|
||
| LobbyStateSchema | `lobby.ts` | WS handler, frontend lobby UI |
|
||
| WebSocketMessageSchema | `lobby.ts` | WS router (discriminated union) |
|
||
|
||
**Rule:** Never duplicate these schemas. Import from `packages/shared` in both API and frontend.
|
||
|
||
---
|
||
|
||
## Error Responses
|
||
|
||
All errors follow this shape:
|
||
|
||
```typescript
|
||
{
|
||
error: string, // Human-readable message
|
||
statusCode: number // HTTP status
|
||
}
|
||
```
|
||
|
||
**Common status codes:**
|
||
|
||
- 400 — ValidationError (bad input, schema mismatch)
|
||
- 401 — Unauthorized (no valid session)
|
||
- 404 — NotFoundError (session, question, or lobby not found)
|
||
- 500 — Unknown error (logged, generic message to client)
|