first commit

This commit is contained in:
itqop 2025-10-11 01:14:16 +03:00
commit 4d51beb350
74 changed files with 3217 additions and 0 deletions

65
.gitignore vendored Normal file
View File

@ -0,0 +1,65 @@
# Python
__pycache__/
*.py[cod]
*$py.class
*.so
.Python
build/
develop-eggs/
dist/
downloads/
eggs/
.eggs/
lib/
lib64/
parts/
sdist/
var/
wheels/
*.egg-info/
.installed.cfg
*.egg
MANIFEST
# Virtual environments
.env
.venv
env/
venv/
ENV/
env.bak/
venv.bak/
# IDE
.vscode/
.idea/
*.swp
*.swo
*~
# OS
.DS_Store
.DS_Store?
._*
.Spotlight-V100
.Trashes
ehthumbs.db
Thumbs.db
# Logs
logs/
*.log
# Database
*.db
*.sqlite3
# Testing
.pytest_cache/
.coverage
htmlcov/
.tox/
.nox/
# Poetry
poetry.lock

282
API_EXAMPLES.md Normal file
View File

@ -0,0 +1,282 @@
# API Examples
Примеры использования API для HubGW.
## Аутентификация
Все запросы (кроме `/health`) требуют заголовок `X-API-Key` с правильным API ключом.
```bash
curl -H "X-API-Key: your-secret-api-key" \
-H "Content-Type: application/json" \
http://localhost:8080/api/v1/health/
```
## Homes
### Создать/обновить дом
```bash
curl -X PUT \
-H "X-API-Key: your-secret-api-key" \
-H "Content-Type: application/json" \
-d '{
"player_uuid": "123e4567-e89b-12d3-a456-426614174000",
"name": "my_home",
"world": "world",
"x": 100.5,
"y": 64.0,
"z": 200.3,
"yaw": 90.0,
"pitch": 0.0,
"is_public": 0
}' \
http://localhost:8080/api/v1/homes/
```
### Получить дом
```bash
curl -X POST \
-H "X-API-Key: your-secret-api-key" \
-H "Content-Type: application/json" \
-d '{
"player_uuid": "123e4567-e89b-12d3-a456-426614174000",
"name": "my_home"
}' \
http://localhost:8080/api/v1/homes/get
```
### Список домов игрока
```bash
curl -H "X-API-Key: your-secret-api-key" \
http://localhost:8080/api/v1/homes/123e4567-e89b-12d3-a456-426614174000
```
## Kits
### Запросить набор
```bash
curl -X POST \
-H "X-API-Key: your-secret-api-key" \
-H "Content-Type: application/json" \
-d '{
"player_uuid": "123e4567-e89b-12d3-a456-426614174000",
"kit_name": "starter"
}' \
http://localhost:8080/api/v1/kits/claim
```
## Cooldowns
### Проверить кулдаун
```bash
curl -X POST \
-H "X-API-Key: your-secret-api-key" \
-H "Content-Type: application/json" \
-d '{
"player_uuid": "123e4567-e89b-12d3-a456-426614174000",
"key": "kit_starter"
}' \
http://localhost:8080/api/v1/cooldowns/check
```
### Установить кулдаун
```bash
curl -X PUT \
-H "X-API-Key: your-secret-api-key" \
-H "Content-Type: application/json" \
-d '{
"key": "kit_starter"
}' \
"http://localhost:8080/api/v1/cooldowns/touch?seconds=3600&player_uuid=123e4567-e89b-12d3-a456-426614174000"
```
## Warps
### Создать варп
```bash
curl -X POST \
-H "X-API-Key: your-secret-api-key" \
-H "Content-Type: application/json" \
-d '{
"name": "spawn",
"world": "world",
"x": 0.0,
"y": 64.0,
"z": 0.0,
"yaw": 0.0,
"pitch": 0.0,
"is_public": 1,
"description": "Main spawn point"
}' \
http://localhost:8080/api/v1/warps/
```
### Обновить варп
```bash
curl -X PATCH \
-H "X-API-Key: your-secret-api-key" \
-H "Content-Type: application/json" \
-d '{
"name": "spawn",
"world": "world_nether",
"description": "Updated spawn point"
}' \
http://localhost:8080/api/v1/warps/
```
### Удалить варп
```bash
curl -X DELETE \
-H "X-API-Key: your-secret-api-key" \
-H "Content-Type: application/json" \
-d '{
"name": "spawn"
}' \
http://localhost:8080/api/v1/warps/
```
### Получить варп
```bash
curl -X POST \
-H "X-API-Key: your-secret-api-key" \
-H "Content-Type: application/json" \
-d '{
"name": "spawn"
}' \
http://localhost:8080/api/v1/warps/get
```
### Список варпов
```bash
curl -X POST \
-H "X-API-Key: your-secret-api-key" \
-H "Content-Type: application/json" \
-d '{
"page": 1,
"size": 20,
"world": "world",
"is_public": 1
}' \
http://localhost:8080/api/v1/warps/list
```
## Whitelist
### Добавить игрока в вайтлист
```bash
curl -X POST \
-H "X-API-Key: your-secret-api-key" \
-H "Content-Type: application/json" \
-d '{
"player_name": "PlayerName",
"player_uuid": "123e4567-e89b-12d3-a456-426614174000",
"added_by": "admin",
"reason": "VIP player"
}' \
http://localhost:8080/api/v1/whitelist/add
```
### Удалить игрока из вайтлиста
```bash
curl -X POST \
-H "X-API-Key: your-secret-api-key" \
-H "Content-Type: application/json" \
-d '{
"player_name": "PlayerName"
}' \
http://localhost:8080/api/v1/whitelist/remove
```
### Проверить вайтлист
```bash
curl -X POST \
-H "X-API-Key: your-secret-api-key" \
-H "Content-Type: application/json" \
-d '{
"player_name": "PlayerName"
}' \
http://localhost:8080/api/v1/whitelist/check
```
### Список вайтлиста
```bash
curl -H "X-API-Key: your-secret-api-key" \
http://localhost:8080/api/v1/whitelist/
```
## Punishments
### Создать наказание
```bash
curl -X POST \
-H "X-API-Key: your-secret-api-key" \
-H "Content-Type: application/json" \
-d '{
"player_uuid": "123e4567-e89b-12d3-a456-426614174000",
"player_name": "PlayerName",
"punishment_type": "ban",
"reason": "Cheating",
"staff_uuid": "987fcdeb-51a2-43d1-b456-426614174000",
"staff_name": "Admin",
"expires_at": "2024-12-31T23:59:59Z"
}' \
http://localhost:8080/api/v1/punishments/
```
### Отменить наказание
```bash
curl -X POST \
-H "X-API-Key: your-secret-api-key" \
-H "Content-Type: application/json" \
-d '{
"punishment_id": "456e7890-e89b-12d3-a456-426614174000",
"revoked_by": "987fcdeb-51a2-43d1-b456-426614174000",
"revoked_reason": "Appeal accepted"
}' \
http://localhost:8080/api/v1/punishments/revoke
```
### Запрос наказаний
```bash
curl -X POST \
-H "X-API-Key: your-secret-api-key" \
-H "Content-Type: application/json" \
-d '{
"player_uuid": "123e4567-e89b-12d3-a456-426614174000",
"punishment_type": "ban",
"is_active": true,
"page": 1,
"size": 20
}' \
http://localhost:8080/api/v1/punishments/query
```
### Статус бана
```bash
curl -H "X-API-Key: your-secret-api-key" \
http://localhost:8080/api/v1/punishments/ban/123e4567-e89b-12d3-a456-426614174000
```
### Статус мута
```bash
curl -H "X-API-Key: your-secret-api-key" \
http://localhost:8080/api/v1/punishments/mute/123e4567-e89b-12d3-a456-426614174000
```
## Audit
### Логирование команды
```bash
curl -X POST \
-H "X-API-Key: your-secret-api-key" \
-H "Content-Type: application/json" \
-d '{
"player_uuid": "123e4567-e89b-12d3-a456-426614174000",
"player_name": "PlayerName",
"command": "tp",
"arguments": ["player2"],
"server": "hub",
"timestamp": "2024-01-01T12:00:00Z"
}' \
http://localhost:8080/api/v1/audit/commands
```

156
DEPLOYMENT.md Normal file
View File

@ -0,0 +1,156 @@
# Развертывание HubGW
Инструкции по развертыванию FastAPI-шлюза HubGW.
## Требования
- Python 3.11+
- PostgreSQL 12+
- Poetry (для управления зависимостями)
## Установка
1. Клонируйте репозиторий:
```bash
git clone <repository-url>
cd hubmc-datagw
```
2. Установите зависимости:
```bash
poetry install
```
3. Создайте файл `.env` на основе `.env.example`:
```bash
cp .env.example .env
```
4. Настройте переменные окружения в `.env`:
```env
APP_ENV=prod
APP_HOST=0.0.0.0
APP_PORT=8080
APP_LOG_LEVEL=INFO
DB_DSN=postgresql+asyncpg://user:password@localhost:5432/hubgw
DB_POOL_SIZE=10
DB_MAX_OVERFLOW=10
API_KEY=your-secure-api-key-here
RATE_LIMIT_PER_MIN=1000
```
## Настройка базы данных
1. Создайте базу данных PostgreSQL:
```sql
CREATE DATABASE hubgw;
CREATE USER hubgw_user WITH PASSWORD 'secure_password';
GRANT ALL PRIVILEGES ON DATABASE hubgw TO hubgw_user;
```
2. Создайте таблицы (в будущем будет добавлена миграция):
```sql
-- Таблицы будут созданы автоматически при первом запуске
-- или можно использовать Alembic для миграций
```
## Запуск
### Разработка
```bash
poetry run hubgw
```
### Продакшн с Gunicorn
```bash
poetry run gunicorn hubgw.main:create_app -w 4 -k uvicorn.workers.UvicornWorker --bind 0.0.0.0:8080
```
### Продакшн с Docker
```dockerfile
FROM python:3.11-slim
WORKDIR /app
# Установка Poetry
RUN pip install poetry
# Копирование файлов
COPY pyproject.toml poetry.lock ./
COPY src/ ./src/
# Установка зависимостей
RUN poetry config virtualenvs.create false
RUN poetry install --no-dev
# Запуск приложения
CMD ["poetry", "run", "hubgw"]
```
## Мониторинг
### Логи
Логи сохраняются в директории `logs/` в продакшне:
- `logs/hubgw.log` - основные логи приложения
- Ротация каждый день
- Хранение 30 дней
- Сжатие старых логов
### Health Check
```bash
curl http://localhost:8080/api/v1/health/
```
## Безопасность
1. **API Key**: Используйте сильный, случайный API ключ
2. **HTTPS**: Настройте SSL/TLS в продакшне
3. **Firewall**: Ограничьте доступ к порту 8080
4. **База данных**: Используйте отдельного пользователя БД с минимальными правами
## Масштабирование
### Горизонтальное масштабирование
- Используйте load balancer (nginx, HAProxy)
- Настройте sticky sessions если необходимо
- Используйте внешний Redis для сессий
### Вертикальное масштабирование
- Увеличьте `DB_POOL_SIZE` и `DB_MAX_OVERFLOW`
- Настройте `RATE_LIMIT_PER_MIN` под нагрузку
- Мониторьте использование памяти и CPU
## Резервное копирование
1. **База данных**: Регулярные бэкапы PostgreSQL
2. **Конфигурация**: Сохраните файл `.env` в безопасном месте
3. **Логи**: Архивируйте важные логи
## Обновление
1. Остановите приложение
2. Создайте бэкап базы данных
3. Обновите код
4. Установите новые зависимости: `poetry install`
5. Примените миграции (если есть)
6. Запустите приложение
7. Проверьте работоспособность
## Устранение неполадок
### Проблемы с подключением к БД
- Проверьте строку подключения в `DB_DSN`
- Убедитесь, что PostgreSQL запущен
- Проверьте права пользователя БД
### Проблемы с API
- Проверьте правильность API ключа
- Убедитесь, что заголовок `X-API-Key` передается
- Проверьте логи приложения
### Проблемы с производительностью
- Увеличьте размер пула БД
- Проверьте индексы в базе данных
- Мониторьте использование ресурсов

31
README.md Normal file
View File

@ -0,0 +1,31 @@
# HubGW
FastAPI Gateway for HubMC
## Установка
```bash
poetry install
```
## Запуск
```bash
poetry run hubgw
```
## Конфигурация
Создайте файл `.env` с необходимыми переменными окружения:
```env
APP_ENV=dev
APP_HOST=0.0.0.0
APP_PORT=8080
APP_LOG_LEVEL=INFO
DB_DSN=postgresql+asyncpg://user:pass@host:5432/hubgw
DB_POOL_SIZE=10
DB_MAX_OVERFLOW=10
API_KEY=your-api-key
RATE_LIMIT_PER_MIN=100
```

31
pyproject.toml Normal file
View File

@ -0,0 +1,31 @@
[build-system]
requires = ["poetry-core"]
build-backend = "poetry.core.masonry.api"
[tool.poetry]
name = "hubgw"
version = "0.1.0"
description = "FastAPI Gateway for HubMC"
authors = ["itqop <leonkl32@gmail.com>"]
readme = "README.md"
[tool.poetry.dependencies]
python = "^3.11"
fastapi = "^0.104.0"
uvicorn = {extras = ["standard"], version = "^0.24.0"}
pydantic = "^2.5.0"
pydantic-settings = "^2.1.0"
sqlalchemy = "^2.0.0"
asyncpg = "^0.29.0"
loguru = "^0.7.0"
[tool.poetry.group.dev.dependencies]
pytest = "^7.4.0"
pytest-asyncio = "^0.21.0"
httpx = "^0.25.0"
[tool.poetry.scripts]
hubgw = "hubgw.__main__:main"
[tool.poetry.packages]
{include = "hubgw", from = "src"}

3
src/hubgw/__init__.py Normal file
View File

@ -0,0 +1,3 @@
"""HubGW - FastAPI Gateway for HubMC"""
__version__ = "0.1.0"

19
src/hubgw/__main__.py Normal file
View File

@ -0,0 +1,19 @@
"""Entry point for hubgw application."""
import uvicorn
from hubgw.main import create_app
def main():
"""Main entry point."""
app = create_app()
uvicorn.run(
app,
host="0.0.0.0",
port=8080,
log_level="info"
)
if __name__ == "__main__":
main()

View File

@ -0,0 +1 @@
"""API module for hubgw."""

72
src/hubgw/api/deps.py Normal file
View File

@ -0,0 +1,72 @@
"""Dependency providers for FastAPI."""
from fastapi import Depends, HTTPException, Header
from sqlalchemy.ext.asyncio import AsyncSession
from typing import Annotated
from hubgw.context import AppContext
from hubgw.core.config import AppSettings
from hubgw.services.homes_service import HomesService
from hubgw.services.kits_service import KitsService
from hubgw.services.cooldowns_service import CooldownsService
from hubgw.services.warps_service import WarpsService
from hubgw.services.whitelist_service import WhitelistService
from hubgw.services.punishments_service import PunishmentsService
from hubgw.services.audit_service import AuditService
async def get_context() -> AppContext:
"""Get application context."""
return AppContext()
async def get_session(context: Annotated[AppContext, Depends(get_context)]) -> AsyncSession:
"""Get database session."""
async with context.session_factory() as session:
yield session
async def verify_api_key(
x_api_key: Annotated[str, Header(alias="X-API-Key")],
context: Annotated[AppContext, Depends(get_context)]
) -> str:
"""Verify API key."""
if x_api_key != context.settings.API_KEY:
raise HTTPException(status_code=401, detail="Invalid API key")
return x_api_key
# Service dependencies
def get_homes_service(session: Annotated[AsyncSession, Depends(get_session)]) -> HomesService:
"""Get homes service."""
return HomesService(session)
def get_kits_service(session: Annotated[AsyncSession, Depends(get_session)]) -> KitsService:
"""Get kits service."""
return KitsService(session)
def get_cooldowns_service(session: Annotated[AsyncSession, Depends(get_session)]) -> CooldownsService:
"""Get cooldowns service."""
return CooldownsService(session)
def get_warps_service(session: Annotated[AsyncSession, Depends(get_session)]) -> WarpsService:
"""Get warps service."""
return WarpsService(session)
def get_whitelist_service(session: Annotated[AsyncSession, Depends(get_session)]) -> WhitelistService:
"""Get whitelist service."""
return WhitelistService(session)
def get_punishments_service(session: Annotated[AsyncSession, Depends(get_session)]) -> PunishmentsService:
"""Get punishments service."""
return PunishmentsService(session)
def get_audit_service(session: Annotated[AsyncSession, Depends(get_session)]) -> AuditService:
"""Get audit service."""
return AuditService(session)

View File

@ -0,0 +1 @@
"""API v1 module."""

24
src/hubgw/api/v1/audit.py Normal file
View File

@ -0,0 +1,24 @@
"""Audit endpoints."""
from fastapi import APIRouter, Depends, HTTPException
from typing import Annotated
from hubgw.api.deps import get_audit_service, verify_api_key
from hubgw.services.audit_service import AuditService
from hubgw.schemas.audit import CommandAuditRequest, CommandAuditResponse
from hubgw.core.errors import AppError, create_http_exception
router = APIRouter()
@router.post("/commands", response_model=CommandAuditResponse, status_code=202)
async def log_command(
request: CommandAuditRequest,
service: Annotated[AuditService, Depends(get_audit_service)],
_: Annotated[str, Depends(verify_api_key)]
):
"""Log command execution for audit."""
try:
return await service.log_command(request)
except AppError as e:
raise create_http_exception(e)

View File

@ -0,0 +1,41 @@
"""Cooldowns endpoints."""
from fastapi import APIRouter, Depends, HTTPException, Query
from typing import Annotated
from uuid import UUID
from hubgw.api.deps import get_cooldowns_service, verify_api_key
from hubgw.services.cooldowns_service import CooldownsService
from hubgw.schemas.cooldowns import CooldownCheckRequest, CooldownCheckResponse, CooldownKey
from hubgw.core.errors import AppError, create_http_exception
router = APIRouter()
@router.post("/check", response_model=CooldownCheckResponse)
async def check_cooldown(
request: CooldownCheckRequest,
service: Annotated[CooldownsService, Depends(get_cooldowns_service)],
_: Annotated[str, Depends(verify_api_key)]
):
"""Check cooldown status."""
try:
return await service.check_cooldown(request)
except AppError as e:
raise create_http_exception(e)
@router.put("/touch")
async def touch_cooldown(
key: CooldownKey,
seconds: int = Query(..., description="Cooldown duration in seconds"),
player_uuid: UUID = Query(..., description="Player UUID"),
service: Annotated[CooldownsService, Depends(get_cooldowns_service)],
_: Annotated[str, Depends(verify_api_key)]
):
"""Touch cooldown."""
try:
await service.touch_cooldown(player_uuid, key.key, seconds)
return {"message": "Cooldown touched successfully"}
except AppError as e:
raise create_http_exception(e)

View File

@ -0,0 +1,11 @@
"""Health check endpoints."""
from fastapi import APIRouter
router = APIRouter()
@router.get("/")
async def health_check():
"""Health check endpoint."""
return {"status": "ok"}

51
src/hubgw/api/v1/homes.py Normal file
View File

@ -0,0 +1,51 @@
"""Homes endpoints."""
from fastapi import APIRouter, Depends, HTTPException
from typing import Annotated
from uuid import UUID
from hubgw.api.deps import get_homes_service, verify_api_key
from hubgw.services.homes_service import HomesService
from hubgw.schemas.homes import HomeUpsertRequest, HomeGetRequest, Home, HomeGetResponse, HomeListResponse
from hubgw.core.errors import AppError, create_http_exception
router = APIRouter()
@router.put("/", response_model=Home)
async def upsert_home(
request: HomeUpsertRequest,
service: Annotated[HomesService, Depends(get_homes_service)],
_: Annotated[str, Depends(verify_api_key)]
):
"""Upsert home."""
try:
return await service.upsert_home(request)
except AppError as e:
raise create_http_exception(e)
@router.post("/get", response_model=HomeGetResponse)
async def get_home(
request: HomeGetRequest,
service: Annotated[HomesService, Depends(get_homes_service)],
_: Annotated[str, Depends(verify_api_key)]
):
"""Get home."""
try:
return await service.get_home(request)
except AppError as e:
raise create_http_exception(e)
@router.get("/{player_uuid}", response_model=HomeListResponse)
async def list_homes(
player_uuid: UUID,
service: Annotated[HomesService, Depends(get_homes_service)],
_: Annotated[str, Depends(verify_api_key)]
):
"""List homes for player."""
try:
return await service.list_homes(player_uuid)
except AppError as e:
raise create_http_exception(e)

24
src/hubgw/api/v1/kits.py Normal file
View File

@ -0,0 +1,24 @@
"""Kits endpoints."""
from fastapi import APIRouter, Depends, HTTPException
from typing import Annotated
from hubgw.api.deps import get_kits_service, verify_api_key
from hubgw.services.kits_service import KitsService
from hubgw.schemas.kits import KitClaimRequest, KitClaimResponse
from hubgw.core.errors import AppError, create_http_exception
router = APIRouter()
@router.post("/claim", response_model=KitClaimResponse)
async def claim_kit(
request: KitClaimRequest,
service: Annotated[KitsService, Depends(get_kits_service)],
_: Annotated[str, Depends(verify_api_key)]
):
"""Claim kit."""
try:
return await service.claim_kit(request)
except AppError as e:
raise create_http_exception(e)

View File

@ -0,0 +1,80 @@
"""Punishments endpoints."""
from fastapi import APIRouter, Depends, HTTPException
from typing import Annotated
from uuid import UUID
from hubgw.api.deps import get_punishments_service, verify_api_key
from hubgw.services.punishments_service import PunishmentsService
from hubgw.schemas.punishments import (
PunishmentCreateRequest, PunishmentRevokeRequest, PunishmentQuery,
PunishmentBase, PunishmentListResponse, ActiveBanStatusResponse, ActiveMuteStatusResponse
)
from hubgw.core.errors import AppError, create_http_exception
router = APIRouter()
@router.post("/", response_model=PunishmentBase, status_code=201)
async def create_punishment(
request: PunishmentCreateRequest,
service: Annotated[PunishmentsService, Depends(get_punishments_service)],
_: Annotated[str, Depends(verify_api_key)]
):
"""Create punishment."""
try:
return await service.create_punishment(request)
except AppError as e:
raise create_http_exception(e)
@router.post("/revoke", response_model=PunishmentBase)
async def revoke_punishment(
request: PunishmentRevokeRequest,
service: Annotated[PunishmentsService, Depends(get_punishments_service)],
_: Annotated[str, Depends(verify_api_key)]
):
"""Revoke punishment."""
try:
return await service.revoke_punishment(request)
except AppError as e:
raise create_http_exception(e)
@router.post("/query", response_model=PunishmentListResponse)
async def query_punishments(
query: PunishmentQuery,
service: Annotated[PunishmentsService, Depends(get_punishments_service)],
_: Annotated[str, Depends(verify_api_key)]
):
"""Query punishments."""
try:
return await service.query_punishments(query)
except AppError as e:
raise create_http_exception(e)
@router.get("/ban/{player_uuid}", response_model=ActiveBanStatusResponse)
async def get_ban_status(
player_uuid: UUID,
service: Annotated[PunishmentsService, Depends(get_punishments_service)],
_: Annotated[str, Depends(verify_api_key)]
):
"""Get active ban status for player."""
try:
return await service.get_active_ban_status(player_uuid)
except AppError as e:
raise create_http_exception(e)
@router.get("/mute/{player_uuid}", response_model=ActiveMuteStatusResponse)
async def get_mute_status(
player_uuid: UUID,
service: Annotated[PunishmentsService, Depends(get_punishments_service)],
_: Annotated[str, Depends(verify_api_key)]
):
"""Get active mute status for player."""
try:
return await service.get_active_mute_status(player_uuid)
except AppError as e:
raise create_http_exception(e)

View File

@ -0,0 +1,16 @@
"""Main API v1 router."""
from fastapi import APIRouter
from hubgw.api.v1 import health, homes, kits, cooldowns, warps, whitelist, punishments, audit
api_router = APIRouter()
# Include all sub-routers
api_router.include_router(health.router, prefix="/health", tags=["health"])
api_router.include_router(homes.router, prefix="/homes", tags=["homes"])
api_router.include_router(kits.router, prefix="/kits", tags=["kits"])
api_router.include_router(cooldowns.router, prefix="/cooldowns", tags=["cooldowns"])
api_router.include_router(warps.router, prefix="/warps", tags=["warps"])
api_router.include_router(whitelist.router, prefix="/whitelist", tags=["whitelist"])
api_router.include_router(punishments.router, prefix="/punishments", tags=["punishments"])
api_router.include_router(audit.router, prefix="/audit", tags=["audit"])

79
src/hubgw/api/v1/warps.py Normal file
View File

@ -0,0 +1,79 @@
"""Warps endpoints."""
from fastapi import APIRouter, Depends, HTTPException
from typing import Annotated
from hubgw.api.deps import get_warps_service, verify_api_key
from hubgw.services.warps_service import WarpsService
from hubgw.schemas.warps import (
WarpCreateRequest, WarpUpdateRequest, WarpDeleteRequest, WarpGetRequest,
Warp, WarpGetResponse, WarpListQuery, WarpListResponse
)
from hubgw.core.errors import AppError, create_http_exception
router = APIRouter()
@router.post("/", response_model=Warp, status_code=201)
async def create_warp(
request: WarpCreateRequest,
service: Annotated[WarpsService, Depends(get_warps_service)],
_: Annotated[str, Depends(verify_api_key)]
):
"""Create warp."""
try:
return await service.create_warp(request)
except AppError as e:
raise create_http_exception(e)
@router.patch("/", response_model=Warp)
async def update_warp(
request: WarpUpdateRequest,
service: Annotated[WarpsService, Depends(get_warps_service)],
_: Annotated[str, Depends(verify_api_key)]
):
"""Update warp."""
try:
return await service.update_warp(request)
except AppError as e:
raise create_http_exception(e)
@router.delete("/", status_code=204)
async def delete_warp(
request: WarpDeleteRequest,
service: Annotated[WarpsService, Depends(get_warps_service)],
_: Annotated[str, Depends(verify_api_key)]
):
"""Delete warp."""
try:
await service.delete_warp(request)
except AppError as e:
raise create_http_exception(e)
@router.post("/get", response_model=WarpGetResponse)
async def get_warp(
request: WarpGetRequest,
service: Annotated[WarpsService, Depends(get_warps_service)],
_: Annotated[str, Depends(verify_api_key)]
):
"""Get warp."""
try:
return await service.get_warp(request)
except AppError as e:
raise create_http_exception(e)
@router.post("/list", response_model=WarpListResponse)
async def list_warps(
query: WarpListQuery,
service: Annotated[WarpsService, Depends(get_warps_service)],
_: Annotated[str, Depends(verify_api_key)]
):
"""List warps."""
try:
return await service.list_warps(query)
except AppError as e:
raise create_http_exception(e)

View File

@ -0,0 +1,65 @@
"""Whitelist endpoints."""
from fastapi import APIRouter, Depends, HTTPException
from typing import Annotated
from hubgw.api.deps import get_whitelist_service, verify_api_key
from hubgw.services.whitelist_service import WhitelistService
from hubgw.schemas.whitelist import (
WhitelistAddRequest, WhitelistRemoveRequest, WhitelistCheckRequest,
WhitelistEntry, WhitelistCheckResponse, WhitelistListResponse
)
from hubgw.core.errors import AppError, create_http_exception
router = APIRouter()
@router.post("/add", response_model=WhitelistEntry, status_code=201)
async def add_player(
request: WhitelistAddRequest,
service: Annotated[WhitelistService, Depends(get_whitelist_service)],
_: Annotated[str, Depends(verify_api_key)]
):
"""Add player to whitelist."""
try:
return await service.add_player(request)
except AppError as e:
raise create_http_exception(e)
@router.post("/remove", status_code=204)
async def remove_player(
request: WhitelistRemoveRequest,
service: Annotated[WhitelistService, Depends(get_whitelist_service)],
_: Annotated[str, Depends(verify_api_key)]
):
"""Remove player from whitelist."""
try:
await service.remove_player(request)
except AppError as e:
raise create_http_exception(e)
@router.post("/check", response_model=WhitelistCheckResponse)
async def check_player(
request: WhitelistCheckRequest,
service: Annotated[WhitelistService, Depends(get_whitelist_service)],
_: Annotated[str, Depends(verify_api_key)]
):
"""Check if player is whitelisted."""
try:
return await service.check_player(request)
except AppError as e:
raise create_http_exception(e)
@router.get("/", response_model=WhitelistListResponse)
async def list_players(
service: Annotated[WhitelistService, Depends(get_whitelist_service)],
_: Annotated[str, Depends(verify_api_key)]
):
"""List all whitelisted players."""
try:
return await service.list_players()
except AppError as e:
raise create_http_exception(e)

39
src/hubgw/context.py Normal file
View File

@ -0,0 +1,39 @@
"""Application context singleton."""
from sqlalchemy.ext.asyncio import AsyncEngine, async_sessionmaker, create_async_engine
from hubgw.core.config import AppSettings
class AppContext:
"""Application context singleton."""
_instance = None
def __new__(cls):
if cls._instance is None:
cls._instance = super().__new__(cls)
return cls._instance
def __init__(self):
if not hasattr(self, 'initialized'):
self.settings = AppSettings()
self.engine: AsyncEngine = None
self.session_factory: async_sessionmaker = None
self.initialized = True
async def startup(self):
"""Initialize database engine and session factory."""
self.engine = create_async_engine(
self.settings.DB_DSN,
pool_size=self.settings.DB_POOL_SIZE,
max_overflow=self.settings.DB_MAX_OVERFLOW
)
self.session_factory = async_sessionmaker(
self.engine,
expire_on_commit=False
)
async def shutdown(self):
"""Close database engine."""
if self.engine:
await self.engine.dispose()

View File

@ -0,0 +1 @@
"""Core module for hubgw."""

27
src/hubgw/core/config.py Normal file
View File

@ -0,0 +1,27 @@
"""Application configuration using Pydantic Settings."""
from pydantic_settings import BaseSettings
from typing import Optional
class AppSettings(BaseSettings):
"""Application settings."""
# App settings
APP_ENV: str = "dev"
APP_HOST: str = "0.0.0.0"
APP_PORT: int = 8080
APP_LOG_LEVEL: str = "INFO"
# Database settings
DB_DSN: str = "postgresql+asyncpg://user:pass@localhost:5432/hubgw"
DB_POOL_SIZE: int = 10
DB_MAX_OVERFLOW: int = 10
# Security settings
API_KEY: str = "your-api-key"
RATE_LIMIT_PER_MIN: Optional[int] = None
class Config:
env_file = ".env"
case_sensitive = True

65
src/hubgw/core/errors.py Normal file
View File

@ -0,0 +1,65 @@
"""Custom exceptions and error handlers."""
from fastapi import HTTPException
from typing import Any, Dict, Optional
class AppError(Exception):
"""Base application error."""
def __init__(
self,
message: str,
code: str = "app_error",
details: Optional[Dict[str, Any]] = None
):
self.message = message
self.code = code
self.details = details or {}
super().__init__(message)
class ValidationError(AppError):
"""Validation error."""
def __init__(self, message: str, details: Optional[Dict[str, Any]] = None):
super().__init__(message, "invalid_state", details)
class NotFoundError(AppError):
"""Not found error."""
def __init__(self, message: str, details: Optional[Dict[str, Any]] = None):
super().__init__(message, "not_found", details)
class AlreadyExistsError(AppError):
"""Already exists error."""
def __init__(self, message: str, details: Optional[Dict[str, Any]] = None):
super().__init__(message, "already_exists", details)
class CooldownActiveError(AppError):
"""Cooldown active error."""
def __init__(self, message: str, details: Optional[Dict[str, Any]] = None):
super().__init__(message, "cooldown_active", details)
def create_http_exception(error: AppError) -> HTTPException:
"""Convert AppError to HTTPException."""
status_code = 400
if isinstance(error, NotFoundError):
status_code = 404
elif isinstance(error, AlreadyExistsError):
status_code = 409
return HTTPException(
status_code=status_code,
detail={
"message": error.message,
"code": error.code,
"details": error.details
}
)

32
src/hubgw/core/logging.py Normal file
View File

@ -0,0 +1,32 @@
"""Logging configuration using loguru."""
import sys
from loguru import logger
from hubgw.core.config import AppSettings
def setup_logging():
"""Setup loguru logging configuration."""
settings = AppSettings()
# Remove default handler
logger.remove()
# Add console handler
logger.add(
sys.stdout,
level=settings.APP_LOG_LEVEL,
format="<green>{time:YYYY-MM-DD HH:mm:ss}</green> | <level>{level: <8}</level> | <cyan>{name}</cyan>:<cyan>{function}</cyan>:<cyan>{line}</cyan> - <level>{message}</level>",
colorize=True
)
# Add file handler for production
if settings.APP_ENV == "prod":
logger.add(
"logs/hubgw.log",
level=settings.APP_LOG_LEVEL,
format="{time:YYYY-MM-DD HH:mm:ss} | {level: <8} | {name}:{function}:{line} - {message}",
rotation="1 day",
retention="30 days",
compression="zip"
)

34
src/hubgw/main.py Normal file
View File

@ -0,0 +1,34 @@
"""FastAPI application factory."""
from fastapi import FastAPI
from hubgw.core.logging import setup_logging
from hubgw.context import AppContext
def create_app() -> FastAPI:
"""Create and configure FastAPI application."""
app = FastAPI(
title="HubGW",
description="FastAPI Gateway for HubMC",
version="0.1.0"
)
# Setup logging
setup_logging()
# Initialize context
ctx = AppContext()
@app.on_event("startup")
async def startup():
await ctx.startup()
@app.on_event("shutdown")
async def shutdown():
await ctx.shutdown()
# Include routers
from hubgw.api.v1.router import api_router
app.include_router(api_router, prefix="/api/v1")
return app

View File

@ -0,0 +1 @@
"""Database models for hubgw."""

12
src/hubgw/models/base.py Normal file
View File

@ -0,0 +1,12 @@
"""Base model class for SQLAlchemy."""
from sqlalchemy.orm import DeclarativeBase
from sqlalchemy import Column, DateTime, func
from datetime import datetime
class Base(DeclarativeBase):
"""Base class for all models."""
created_at = Column(DateTime(timezone=True), server_default=func.now())
updated_at = Column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now())

View File

@ -0,0 +1,17 @@
"""Cooldown model."""
from sqlalchemy import Column, String, DateTime, Integer
from sqlalchemy.dialects.postgresql import UUID
from hubgw.models.base import Base
class Cooldown(Base):
"""Cooldown model."""
__tablename__ = "cooldowns"
id = Column(UUID(as_uuid=True), primary_key=True)
key = Column(String(255), nullable=False, unique=True, index=True)
player_uuid = Column(UUID(as_uuid=True), nullable=False, index=True)
expires_at = Column(DateTime(timezone=True), nullable=False)
cooldown_seconds = Column(Integer, nullable=False)

22
src/hubgw/models/home.py Normal file
View File

@ -0,0 +1,22 @@
"""Home model."""
from sqlalchemy import Column, String, Float, Integer, ForeignKey
from sqlalchemy.dialects.postgresql import UUID
from hubgw.models.base import Base
class Home(Base):
"""Home model."""
__tablename__ = "homes"
id = Column(UUID(as_uuid=True), primary_key=True)
player_uuid = Column(UUID(as_uuid=True), nullable=False, index=True)
name = Column(String(255), nullable=False)
world = Column(String(255), nullable=False)
x = Column(Float, nullable=False)
y = Column(Float, nullable=False)
z = Column(Float, nullable=False)
yaw = Column(Float, default=0.0)
pitch = Column(Float, default=0.0)
is_public = Column(Integer, default=0) # 0 = private, 1 = public

View File

@ -0,0 +1,25 @@
"""Punishment model."""
from sqlalchemy import Column, String, DateTime, Integer, Text
from sqlalchemy.dialects.postgresql import UUID
from hubgw.models.base import Base
class Punishment(Base):
"""Punishment model."""
__tablename__ = "punishments"
id = Column(UUID(as_uuid=True), primary_key=True)
player_uuid = Column(UUID(as_uuid=True), nullable=False, index=True)
player_name = Column(String(255), nullable=False)
punishment_type = Column(String(50), nullable=False) # ban, warn, mute
reason = Column(Text, nullable=False)
staff_uuid = Column(UUID(as_uuid=True), nullable=False)
staff_name = Column(String(255), nullable=False)
created_at = Column(DateTime(timezone=True), nullable=False)
expires_at = Column(DateTime(timezone=True), nullable=True) # None for permanent
is_active = Column(Integer, default=1) # 0 = inactive, 1 = active
revoked_at = Column(DateTime(timezone=True), nullable=True)
revoked_by = Column(UUID(as_uuid=True), nullable=True)
revoked_reason = Column(Text, nullable=True)

22
src/hubgw/models/warp.py Normal file
View File

@ -0,0 +1,22 @@
"""Warp model."""
from sqlalchemy import Column, String, Float, Integer
from sqlalchemy.dialects.postgresql import UUID
from hubgw.models.base import Base
class Warp(Base):
"""Warp model."""
__tablename__ = "warps"
id = Column(UUID(as_uuid=True), primary_key=True)
name = Column(String(255), nullable=False, unique=True, index=True)
world = Column(String(255), nullable=False)
x = Column(Float, nullable=False)
y = Column(Float, nullable=False)
z = Column(Float, nullable=False)
yaw = Column(Float, default=0.0)
pitch = Column(Float, default=0.0)
is_public = Column(Integer, default=1) # 0 = private, 1 = public
description = Column(String(500), nullable=True)

View File

@ -0,0 +1,18 @@
"""Whitelist model."""
from sqlalchemy import Column, String, DateTime
from sqlalchemy.dialects.postgresql import UUID
from hubgw.models.base import Base
class WhitelistEntry(Base):
"""Whitelist entry model."""
__tablename__ = "whitelist"
id = Column(UUID(as_uuid=True), primary_key=True)
player_name = Column(String(255), nullable=False, unique=True, index=True)
player_uuid = Column(UUID(as_uuid=True), nullable=True, index=True)
added_by = Column(String(255), nullable=False)
added_at = Column(DateTime(timezone=True), nullable=False)
reason = Column(String(500), nullable=True)

View File

@ -0,0 +1 @@
"""Repositories for hubgw."""

View File

@ -0,0 +1,63 @@
"""Cooldowns repository."""
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select, insert, update, delete, func
from typing import Optional, List
from uuid import UUID
from datetime import datetime, timedelta
from hubgw.models.cooldown import Cooldown
class CooldownsRepository:
"""Cooldowns repository for database operations."""
def __init__(self, session: AsyncSession):
self.session = session
async def check(self, player_uuid: UUID, key: str) -> Optional[Cooldown]:
"""Check if cooldown is active."""
stmt = select(Cooldown).where(
Cooldown.player_uuid == player_uuid,
Cooldown.key == key,
Cooldown.expires_at > func.now()
)
result = await self.session.execute(stmt)
return result.scalar_one_or_none()
async def touch(self, player_uuid: UUID, key: str, cooldown_seconds: int) -> Cooldown:
"""Touch cooldown (create or update)."""
expires_at = datetime.utcnow() + timedelta(seconds=cooldown_seconds)
stmt = insert(Cooldown).values(
key=key,
player_uuid=player_uuid,
cooldown_seconds=cooldown_seconds,
expires_at=expires_at
)
stmt = stmt.on_conflict_do_update(
index_elements=['key', 'player_uuid'],
set_=dict(
cooldown_seconds=stmt.excluded.cooldown_seconds,
expires_at=stmt.excluded.expires_at
)
)
result = await self.session.execute(stmt)
await self.session.commit()
# Get the touched cooldown
stmt = select(Cooldown).where(
Cooldown.player_uuid == player_uuid,
Cooldown.key == key
)
result = await self.session.execute(stmt)
return result.scalar_one()
async def list_by_player(self, player_uuid: UUID) -> List[Cooldown]:
"""List active cooldowns by player."""
stmt = select(Cooldown).where(
Cooldown.player_uuid == player_uuid,
Cooldown.expires_at > func.now()
)
result = await self.session.execute(stmt)
return list(result.scalars().all())

View File

@ -0,0 +1,68 @@
"""Homes repository."""
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select, update, delete
from sqlalchemy.dialects.postgresql import insert
from typing import List, Optional
from uuid import UUID
from hubgw.models.home import Home
from hubgw.schemas.homes import HomeUpsertRequest, HomeGetRequest
class HomesRepository:
"""Homes repository for database operations."""
def __init__(self, session: AsyncSession):
self.session = session
async def upsert(self, request: HomeUpsertRequest) -> Home:
"""Upsert home."""
stmt = insert(Home).values(
player_uuid=request.player_uuid,
name=request.name,
world=request.world,
x=request.x,
y=request.y,
z=request.z,
yaw=request.yaw,
pitch=request.pitch,
is_public=request.is_public
)
stmt = stmt.on_conflict_do_update(
index_elements=['player_uuid', 'name'],
set_=dict(
world=stmt.excluded.world,
x=stmt.excluded.x,
y=stmt.excluded.y,
z=stmt.excluded.z,
yaw=stmt.excluded.yaw,
pitch=stmt.excluded.pitch,
is_public=stmt.excluded.is_public
)
)
result = await self.session.execute(stmt)
await self.session.commit()
# Get the upserted home
stmt = select(Home).where(
Home.player_uuid == request.player_uuid,
Home.name == request.name
)
result = await self.session.execute(stmt)
return result.scalar_one()
async def get_by_request(self, request: HomeGetRequest) -> Optional[Home]:
"""Get home by request."""
stmt = select(Home).where(
Home.player_uuid == request.player_uuid,
Home.name == request.name
)
result = await self.session.execute(stmt)
return result.scalar_one_or_none()
async def list_by_player(self, player_uuid: UUID) -> List[Home]:
"""List homes by player UUID."""
stmt = select(Home).where(Home.player_uuid == player_uuid)
result = await self.session.execute(stmt)
return list(result.scalars().all())

View File

@ -0,0 +1,37 @@
"""Kits repository."""
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select, func
from typing import Optional
from uuid import UUID
from hubgw.models.cooldown import Cooldown
class KitsRepository:
"""Kits repository for database operations."""
def __init__(self, session: AsyncSession):
self.session = session
async def check_cooldown(self, player_uuid: UUID, kit_name: str) -> Optional[Cooldown]:
"""Check if player has active cooldown for kit."""
stmt = select(Cooldown).where(
Cooldown.player_uuid == player_uuid,
Cooldown.key == f"kit_{kit_name}",
Cooldown.expires_at > func.now()
)
result = await self.session.execute(stmt)
return result.scalar_one_or_none()
async def create_cooldown(self, player_uuid: UUID, kit_name: str, cooldown_seconds: int) -> Cooldown:
"""Create cooldown for kit."""
cooldown = Cooldown(
key=f"kit_{kit_name}",
player_uuid=player_uuid,
cooldown_seconds=cooldown_seconds
)
self.session.add(cooldown)
await self.session.commit()
await self.session.refresh(cooldown)
return cooldown

View File

@ -0,0 +1,105 @@
"""Punishments repository."""
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select, insert, update, func
from typing import List, Optional
from uuid import UUID
from datetime import datetime
from hubgw.models.punishment import Punishment
from hubgw.schemas.punishments import PunishmentCreateRequest, PunishmentRevokeRequest, PunishmentQuery
class PunishmentsRepository:
"""Punishments repository for database operations."""
def __init__(self, session: AsyncSession):
self.session = session
async def create(self, request: PunishmentCreateRequest) -> Punishment:
"""Create punishment."""
punishment = Punishment(
player_uuid=request.player_uuid,
player_name=request.player_name,
punishment_type=request.punishment_type,
reason=request.reason,
staff_uuid=request.staff_uuid,
staff_name=request.staff_name,
created_at=datetime.utcnow(),
expires_at=request.expires_at
)
self.session.add(punishment)
await self.session.commit()
await self.session.refresh(punishment)
return punishment
async def revoke(self, request: PunishmentRevokeRequest) -> Optional[Punishment]:
"""Revoke punishment."""
stmt = select(Punishment).where(Punishment.id == request.punishment_id)
result = await self.session.execute(stmt)
punishment = result.scalar_one_or_none()
if not punishment:
return None
punishment.is_active = 0
punishment.revoked_at = datetime.utcnow()
punishment.revoked_by = request.revoked_by
punishment.revoked_reason = request.revoked_reason
await self.session.commit()
await self.session.refresh(punishment)
return punishment
async def query(self, query: PunishmentQuery) -> tuple[List[Punishment], int]:
"""Query punishments with filters and pagination."""
stmt = select(Punishment)
count_stmt = select(func.count(Punishment.id))
# Apply filters
if query.player_uuid:
stmt = stmt.where(Punishment.player_uuid == query.player_uuid)
count_stmt = count_stmt.where(Punishment.player_uuid == query.player_uuid)
if query.punishment_type:
stmt = stmt.where(Punishment.punishment_type == query.punishment_type)
count_stmt = count_stmt.where(Punishment.punishment_type == query.punishment_type)
if query.is_active is not None:
stmt = stmt.where(Punishment.is_active == (1 if query.is_active else 0))
count_stmt = count_stmt.where(Punishment.is_active == (1 if query.is_active else 0))
# Get total count
count_result = await self.session.execute(count_stmt)
total = count_result.scalar()
# Apply pagination
offset = (query.page - 1) * query.size
stmt = stmt.offset(offset).limit(query.size).order_by(Punishment.created_at.desc())
result = await self.session.execute(stmt)
punishments = list(result.scalars().all())
return punishments, total
async def get_active_ban(self, player_uuid: UUID) -> Optional[Punishment]:
"""Get active ban for player."""
stmt = select(Punishment).where(
Punishment.player_uuid == player_uuid,
Punishment.punishment_type == "ban",
Punishment.is_active == 1,
(Punishment.expires_at.is_(None)) | (Punishment.expires_at > datetime.utcnow())
)
result = await self.session.execute(stmt)
return result.scalar_one_or_none()
async def get_active_mute(self, player_uuid: UUID) -> Optional[Punishment]:
"""Get active mute for player."""
stmt = select(Punishment).where(
Punishment.player_uuid == player_uuid,
Punishment.punishment_type == "mute",
Punishment.is_active == 1,
(Punishment.expires_at.is_(None)) | (Punishment.expires_at > datetime.utcnow())
)
result = await self.session.execute(stmt)
return result.scalar_one_or_none()

View File

@ -0,0 +1,91 @@
"""Warps repository."""
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select, insert, update, delete, func
from typing import List, Optional
from uuid import UUID
from hubgw.models.warp import Warp
from hubgw.schemas.warps import WarpCreateRequest, WarpUpdateRequest, WarpDeleteRequest, WarpGetRequest, WarpListQuery
class WarpsRepository:
"""Warps repository for database operations."""
def __init__(self, session: AsyncSession):
self.session = session
async def create(self, request: WarpCreateRequest) -> Warp:
"""Create warp."""
warp = Warp(
name=request.name,
world=request.world,
x=request.x,
y=request.y,
z=request.z,
yaw=request.yaw,
pitch=request.pitch,
is_public=request.is_public,
description=request.description
)
self.session.add(warp)
await self.session.commit()
await self.session.refresh(warp)
return warp
async def update(self, request: WarpUpdateRequest) -> Optional[Warp]:
"""Update warp."""
stmt = select(Warp).where(Warp.name == request.name)
result = await self.session.execute(stmt)
warp = result.scalar_one_or_none()
if not warp:
return None
update_data = request.dict(exclude_unset=True, exclude={'name'})
for field, value in update_data.items():
setattr(warp, field, value)
await self.session.commit()
await self.session.refresh(warp)
return warp
async def delete(self, request: WarpDeleteRequest) -> bool:
"""Delete warp."""
stmt = delete(Warp).where(Warp.name == request.name)
result = await self.session.execute(stmt)
await self.session.commit()
return result.rowcount > 0
async def get_by_request(self, request: WarpGetRequest) -> Optional[Warp]:
"""Get warp by request."""
stmt = select(Warp).where(Warp.name == request.name)
result = await self.session.execute(stmt)
return result.scalar_one_or_none()
async def list(self, query: WarpListQuery) -> tuple[List[Warp], int]:
"""List warps with pagination."""
stmt = select(Warp)
count_stmt = select(func.count(Warp.id))
# Apply filters
if query.world:
stmt = stmt.where(Warp.world == query.world)
count_stmt = count_stmt.where(Warp.world == query.world)
if query.is_public is not None:
stmt = stmt.where(Warp.is_public == query.is_public)
count_stmt = count_stmt.where(Warp.is_public == query.is_public)
# Get total count
count_result = await self.session.execute(count_stmt)
total = count_result.scalar()
# Apply pagination
offset = (query.page - 1) * query.size
stmt = stmt.offset(offset).limit(query.size)
result = await self.session.execute(stmt)
warps = list(result.scalars().all())
return warps, total

View File

@ -0,0 +1,56 @@
"""Whitelist repository."""
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select, insert, delete, func
from typing import List, Optional
from uuid import UUID
from datetime import datetime
from hubgw.models.whitelist import WhitelistEntry
from hubgw.schemas.whitelist import WhitelistAddRequest, WhitelistRemoveRequest, WhitelistCheckRequest
class WhitelistRepository:
"""Whitelist repository for database operations."""
def __init__(self, session: AsyncSession):
self.session = session
async def add(self, request: WhitelistAddRequest) -> WhitelistEntry:
"""Add player to whitelist."""
entry = WhitelistEntry(
player_name=request.player_name,
player_uuid=request.player_uuid,
added_by=request.added_by,
added_at=datetime.utcnow(),
reason=request.reason
)
self.session.add(entry)
await self.session.commit()
await self.session.refresh(entry)
return entry
async def remove(self, request: WhitelistRemoveRequest) -> bool:
"""Remove player from whitelist."""
stmt = delete(WhitelistEntry).where(WhitelistEntry.player_name == request.player_name)
result = await self.session.execute(stmt)
await self.session.commit()
return result.rowcount > 0
async def check(self, request: WhitelistCheckRequest) -> Optional[WhitelistEntry]:
"""Check if player is whitelisted."""
stmt = select(WhitelistEntry).where(WhitelistEntry.player_name == request.player_name)
result = await self.session.execute(stmt)
return result.scalar_one_or_none()
async def list_all(self) -> List[WhitelistEntry]:
"""List all whitelist entries."""
stmt = select(WhitelistEntry).order_by(WhitelistEntry.player_name)
result = await self.session.execute(stmt)
return list(result.scalars().all())
async def count(self) -> int:
"""Get total whitelist count."""
stmt = select(func.count(WhitelistEntry.id))
result = await self.session.execute(stmt)
return result.scalar()

View File

@ -0,0 +1 @@
"""Pydantic schemas for hubgw."""

View File

@ -0,0 +1,23 @@
"""Audit schemas."""
from pydantic import BaseModel
from typing import List, Dict, Any
from datetime import datetime
from uuid import UUID
class CommandAuditRequest(BaseModel):
"""Command audit request schema."""
player_uuid: UUID
player_name: str
command: str
arguments: List[str]
server: str
timestamp: datetime
class CommandAuditResponse(BaseModel):
"""Command audit response schema."""
accepted: int

View File

@ -0,0 +1,42 @@
"""Common schemas."""
from pydantic import BaseModel
from typing import Optional
from datetime import datetime
from uuid import UUID
class BaseSchema(BaseModel):
"""Base schema with common fields."""
created_at: datetime
updated_at: datetime
class ErrorResponse(BaseModel):
"""Error response schema."""
message: str
code: str
details: Optional[dict] = None
class PaginationParams(BaseModel):
"""Pagination parameters."""
page: int = 1
size: int = 20
@property
def offset(self) -> int:
return (self.page - 1) * self.size
class PaginatedResponse(BaseModel):
"""Paginated response schema."""
items: list
total: int
page: int
size: int
pages: int

View File

@ -0,0 +1,38 @@
"""Cooldown schemas."""
from pydantic import BaseModel
from uuid import UUID
from datetime import datetime
from typing import Optional
from hubgw.schemas.common import BaseSchema
class CooldownKey(BaseModel):
"""Cooldown key schema."""
key: str
class CooldownCheckRequest(BaseModel):
"""Cooldown check request schema."""
player_uuid: UUID
key: str
class CooldownCheckResponse(BaseModel):
"""Cooldown check response schema."""
is_active: bool
expires_at: Optional[datetime] = None
remaining_seconds: Optional[int] = None
class Cooldown(BaseSchema):
"""Cooldown schema."""
id: UUID
key: str
player_uuid: UUID
expires_at: datetime
cooldown_seconds: int

View File

@ -0,0 +1,65 @@
"""Home schemas."""
from pydantic import BaseModel
from typing import Optional
from datetime import datetime
from uuid import UUID
from hubgw.schemas.common import BaseSchema
class HomeBase(BaseModel):
"""Base home schema."""
name: str
world: str
x: float
y: float
z: float
yaw: float = 0.0
pitch: float = 0.0
is_public: int = 0
class HomeCreate(HomeBase):
"""Home creation schema."""
player_uuid: UUID
class HomeUpdate(HomeBase):
"""Home update schema."""
pass
class HomeUpsertRequest(HomeBase):
"""Home upsert request schema."""
player_uuid: UUID
class Home(HomeBase, BaseSchema):
"""Home response schema."""
id: UUID
player_uuid: UUID
class HomeGetRequest(BaseModel):
"""Home get request schema."""
player_uuid: UUID
name: str
class HomeGetResponse(Home):
"""Home get response schema."""
pass
class HomeListResponse(BaseModel):
"""Home list response schema."""
homes: list[Home]
total: int

20
src/hubgw/schemas/kits.py Normal file
View File

@ -0,0 +1,20 @@
"""Kit schemas."""
from pydantic import BaseModel
from uuid import UUID
from typing import Optional
class KitClaimRequest(BaseModel):
"""Kit claim request schema."""
player_uuid: UUID
kit_name: str
class KitClaimResponse(BaseModel):
"""Kit claim response schema."""
success: bool
message: str
cooldown_remaining: Optional[int] = None

View File

@ -0,0 +1,78 @@
"""Punishment schemas."""
from pydantic import BaseModel
from typing import Optional
from datetime import datetime
from uuid import UUID
from hubgw.schemas.common import BaseSchema
class PunishmentCreateRequest(BaseModel):
"""Punishment create request schema."""
player_uuid: UUID
player_name: str
punishment_type: str # ban, warn, mute
reason: str
staff_uuid: UUID
staff_name: str
expires_at: Optional[datetime] = None # None for permanent
class PunishmentRevokeRequest(BaseModel):
"""Punishment revoke request schema."""
punishment_id: UUID
revoked_by: UUID
revoked_reason: str
class PunishmentQuery(BaseModel):
"""Punishment query schema."""
player_uuid: Optional[UUID] = None
punishment_type: Optional[str] = None
is_active: Optional[bool] = None
page: int = 1
size: int = 20
class PunishmentBase(BaseSchema):
"""Base punishment schema."""
id: UUID
player_uuid: UUID
player_name: str
punishment_type: str
reason: str
staff_uuid: UUID
staff_name: str
expires_at: Optional[datetime] = None
is_active: int
revoked_at: Optional[datetime] = None
revoked_by: Optional[UUID] = None
revoked_reason: Optional[str] = None
class PunishmentListResponse(BaseModel):
"""Punishment list response schema."""
punishments: list[PunishmentBase]
total: int
page: int
size: int
pages: int
class ActiveBanStatusResponse(BaseModel):
"""Active ban status response schema."""
is_banned: bool
punishment: Optional[PunishmentBase] = None
class ActiveMuteStatusResponse(BaseModel):
"""Active mute status response schema."""
is_muted: bool
punishment: Optional[PunishmentBase] = None

View File

@ -0,0 +1,84 @@
"""Warp schemas."""
from pydantic import BaseModel
from typing import Optional
from datetime import datetime
from uuid import UUID
from hubgw.schemas.common import BaseSchema
class WarpBase(BaseModel):
"""Base warp schema."""
name: str
world: str
x: float
y: float
z: float
yaw: float = 0.0
pitch: float = 0.0
is_public: int = 1
description: Optional[str] = None
class WarpCreateRequest(WarpBase):
"""Warp create request schema."""
pass
class WarpUpdateRequest(BaseModel):
"""Warp update request schema."""
name: str
world: Optional[str] = None
x: Optional[float] = None
y: Optional[float] = None
z: Optional[float] = None
yaw: Optional[float] = None
pitch: Optional[float] = None
is_public: Optional[int] = None
description: Optional[str] = None
class WarpDeleteRequest(BaseModel):
"""Warp delete request schema."""
name: str
class WarpGetRequest(BaseModel):
"""Warp get request schema."""
name: str
class Warp(WarpBase, BaseSchema):
"""Warp response schema."""
id: UUID
class WarpGetResponse(Warp):
"""Warp get response schema."""
pass
class WarpListQuery(BaseModel):
"""Warp list query schema."""
page: int = 1
size: int = 20
world: Optional[str] = None
is_public: Optional[int] = None
class WarpListResponse(BaseModel):
"""Warp list response schema."""
warps: list[Warp]
total: int
page: int
size: int
pages: int

View File

@ -0,0 +1,53 @@
"""Whitelist schemas."""
from pydantic import BaseModel
from typing import Optional
from datetime import datetime
from uuid import UUID
from hubgw.schemas.common import BaseSchema
class WhitelistAddRequest(BaseModel):
"""Whitelist add request schema."""
player_name: str
player_uuid: Optional[UUID] = None
added_by: str
reason: Optional[str] = None
class WhitelistRemoveRequest(BaseModel):
"""Whitelist remove request schema."""
player_name: str
class WhitelistCheckRequest(BaseModel):
"""Whitelist check request schema."""
player_name: str
class WhitelistCheckResponse(BaseModel):
"""Whitelist check response schema."""
is_whitelisted: bool
player_uuid: Optional[UUID] = None
class WhitelistEntry(BaseSchema):
"""Whitelist entry schema."""
id: UUID
player_name: str
player_uuid: Optional[UUID] = None
added_by: str
added_at: datetime
reason: Optional[str] = None
class WhitelistListResponse(BaseModel):
"""Whitelist list response schema."""
entries: list[WhitelistEntry]
total: int

View File

@ -0,0 +1 @@
"""Services for hubgw."""

View File

@ -0,0 +1,17 @@
"""Audit service."""
from sqlalchemy.ext.asyncio import AsyncSession
from hubgw.schemas.audit import CommandAuditRequest, CommandAuditResponse
class AuditService:
"""Audit service for business logic."""
def __init__(self, session: AsyncSession):
self.session = session
async def log_command(self, request: CommandAuditRequest) -> CommandAuditResponse:
"""Log command execution for audit."""
# In a real implementation, this would store the command in an audit table
# For now, we'll just return success
return CommandAuditResponse(accepted=1)

View File

@ -0,0 +1,34 @@
"""Cooldowns service."""
from sqlalchemy.ext.asyncio import AsyncSession
from uuid import UUID
from datetime import datetime
from hubgw.repositories.cooldowns_repo import CooldownsRepository
from hubgw.schemas.cooldowns import CooldownCheckRequest, CooldownCheckResponse, CooldownKey
class CooldownsService:
"""Cooldowns service for business logic."""
def __init__(self, session: AsyncSession):
self.repo = CooldownsRepository(session)
async def check_cooldown(self, request: CooldownCheckRequest) -> CooldownCheckResponse:
"""Check cooldown status."""
cooldown = await self.repo.check(request.player_uuid, request.key)
if not cooldown:
return CooldownCheckResponse(is_active=False)
remaining_seconds = int((cooldown.expires_at - datetime.utcnow()).total_seconds())
return CooldownCheckResponse(
is_active=remaining_seconds > 0,
expires_at=cooldown.expires_at,
remaining_seconds=max(0, remaining_seconds)
)
async def touch_cooldown(self, player_uuid: UUID, key: str, cooldown_seconds: int) -> None:
"""Touch cooldown (create or update)."""
await self.repo.touch(player_uuid, key, cooldown_seconds)

View File

@ -0,0 +1,65 @@
"""Homes service."""
from sqlalchemy.ext.asyncio import AsyncSession
from typing import List
from uuid import UUID
from hubgw.repositories.homes_repo import HomesRepository
from hubgw.schemas.homes import HomeUpsertRequest, HomeGetRequest, Home, HomeGetResponse, HomeListResponse
from hubgw.core.errors import NotFoundError
class HomesService:
"""Homes service for business logic."""
def __init__(self, session: AsyncSession):
self.repo = HomesRepository(session)
async def upsert_home(self, request: HomeUpsertRequest) -> Home:
"""Upsert home with business logic."""
return await self.repo.upsert(request)
async def get_home(self, request: HomeGetRequest) -> HomeGetResponse:
"""Get home with business logic."""
home = await self.repo.get_by_request(request)
if not home:
raise NotFoundError(f"Home '{request.name}' not found for player {request.player_uuid}")
return HomeGetResponse(
id=home.id,
player_uuid=home.player_uuid,
name=home.name,
world=home.world,
x=home.x,
y=home.y,
z=home.z,
yaw=home.yaw,
pitch=home.pitch,
is_public=home.is_public,
created_at=home.created_at,
updated_at=home.updated_at
)
async def list_homes(self, player_uuid: UUID) -> HomeListResponse:
"""List homes with business logic."""
homes = await self.repo.list_by_player(player_uuid)
home_list = [
Home(
id=home.id,
player_uuid=home.player_uuid,
name=home.name,
world=home.world,
x=home.x,
y=home.y,
z=home.z,
yaw=home.yaw,
pitch=home.pitch,
is_public=home.is_public,
created_at=home.created_at,
updated_at=home.updated_at
)
for home in homes
]
return HomeListResponse(homes=home_list, total=len(home_list))

View File

@ -0,0 +1,38 @@
"""Kits service."""
from sqlalchemy.ext.asyncio import AsyncSession
from uuid import UUID
from datetime import datetime, timedelta
from hubgw.repositories.kits_repo import KitsRepository
from hubgw.schemas.kits import KitClaimRequest, KitClaimResponse
from hubgw.core.errors import CooldownActiveError
class KitsService:
"""Kits service for business logic."""
def __init__(self, session: AsyncSession):
self.repo = KitsRepository(session)
async def claim_kit(self, request: KitClaimRequest) -> KitClaimResponse:
"""Claim kit with cooldown logic."""
# Check if player has active cooldown
cooldown = await self.repo.check_cooldown(request.player_uuid, request.kit_name)
if cooldown:
remaining_seconds = int((cooldown.expires_at - datetime.utcnow()).total_seconds())
if remaining_seconds > 0:
raise CooldownActiveError(
f"Kit '{request.kit_name}' is on cooldown for {remaining_seconds} seconds",
{"cooldown_remaining": remaining_seconds}
)
# Create cooldown (assuming 1 hour cooldown for all kits)
cooldown_seconds = 3600 # 1 hour
await self.repo.create_cooldown(request.player_uuid, request.kit_name, cooldown_seconds)
return KitClaimResponse(
success=True,
message=f"Kit '{request.kit_name}' claimed successfully"
)

View File

@ -0,0 +1,144 @@
"""Punishments service."""
from sqlalchemy.ext.asyncio import AsyncSession
from typing import List
from uuid import UUID
from hubgw.repositories.punishments_repo import PunishmentsRepository
from hubgw.schemas.punishments import (
PunishmentCreateRequest, PunishmentRevokeRequest, PunishmentQuery,
PunishmentBase, PunishmentListResponse, ActiveBanStatusResponse, ActiveMuteStatusResponse
)
from hubgw.core.errors import NotFoundError
class PunishmentsService:
"""Punishments service for business logic."""
def __init__(self, session: AsyncSession):
self.repo = PunishmentsRepository(session)
async def create_punishment(self, request: PunishmentCreateRequest) -> PunishmentBase:
"""Create punishment with business logic."""
punishment = await self.repo.create(request)
return PunishmentBase(
id=punishment.id,
player_uuid=punishment.player_uuid,
player_name=punishment.player_name,
punishment_type=punishment.punishment_type,
reason=punishment.reason,
staff_uuid=punishment.staff_uuid,
staff_name=punishment.staff_name,
created_at=punishment.created_at,
expires_at=punishment.expires_at,
is_active=punishment.is_active,
revoked_at=punishment.revoked_at,
revoked_by=punishment.revoked_by,
revoked_reason=punishment.revoked_reason
)
async def revoke_punishment(self, request: PunishmentRevokeRequest) -> PunishmentBase:
"""Revoke punishment with business logic."""
punishment = await self.repo.revoke(request)
if not punishment:
raise NotFoundError(f"Punishment {request.punishment_id} not found")
return PunishmentBase(
id=punishment.id,
player_uuid=punishment.player_uuid,
player_name=punishment.player_name,
punishment_type=punishment.punishment_type,
reason=punishment.reason,
staff_uuid=punishment.staff_uuid,
staff_name=punishment.staff_name,
created_at=punishment.created_at,
expires_at=punishment.expires_at,
is_active=punishment.is_active,
revoked_at=punishment.revoked_at,
revoked_by=punishment.revoked_by,
revoked_reason=punishment.revoked_reason
)
async def query_punishments(self, query: PunishmentQuery) -> PunishmentListResponse:
"""Query punishments with business logic."""
punishments, total = await self.repo.query(query)
punishment_list = [
PunishmentBase(
id=p.id,
player_uuid=p.player_uuid,
player_name=p.player_name,
punishment_type=p.punishment_type,
reason=p.reason,
staff_uuid=p.staff_uuid,
staff_name=p.staff_name,
created_at=p.created_at,
expires_at=p.expires_at,
is_active=p.is_active,
revoked_at=p.revoked_at,
revoked_by=p.revoked_by,
revoked_reason=p.revoked_reason
)
for p in punishments
]
pages = (total + query.size - 1) // query.size
return PunishmentListResponse(
punishments=punishment_list,
total=total,
page=query.page,
size=query.size,
pages=pages
)
async def get_active_ban_status(self, player_uuid: UUID) -> ActiveBanStatusResponse:
"""Get active ban status for player."""
ban = await self.repo.get_active_ban(player_uuid)
if not ban:
return ActiveBanStatusResponse(is_banned=False)
punishment = PunishmentBase(
id=ban.id,
player_uuid=ban.player_uuid,
player_name=ban.player_name,
punishment_type=ban.punishment_type,
reason=ban.reason,
staff_uuid=ban.staff_uuid,
staff_name=ban.staff_name,
created_at=ban.created_at,
expires_at=ban.expires_at,
is_active=ban.is_active,
revoked_at=ban.revoked_at,
revoked_by=ban.revoked_by,
revoked_reason=ban.revoked_reason
)
return ActiveBanStatusResponse(is_banned=True, punishment=punishment)
async def get_active_mute_status(self, player_uuid: UUID) -> ActiveMuteStatusResponse:
"""Get active mute status for player."""
mute = await self.repo.get_active_mute(player_uuid)
if not mute:
return ActiveMuteStatusResponse(is_muted=False)
punishment = PunishmentBase(
id=mute.id,
player_uuid=mute.player_uuid,
player_name=mute.player_name,
punishment_type=mute.punishment_type,
reason=mute.reason,
staff_uuid=mute.staff_uuid,
staff_name=mute.staff_name,
created_at=mute.created_at,
expires_at=mute.expires_at,
is_active=mute.is_active,
revoked_at=mute.revoked_at,
revoked_by=mute.revoked_by,
revoked_reason=mute.revoked_reason
)
return ActiveMuteStatusResponse(is_muted=True, punishment=punishment)

View File

@ -0,0 +1,94 @@
"""Warps service."""
from sqlalchemy.ext.asyncio import AsyncSession
from typing import List
from hubgw.repositories.warps_repo import WarpsRepository
from hubgw.schemas.warps import (
WarpCreateRequest, WarpUpdateRequest, WarpDeleteRequest, WarpGetRequest,
Warp, WarpGetResponse, WarpListQuery, WarpListResponse
)
from hubgw.core.errors import NotFoundError, AlreadyExistsError
class WarpsService:
"""Warps service for business logic."""
def __init__(self, session: AsyncSession):
self.repo = WarpsRepository(session)
async def create_warp(self, request: WarpCreateRequest) -> Warp:
"""Create warp with business logic."""
# Check if warp with same name already exists
existing = await self.repo.get_by_request(WarpGetRequest(name=request.name))
if existing:
raise AlreadyExistsError(f"Warp '{request.name}' already exists")
return await self.repo.create(request)
async def update_warp(self, request: WarpUpdateRequest) -> Warp:
"""Update warp with business logic."""
warp = await self.repo.update(request)
if not warp:
raise NotFoundError(f"Warp '{request.name}' not found")
return warp
async def delete_warp(self, request: WarpDeleteRequest) -> None:
"""Delete warp with business logic."""
success = await self.repo.delete(request)
if not success:
raise NotFoundError(f"Warp '{request.name}' not found")
async def get_warp(self, request: WarpGetRequest) -> WarpGetResponse:
"""Get warp with business logic."""
warp = await self.repo.get_by_request(request)
if not warp:
raise NotFoundError(f"Warp '{request.name}' not found")
return WarpGetResponse(
id=warp.id,
name=warp.name,
world=warp.world,
x=warp.x,
y=warp.y,
z=warp.z,
yaw=warp.yaw,
pitch=warp.pitch,
is_public=warp.is_public,
description=warp.description,
created_at=warp.created_at,
updated_at=warp.updated_at
)
async def list_warps(self, query: WarpListQuery) -> WarpListResponse:
"""List warps with business logic."""
warps, total = await self.repo.list(query)
warp_list = [
Warp(
id=warp.id,
name=warp.name,
world=warp.world,
x=warp.x,
y=warp.y,
z=warp.z,
yaw=warp.yaw,
pitch=warp.pitch,
is_public=warp.is_public,
description=warp.description,
created_at=warp.created_at,
updated_at=warp.updated_at
)
for warp in warps
]
pages = (total + query.size - 1) // query.size
return WarpListResponse(
warps=warp_list,
total=total,
page=query.page,
size=query.size,
pages=pages
)

View File

@ -0,0 +1,63 @@
"""Whitelist service."""
from sqlalchemy.ext.asyncio import AsyncSession
from typing import List
from hubgw.repositories.whitelist_repo import WhitelistRepository
from hubgw.schemas.whitelist import (
WhitelistAddRequest, WhitelistRemoveRequest, WhitelistCheckRequest,
WhitelistEntry, WhitelistCheckResponse, WhitelistListResponse
)
from hubgw.core.errors import AlreadyExistsError, NotFoundError
class WhitelistService:
"""Whitelist service for business logic."""
def __init__(self, session: AsyncSession):
self.repo = WhitelistRepository(session)
async def add_player(self, request: WhitelistAddRequest) -> WhitelistEntry:
"""Add player to whitelist with business logic."""
# Check if player is already whitelisted
existing = await self.repo.check(WhitelistCheckRequest(player_name=request.player_name))
if existing:
raise AlreadyExistsError(f"Player '{request.player_name}' is already whitelisted")
return await self.repo.add(request)
async def remove_player(self, request: WhitelistRemoveRequest) -> None:
"""Remove player from whitelist with business logic."""
success = await self.repo.remove(request)
if not success:
raise NotFoundError(f"Player '{request.player_name}' not found in whitelist")
async def check_player(self, request: WhitelistCheckRequest) -> WhitelistCheckResponse:
"""Check if player is whitelisted."""
entry = await self.repo.check(request)
return WhitelistCheckResponse(
is_whitelisted=entry is not None,
player_uuid=entry.player_uuid if entry else None
)
async def list_players(self) -> WhitelistListResponse:
"""List all whitelisted players."""
entries = await self.repo.list_all()
total = await self.repo.count()
entry_list = [
WhitelistEntry(
id=entry.id,
player_name=entry.player_name,
player_uuid=entry.player_uuid,
added_by=entry.added_by,
added_at=entry.added_at,
reason=entry.reason,
created_at=entry.created_at,
updated_at=entry.updated_at
)
for entry in entries
]
return WhitelistListResponse(entries=entry_list, total=total)

View File

@ -0,0 +1 @@
"""Utility functions for hubgw."""

View File

@ -0,0 +1,37 @@
"""Pagination utility functions."""
from typing import List, TypeVar, Generic
from math import ceil
T = TypeVar('T')
class PaginatedResult(Generic[T]):
"""Paginated result container."""
def __init__(self, items: List[T], total: int, page: int, size: int):
self.items = items
self.total = total
self.page = page
self.size = size
self.pages = ceil(total / size) if size > 0 else 0
def to_dict(self) -> dict:
"""Convert to dictionary."""
return {
"items": self.items,
"total": self.total,
"page": self.page,
"size": self.size,
"pages": self.pages
}
def calculate_offset(page: int, size: int) -> int:
"""Calculate offset for pagination."""
return (page - 1) * size
def calculate_pages(total: int, size: int) -> int:
"""Calculate total pages."""
return ceil(total / size) if size > 0 else 0

24
src/hubgw/utils/time.py Normal file
View File

@ -0,0 +1,24 @@
"""Time utility functions."""
from datetime import datetime, timezone
from typing import Optional
def utc_now() -> datetime:
"""Get current UTC datetime."""
return datetime.now(timezone.utc)
def is_expired(expires_at: Optional[datetime]) -> bool:
"""Check if datetime is expired."""
if expires_at is None:
return False
return expires_at < utc_now()
def seconds_until(expires_at: Optional[datetime]) -> int:
"""Get seconds until datetime expires."""
if expires_at is None:
return 0
delta = expires_at - utc_now()
return max(0, int(delta.total_seconds()))

25
src/hubgw/utils/uuid.py Normal file
View File

@ -0,0 +1,25 @@
"""UUID utility functions."""
import uuid
from typing import Union
def generate_uuid() -> str:
"""Generate a new UUID string."""
return str(uuid.uuid4())
def is_valid_uuid(uuid_string: str) -> bool:
"""Check if string is a valid UUID."""
try:
uuid.UUID(uuid_string)
return True
except ValueError:
return False
def parse_uuid(uuid_string: Union[str, uuid.UUID]) -> uuid.UUID:
"""Parse UUID from string or return UUID object."""
if isinstance(uuid_string, uuid.UUID):
return uuid_string
return uuid.UUID(uuid_string)

1
tests/__init__.py Normal file
View File

@ -0,0 +1 @@
"""Tests for hubgw."""

54
tests/conftest.py Normal file
View File

@ -0,0 +1,54 @@
"""Pytest configuration and fixtures."""
import pytest
import asyncio
from sqlalchemy.ext.asyncio import AsyncSession, create_async_engine, async_sessionmaker
from fastapi.testclient import TestClient
from hubgw.main import create_app
from hubgw.context import AppContext
from hubgw.core.config import AppSettings
@pytest.fixture(scope="session")
def event_loop():
"""Create event loop for async tests."""
loop = asyncio.get_event_loop_policy().new_event_loop()
yield loop
loop.close()
@pytest.fixture
async def test_db():
"""Create test database engine."""
settings = AppSettings()
settings.DB_DSN = "postgresql+asyncpg://test:test@localhost:5432/hubgw_test"
engine = create_async_engine(settings.DB_DSN)
session_factory = async_sessionmaker(engine, expire_on_commit=False)
yield engine, session_factory
await engine.dispose()
@pytest.fixture
async def test_session(test_db):
"""Create test database session."""
engine, session_factory = test_db
async with session_factory() as session:
yield session
@pytest.fixture
def test_client():
"""Create test client."""
app = create_app()
return TestClient(app)
@pytest.fixture
def test_context():
"""Create test context."""
return AppContext()

View File

@ -0,0 +1,19 @@
"""Tests for audit endpoints."""
import pytest
from fastapi.testclient import TestClient
from uuid import uuid4
from datetime import datetime
def test_log_command_unauthorized(test_client: TestClient):
"""Test log command without API key."""
response = test_client.post("/api/v1/audit/commands", json={
"player_uuid": str(uuid4()),
"player_name": "test_player",
"command": "tp",
"arguments": ["player2"],
"server": "hub",
"timestamp": datetime.utcnow().isoformat()
})
assert response.status_code == 401

View File

@ -0,0 +1,22 @@
"""Tests for cooldowns endpoints."""
import pytest
from fastapi.testclient import TestClient
from uuid import uuid4
def test_check_cooldown_unauthorized(test_client: TestClient):
"""Test check cooldown without API key."""
response = test_client.post("/api/v1/cooldowns/check", json={
"player_uuid": str(uuid4()),
"key": "test_key"
})
assert response.status_code == 401
def test_touch_cooldown_unauthorized(test_client: TestClient):
"""Test touch cooldown without API key."""
response = test_client.put("/api/v1/cooldowns/touch?seconds=60", json={
"key": "test_key"
})
assert response.status_code == 401

View File

@ -0,0 +1,11 @@
"""Tests for health endpoints."""
import pytest
from fastapi.testclient import TestClient
def test_health_check(test_client: TestClient):
"""Test health check endpoint."""
response = test_client.get("/api/v1/health/")
assert response.status_code == 200
assert response.json() == {"status": "ok"}

View File

@ -0,0 +1,33 @@
"""Tests for homes endpoints."""
import pytest
from fastapi.testclient import TestClient
from uuid import uuid4
def test_upsert_home_unauthorized(test_client: TestClient):
"""Test upsert home without API key."""
response = test_client.put("/api/v1/homes/", json={
"player_uuid": str(uuid4()),
"name": "test_home",
"world": "world",
"x": 0.0,
"y": 64.0,
"z": 0.0
})
assert response.status_code == 401
def test_get_home_unauthorized(test_client: TestClient):
"""Test get home without API key."""
response = test_client.post("/api/v1/homes/get", json={
"player_uuid": str(uuid4()),
"name": "test_home"
})
assert response.status_code == 401
def test_list_homes_unauthorized(test_client: TestClient):
"""Test list homes without API key."""
response = test_client.get(f"/api/v1/homes/{uuid4()}")
assert response.status_code == 401

14
tests/test_api_v1_kits.py Normal file
View File

@ -0,0 +1,14 @@
"""Tests for kits endpoints."""
import pytest
from fastapi.testclient import TestClient
from uuid import uuid4
def test_claim_kit_unauthorized(test_client: TestClient):
"""Test claim kit without API key."""
response = test_client.post("/api/v1/kits/claim", json={
"player_uuid": str(uuid4()),
"kit_name": "starter"
})
assert response.status_code == 401

View File

@ -0,0 +1,49 @@
"""Tests for punishments endpoints."""
import pytest
from fastapi.testclient import TestClient
from uuid import uuid4
def test_create_punishment_unauthorized(test_client: TestClient):
"""Test create punishment without API key."""
response = test_client.post("/api/v1/punishments/", json={
"player_uuid": str(uuid4()),
"player_name": "test_player",
"punishment_type": "ban",
"reason": "Test ban",
"staff_uuid": str(uuid4()),
"staff_name": "admin"
})
assert response.status_code == 401
def test_revoke_punishment_unauthorized(test_client: TestClient):
"""Test revoke punishment without API key."""
response = test_client.post("/api/v1/punishments/revoke", json={
"punishment_id": str(uuid4()),
"revoked_by": str(uuid4()),
"revoked_reason": "Test revoke"
})
assert response.status_code == 401
def test_query_punishments_unauthorized(test_client: TestClient):
"""Test query punishments without API key."""
response = test_client.post("/api/v1/punishments/query", json={
"page": 1,
"size": 20
})
assert response.status_code == 401
def test_get_ban_status_unauthorized(test_client: TestClient):
"""Test get ban status without API key."""
response = test_client.get(f"/api/v1/punishments/ban/{uuid4()}")
assert response.status_code == 401
def test_get_mute_status_unauthorized(test_client: TestClient):
"""Test get mute status without API key."""
response = test_client.get(f"/api/v1/punishments/mute/{uuid4()}")
assert response.status_code == 401

View File

@ -0,0 +1,50 @@
"""Tests for warps endpoints."""
import pytest
from fastapi.testclient import TestClient
def test_create_warp_unauthorized(test_client: TestClient):
"""Test create warp without API key."""
response = test_client.post("/api/v1/warps/", json={
"name": "test_warp",
"world": "world",
"x": 0.0,
"y": 64.0,
"z": 0.0
})
assert response.status_code == 401
def test_update_warp_unauthorized(test_client: TestClient):
"""Test update warp without API key."""
response = test_client.patch("/api/v1/warps/", json={
"name": "test_warp",
"world": "world_nether"
})
assert response.status_code == 401
def test_delete_warp_unauthorized(test_client: TestClient):
"""Test delete warp without API key."""
response = test_client.delete("/api/v1/warps/", json={
"name": "test_warp"
})
assert response.status_code == 401
def test_get_warp_unauthorized(test_client: TestClient):
"""Test get warp without API key."""
response = test_client.post("/api/v1/warps/get", json={
"name": "test_warp"
})
assert response.status_code == 401
def test_list_warps_unauthorized(test_client: TestClient):
"""Test list warps without API key."""
response = test_client.post("/api/v1/warps/list", json={
"page": 1,
"size": 20
})
assert response.status_code == 401

View File

@ -0,0 +1,37 @@
"""Tests for whitelist endpoints."""
import pytest
from fastapi.testclient import TestClient
from uuid import uuid4
def test_add_player_unauthorized(test_client: TestClient):
"""Test add player without API key."""
response = test_client.post("/api/v1/whitelist/add", json={
"player_name": "test_player",
"player_uuid": str(uuid4()),
"added_by": "admin"
})
assert response.status_code == 401
def test_remove_player_unauthorized(test_client: TestClient):
"""Test remove player without API key."""
response = test_client.post("/api/v1/whitelist/remove", json={
"player_name": "test_player"
})
assert response.status_code == 401
def test_check_player_unauthorized(test_client: TestClient):
"""Test check player without API key."""
response = test_client.post("/api/v1/whitelist/check", json={
"player_name": "test_player"
})
assert response.status_code == 401
def test_list_players_unauthorized(test_client: TestClient):
"""Test list players without API key."""
response = test_client.get("/api/v1/whitelist/")
assert response.status_code == 401

48
tests/test_repos_homes.py Normal file
View File

@ -0,0 +1,48 @@
"""Tests for homes repository."""
import pytest
from uuid import uuid4
from hubgw.repositories.homes_repo import HomesRepository
from hubgw.schemas.homes import HomeUpsertRequest, HomeGetRequest
@pytest.mark.asyncio
async def test_upsert_home(test_session):
"""Test upsert home."""
repo = HomesRepository(test_session)
request = HomeUpsertRequest(
player_uuid=uuid4(),
name="test_home",
world="world",
x=0.0,
y=64.0,
z=0.0
)
home = await repo.upsert(request)
assert home.name == "test_home"
assert home.player_uuid == request.player_uuid
@pytest.mark.asyncio
async def test_get_home_not_found(test_session):
"""Test get home when not found."""
repo = HomesRepository(test_session)
request = HomeGetRequest(
player_uuid=uuid4(),
name="nonexistent_home"
)
home = await repo.get_by_request(request)
assert home is None
@pytest.mark.asyncio
async def test_list_homes_empty(test_session):
"""Test list homes when empty."""
repo = HomesRepository(test_session)
homes = await repo.list_by_player(uuid4())
assert homes == []

View File

@ -0,0 +1,56 @@
"""Tests for punishments repository."""
import pytest
from uuid import uuid4
from datetime import datetime
from hubgw.repositories.punishments_repo import PunishmentsRepository
from hubgw.schemas.punishments import PunishmentCreateRequest, PunishmentRevokeRequest, PunishmentQuery
@pytest.mark.asyncio
async def test_create_punishment(test_session):
"""Test create punishment."""
repo = PunishmentsRepository(test_session)
request = PunishmentCreateRequest(
player_uuid=uuid4(),
player_name="test_player",
punishment_type="ban",
reason="Test ban",
staff_uuid=uuid4(),
staff_name="admin"
)
punishment = await repo.create(request)
assert punishment.player_name == "test_player"
assert punishment.punishment_type == "ban"
@pytest.mark.asyncio
async def test_query_punishments_empty(test_session):
"""Test query punishments when empty."""
repo = PunishmentsRepository(test_session)
query = PunishmentQuery(page=1, size=20)
punishments, total = await repo.query(query)
assert punishments == []
assert total == 0
@pytest.mark.asyncio
async def test_get_active_ban_not_found(test_session):
"""Test get active ban when not found."""
repo = PunishmentsRepository(test_session)
ban = await repo.get_active_ban(uuid4())
assert ban is None
@pytest.mark.asyncio
async def test_get_active_mute_not_found(test_session):
"""Test get active mute when not found."""
repo = PunishmentsRepository(test_session)
mute = await repo.get_active_mute(uuid4())
assert mute is None

46
tests/test_repos_warps.py Normal file
View File

@ -0,0 +1,46 @@
"""Tests for warps repository."""
import pytest
from hubgw.repositories.warps_repo import WarpsRepository
from hubgw.schemas.warps import WarpCreateRequest, WarpUpdateRequest, WarpDeleteRequest, WarpGetRequest, WarpListQuery
@pytest.mark.asyncio
async def test_create_warp(test_session):
"""Test create warp."""
repo = WarpsRepository(test_session)
request = WarpCreateRequest(
name="test_warp",
world="world",
x=0.0,
y=64.0,
z=0.0
)
warp = await repo.create(request)
assert warp.name == "test_warp"
assert warp.world == "world"
@pytest.mark.asyncio
async def test_get_warp_not_found(test_session):
"""Test get warp when not found."""
repo = WarpsRepository(test_session)
request = WarpGetRequest(name="nonexistent_warp")
warp = await repo.get_by_request(request)
assert warp is None
@pytest.mark.asyncio
async def test_list_warps_empty(test_session):
"""Test list warps when empty."""
repo = WarpsRepository(test_session)
query = WarpListQuery(page=1, size=20)
warps, total = await repo.list(query)
assert warps == []
assert total == 0

View File

@ -0,0 +1,42 @@
"""Tests for whitelist repository."""
import pytest
from uuid import uuid4
from hubgw.repositories.whitelist_repo import WhitelistRepository
from hubgw.schemas.whitelist import WhitelistAddRequest, WhitelistRemoveRequest, WhitelistCheckRequest
@pytest.mark.asyncio
async def test_add_player(test_session):
"""Test add player to whitelist."""
repo = WhitelistRepository(test_session)
request = WhitelistAddRequest(
player_name="test_player",
player_uuid=uuid4(),
added_by="admin"
)
entry = await repo.add(request)
assert entry.player_name == "test_player"
assert entry.added_by == "admin"
@pytest.mark.asyncio
async def test_check_player_not_found(test_session):
"""Test check player when not found."""
repo = WhitelistRepository(test_session)
request = WhitelistCheckRequest(player_name="nonexistent_player")
entry = await repo.check(request)
assert entry is None
@pytest.mark.asyncio
async def test_list_players_empty(test_session):
"""Test list players when empty."""
repo = WhitelistRepository(test_session)
entries = await repo.list_all()
assert entries == []