Files
ruby-sea-battle/main-server/API.md
T

393 lines
14 KiB
Markdown

# Sea Battle — Main Server API
Центральный сервер для браузерной онлайн-игры «Морской бой». Предоставляет REST API для регистрации, аутентификации и матчмейкинга.
## Дополнительная документация
- [docs/matchmaking.md](docs/matchmaking.md) — детальное описание алгоритма матчмейкинга, структур данных Redis, формулы допуска рейтинга, Lua-скриптов и гарантий доставки.
- [docs/architecture.md](docs/architecture.md) — архитектура главного сервера, стек компонентов Rails, безопасность и взаимодействие с Game Server.
## Базовый URL
```
http://localhost:3000/api
```
## Аутентификация
Все endpoints (кроме `/register` и `/login`) требуют JWT-токен. Токен может передаваться двумя способами:
1. В HTTP-заголовке (рекомендуемый способ для REST API):
```
Authorization: Bearer <token>
```
2. В параметрах запроса query string (используется для WebSocket-подключений и поддерживается REST API):
```
?token=<token>
```
Токен выдаётся при успешной регистрации или входе и действителен **7 дней**. Токен содержит идентификатор пользователя (`user_id`) и время истечения (`exp`). При обращении к защищённым endpoints сервер проверяет подпись токена, срок действия и существование пользователя в базе данных.
---
## Endpoints
### Регистрация
Создаёт нового пользователя и возвращает токен.
**URL:** `POST /api/register`
**Auth:** не требуется
**Content-Type:** `application/json` или `multipart/form-data` (при загрузке аватара)
**Параметры (JSON):**
| Параметр | Тип | Обязательный | Описание |
|----------|-----|--------------|----------|
| `user.nickname` | string | ✅ | Уникальный никнейм (3–24 символа) |
| `user.password` | string | ✅ | Пароль (минимум 6 символов) |
| `user.password_confirmation` | string | ✅ | Подтверждение пароля |
| `user.avatar` | file | — | Аватар (PNG, JPEG, GIF, WebP) |
**Пример запроса (JSON):**
```bash
curl -X POST http://localhost:3000/api/register \
-H "Content-Type: application/json" \
-d '{
"user": {
"nickname": "CaptainNemo",
"password": "secret123",
"password_confirmation": "secret123"
}
}'
```
**Пример запроса (с аватаром):**
```bash
curl -X POST http://localhost:3000/api/register \
-F "user[nickname]=CaptainNemo" \
-F "user[password]=secret123" \
-F "user[password_confirmation]=secret123" \
-F "user[avatar]=@avatar.png"
```
**Успешный ответ (201 Created):**
```json
{
"user": {
"id": 1,
"nickname": "CaptainNemo",
"avatar_url": "http://localhost:3000/rails/active_storage/blobs/...",
"created_at": "2026-09-21T10:00:00.000Z"
},
"token": "eyJhbGciOiJIUzI1NiJ9.eyJ1c2VyX2lkIjox..."
}
```
**Ошибки (422 Unprocessable Entity):**
```json
{
"errors": [
"Nickname has already been taken",
"Password is too short (minimum is 6 characters)"
]
}
```
---
### Вход
Аутентифицирует пользователя и возвращает токен.
**URL:** `POST /api/login`
**Auth:** не требуется
**Content-Type:** `application/json`
**Параметры:**
| Параметр | Тип | Обязательный | Описание |
|----------|-----|--------------|----------|
| `nickname` | string | ✅ | Никнейм |
| `password` | string | ✅ | Пароль |
**Пример запроса:**
```bash
curl -X POST http://localhost:3000/api/login \
-H "Content-Type: application/json" \
-d '{
"nickname": "CaptainNemo",
"password": "secret123"
}'
```
**Успешный ответ (200 OK):**
```json
{
"user": {
"id": 1,
"nickname": "CaptainNemo",
"avatar_url": "http://localhost:3000/rails/active_storage/blobs/...",
"created_at": "2026-09-21T10:00:00.000Z"
},
"token": "eyJhbGciOiJIUzI1NiJ9.eyJ1c2VyX2lkIjox..."
}
```
**Ошибка (401 Unauthorized):**
```json
{
"error": "Invalid nickname or password"
}
```
---
### Текущий пользователь
Возвращает данные авторизованного пользователя.
**URL:** `GET /api/me`
**Auth:** требуется
**Пример запроса:**
```bash
curl -X GET http://localhost:3000/api/me \
-H "Authorization: Bearer eyJhbGciOiJIUzI1NiJ9..."
```
**Успешный ответ (200 OK):**
```json
{
"user": {
"id": 1,
"nickname": "CaptainNemo",
"avatar_url": "http://localhost:3000/rails/active_storage/blobs/...",
"created_at": "2026-09-21T10:00:00.000Z"
}
}
```
**Ошибка (401 Unauthorized):**
```json
{
"error": "Unauthorized"
}
```
---
### Выход
Завершает сессию. JWT является stateless — клиент должен удалить токен.
**URL:** `DELETE /api/logout`
**Auth:** требуется
**Пример запроса:**
```bash
curl -X DELETE http://localhost:3000/api/logout \
-H "Authorization: Bearer eyJhbGciOiJIUzI1NiJ9..."
```
**Успешный ответ (200 OK):**
```json
{
"message": "Signed out successfully"
}
```
---
### Поиск соперника (Матчмейкинг)
Поиск соперника происходит через **WebSocket** на базе протокола `Action Cable`. Это устраняет необходимость long-polling: сокет не блокирует HTTP-потоки, а результат поиска доставляется сервером асинхронно push-уведомлением.
> Подробное описание структур данных в Redis, формулы допуска рейтинга, двухфазного поиска и Lua-скриптов вынесено в отдельный документ: [docs/matchmaking.md](docs/matchmaking.md).
**URL подключения:** `ws://localhost:3000/cable?token=<JWT>`
**Auth:** токен передаётся параметром `token` в query string (или в заголовке `Authorization: Bearer <JWT>` при поддержке клиентом). Если токен отсутствует, просрочен или пользователь не найден, сервер немедленно закрывает соединение (`reject_unauthorized_connection`).
**Канал:** `MatchmakingChannel`
#### Протокол взаимодействия Action Cable
Action Cable использует формат JSON-сообщений через WebSocket:
1. **Подключение и рукопожатие:**
При открытии сокета сервер отправляет приветственное сообщение:
```json
{ "type": "welcome" }
```
В процессе работы сервер регулярно отправляет heartbeat-сообщения (пинг):
```json
{ "type": "ping", "message": 1729864800 }
```
2. **Подписка на канал:**
Клиент отправляет команду подписки на канал `MatchmakingChannel`:
```json
{
"command": "subscribe",
"identifier": "{\"channel\":\"MatchmakingChannel\"}"
}
```
При успешной подписке сервер отвечает подтверждением:
```json
{
"type": "confirm_subscription",
"identifier": "{\"channel\":\"MatchmakingChannel\"}"
}
```
*Примечание:* Если матч был сформирован в момент временного разрыва соединения (кратковременный реконнект), сервер в момент подписки выполняет операцию `claim` и немедленно передаёт клиенту сообщение `matched` (гарантия exactly-once доставки).
3. **Запуск поиска соперника:**
Для входа в очередь матчмейкинга клиент отправляет action `start`:
```json
{
"command": "message",
"identifier": "{\"channel\":\"MatchmakingChannel\"}",
"data": "{\"action\":\"start\"}"
}
```
Рейтинг для подбора считывается из БД (`users.rating`, по умолчанию `100`). Игрок помещается в Redis (`mm:queue`). Сервер сразу выполняет попытку мгновенного подбора (Phase 1). Если подходящий соперник не найден сразу, фоновый воркер `MatchmakingSweepJob` каждые 2 секунды проверяет очередь (Phase 2), плавно расширяя допустимый диапазон рейтинга по мере ожидания.
4. **Получение результатов поиска:**
**Матч найден:**
```json
{
"identifier": "{\"channel\":\"MatchmakingChannel\"}",
"message": {
"type": "matched",
"match_id": "145575d6-3a4b-4c5d-9e6f-7a8b9c0d1e2f",
"game_token": "eyJhbGciOiJIUzI1NiJ9.eyJtYXRjaF9pZCI6IjE...",
"game_server": "ws://localhost:8080",
"players": [1, 2]
}
}
```
| Поле | Описание |
|------|----------|
| `match_id` | Уникальный UUID матча, сгенерированный сервером |
| `game_token` | Подписанный JWT-токен для авторизации на `game-server` (действителен `MATCHMAKING_TOKEN_TTL` сек) |
| `game_server` | WebSocket URL игрового сервера Go, на который должны подключиться оба игрока |
| `players` | Массив идентификаторов игроков матча `[player1_id, player2_id]` |
**Таймаут поиска** (соперник не найден за `MATCHMAKING_QUEUE_TTL` секунд, по умолчанию 60 сек):
```json
{
"identifier": "{\"channel\":\"MatchmakingChannel\"}",
"message": {
"type": "timeout"
}
}
```
После таймаута игрок удаляется из очереди поиска. При необходимости поиск можно начать заново командой `start`.
5. **Отмена поиска:**
Отменить поиск можно двумя способами:
- Отправить команду `stop`:
```json
{
"command": "message",
"identifier": "{\"channel\":\"MatchmakingChannel\"}",
"data": "{\"action\":\"stop\"}"
}
```
- Отписаться от канала или закрыть WebSocket-соединение:
```json
{
"command": "unsubscribe",
"identifier": "{\"channel\":\"MatchmakingChannel\"}"
}
```
Сервер атомарно удаляет игрока из всех очередей и хешей Redis.
---
## Health Check
Проверка доступности сервера.
**URL:** `GET /up`
**Auth:** не требуется
**Успешный ответ (200 OK):**
```html
<!DOCTYPE html><html><body style="background-color: green"></body></html>
```
---
## Коды ошибок
| Код | Описание |
|-----|----------|
| `200` | Успешный запрос |
| `201` | Ресурс создан (регистрация) |
| `401` | Неавторизован (отсутствует/невалиден токен) |
| `422` | Ошибка валидации |
| `500` | Внутренняя ошибка сервера |
---
## Переменные окружения
| Переменная | По умолчанию | Описание |
|------------|--------------|----------|
| `REDIS_URL` | `redis://localhost:6380/0` | Подключение к Redis (матчмейкинг и pub/sub Action Cable) |
| `GAME_SERVER_URL` | `ws://localhost:8080` | Адрес игрового сервера для матчмейкинга |
| `MATCHMAKING_QUEUE_TTL` | `60` | Время ожидания соперника до таймаута, сек |
| `MATCHMAKING_PENDING_TTL` | `15` | Время ожидания финализации пары, сек |
| `MATCHMAKING_MATCH_TTL` | `120` | Время жизни данных матча, сек |
| `MATCHMAKING_TOKEN_TTL` | `120` | Время жизни `game_token`, сек |
| `MATCHMAKING_BASE_RANGE` | `100` | Начальный допуск по рейтингу |
| `MATCHMAKING_RANGE_PER_SECOND` | `50` | Рост допуска за секунду ожидания |
| `MATCHMAKING_MAX_RANGE` | `1000` | Максимальный допуск по рейтингу |
| `MATCHMAKING_SWEEP_INTERVAL` | `2` | Периодичность фоновой сверки очереди, сек |
| `CORS_ORIGINS` | `*` | Разрешённые origins для CORS (через запятую) |
| `SECRET_KEY_BASE` | из `credentials.yml.enc` | Секретный ключ для JWT |
---
## Пример workflow: от регистрации до матча
```bash
# 1. Регистрация
TOKEN=$(curl -s -X POST http://localhost:3000/api/register \
-H "Content-Type: application/json" \
-d '{"user":{"nickname":"Player1","password":"secret123","password_confirmation":"secret123"}}' \
| jq -r '.token')
# 2. Проверка профиля
curl -s http://localhost:3000/api/me \
-H "Authorization: Bearer $TOKEN"
# 3. Поиск матча — WebSocket (например, через wscat):
# wscat -c "ws://localhost:3000/cable?token=$TOKEN"
# > {"command":"subscribe","identifier":"{\"channel\":\"MatchmakingChannel\"}"}
# > {"command":"message","identifier":"{\"channel\":\"MatchmakingChannel\"}","data":"{\"action\":\"start\"}"}
```