diff --git a/.github/dependabot.yml b/.github/dependabot.yml index 2039673..d27ad5b 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -24,6 +24,12 @@ updates: # springdoc 3.x requires Spring Boot 4 (not planned), so its majors cannot pass CI. - dependency-name: org.springdoc:springdoc-openapi-starter-webmvc-ui update-types: ["version-update:semver-major"] + - dependency-name: org.springdoc:springdoc-openapi-starter-webflux-ui + update-types: ["version-update:semver-major"] + # Spring Cloud minor trains switch Spring Boot majors (CI: NoClassDefFoundError from a + # Boot 4 package) - bumped manually together with a Boot migration. + - dependency-name: org.springframework.cloud:spring-cloud-dependencies + update-types: ["version-update:semver-major", "version-update:semver-minor"] - package-ecosystem: maven directory: /services/user-service @@ -86,6 +92,10 @@ updates: # protobuf majors must move in lockstep with the grpcio toolchain - bumped manually. - dependency-name: protobuf update-types: ["version-update:semver-major"] + # grpcio minors regenerate gencode against protobuf 7 while the runtime stays on 6 + # (CI: protobuf Gencode/Runtime VersionError) - the whole stack is bumped together, manually. + - dependency-name: "grpcio*" + update-types: ["version-update:semver-major", "version-update:semver-minor"] # The engine depends on exact py-draughts behavior (even patch bumps break rule tests) - # bumped manually with a full test review. - dependency-name: py-draughts @@ -109,8 +119,9 @@ updates: # Java 21 LTS is the project's deliberate target; a new Java major/minor is a manual decision. - dependency-name: eclipse-temurin update-types: ["version-update:semver-major", "version-update:semver-minor"] + # The builder tag pins the JDK in its suffix (3.9-eclipse-temurin-21); semver on the + # Maven version cannot see a JDK change, so this image is bumped manually. - dependency-name: maven - update-types: ["version-update:semver-major", "version-update:semver-minor"] - package-ecosystem: docker directory: /services/user-service @@ -120,8 +131,9 @@ updates: ignore: - dependency-name: eclipse-temurin update-types: ["version-update:semver-major", "version-update:semver-minor"] + # The builder tag pins the JDK in its suffix (3.9-eclipse-temurin-21); semver on the + # Maven version cannot see a JDK change, so this image is bumped manually. - dependency-name: maven - update-types: ["version-update:semver-major", "version-update:semver-minor"] - package-ecosystem: docker directory: /services/game-service @@ -131,8 +143,9 @@ updates: ignore: - dependency-name: eclipse-temurin update-types: ["version-update:semver-major", "version-update:semver-minor"] + # The builder tag pins the JDK in its suffix (3.9-eclipse-temurin-21); semver on the + # Maven version cannot see a JDK change, so this image is bumped manually. - dependency-name: maven - update-types: ["version-update:semver-major", "version-update:semver-minor"] - package-ecosystem: docker directory: /services/matchmaking-service @@ -142,8 +155,9 @@ updates: ignore: - dependency-name: eclipse-temurin update-types: ["version-update:semver-major", "version-update:semver-minor"] + # The builder tag pins the JDK in its suffix (3.9-eclipse-temurin-21); semver on the + # Maven version cannot see a JDK change, so this image is bumped manually. - dependency-name: maven - update-types: ["version-update:semver-major", "version-update:semver-minor"] - package-ecosystem: docker directory: /services/game-engine-service diff --git a/.github/workflows/protobuf-contracts.yml b/.github/workflows/protobuf-contracts.yml index a105363..fb244be 100644 --- a/.github/workflows/protobuf-contracts.yml +++ b/.github/workflows/protobuf-contracts.yml @@ -15,6 +15,8 @@ on: - "services/matchmaking-service/src/**" - "services/matchmaking-service/pom.xml" - "services/game-engine-service/**" + - "services/*/Dockerfile" + - ".dockerignore" - ".github/workflows/protobuf-contracts.yml" pull_request: branches: [dev, main] @@ -30,6 +32,8 @@ on: - "services/matchmaking-service/src/**" - "services/matchmaking-service/pom.xml" - "services/game-engine-service/**" + - "services/*/Dockerfile" + - ".dockerignore" - ".github/workflows/protobuf-contracts.yml" concurrency: @@ -86,8 +90,44 @@ jobs: python -m pip install --disable-pip-version-check check-jsonschema check-jsonschema --check-metaschema \ checkers-contracts/http/v1/api-error.schema.json \ + checkers-contracts/auth/v1/contract.schema.json \ checkers-contracts/auth/v1/access-token-claims.schema.json \ - checkers-contracts/auth/v1/refresh-token-claims.schema.json + checkers-contracts/auth/v1/refresh-token-claims.schema.json \ + checkers-contracts/ws/v1/game-state.schema.json \ + checkers-contracts/ws/v1/match-found.schema.json + check-jsonschema \ + --schemafile checkers-contracts/auth/v1/contract.schema.json \ + checkers-contracts/auth/v1/contract.json + python - <<'PY' + import base64 + import json + import os + from pathlib import Path + + vectors = json.loads( + Path("checkers-contracts/auth/v1/test-vectors.json").read_text(encoding="utf-8") + ) + encoded_payload = vectors["accessToken"].split(".")[1] + padding = "=" * (-len(encoded_payload) % 4) + token_claims = json.loads(base64.urlsafe_b64decode(encoded_payload + padding)) + if token_claims != vectors["accessClaims"]: + raise SystemExit("accessClaims does not match the compact accessToken payload") + + output = Path(os.environ["RUNNER_TEMP"]) / "auth-claim-instances" + output.mkdir() + (output / "access.json").write_text( + json.dumps(vectors["accessClaims"]), encoding="utf-8" + ) + (output / "refresh.json").write_text( + json.dumps(vectors["refreshClaims"]), encoding="utf-8" + ) + PY + check-jsonschema \ + --schemafile checkers-contracts/auth/v1/access-token-claims.schema.json \ + "$RUNNER_TEMP/auth-claim-instances/access.json" + check-jsonschema \ + --schemafile checkers-contracts/auth/v1/refresh-token-claims.schema.json \ + "$RUNNER_TEMP/auth-claim-instances/refresh.json" - name: Reject breaking changes on pull requests if: github.event_name == 'pull_request' # Same baseline guard as the push variant below: a PR whose base predates the versioned @@ -109,6 +149,65 @@ jobs: echo "Establishing the initial checkers.v1 compatibility baseline." fi + openapi-contracts: + name: OpenAPI snapshots and compatibility + runs-on: ubuntu-latest + env: + BASE_SHA: ${{ github.event_name == 'pull_request' && github.event.pull_request.base.sha || github.event.before }} + steps: + - uses: actions/checkout@v7 + with: + fetch-depth: 0 + - uses: actions/setup-java@v5 + with: + distribution: temurin + java-version: "21" + cache: maven + - name: Capture the REST contract baseline + run: | + mkdir -p "$RUNNER_TEMP/openapi-base" + if git cat-file -e "$BASE_SHA:checkers-contracts/openapi/user-service.json"; then + for service in user-service game-service matchmaking-service; do + git show "$BASE_SHA:checkers-contracts/openapi/$service.json" \ + > "$RUNNER_TEMP/openapi-base/$service.json" + done + echo "OPENAPI_BASELINE=true" >> "$GITHUB_ENV" + else + echo "OPENAPI_BASELINE=false" >> "$GITHUB_ENV" + echo "Establishing the initial REST compatibility baseline." + fi + - name: Regenerate OpenAPI snapshots + run: | + bash services/user-service/mvnw --batch-mode \ + -f services/user-service/pom.xml -Dtest=OpenApiSnapshotTest test + bash services/game-service/mvnw --batch-mode \ + -f services/game-service/pom.xml '-Dtest=GameControllerMockMvcTest#snapshotsOpenApiContract' test + bash services/matchmaking-service/mvnw --batch-mode \ + -f services/matchmaking-service/pom.xml '-Dtest=QueueControllerMockMvcTest#snapshotsOpenApiContract' test + - name: Reject snapshot drift + run: git diff --exit-code -- checkers-contracts/openapi + - name: Reject breaking REST changes + if: env.OPENAPI_BASELINE == 'true' + # Escape hatch for deliberate spec corrections (e.g. a springdoc upgrade starts emitting + # `required` that Bean Validation always enforced): the openapi-breaking-approved label on + # a PR, or an [openapi-rebaseline] marker in a pushed commit message, downgrades the check + # to report-only for that run. The diff is still printed for review. + env: + BREAKING_APPROVED: ${{ (github.event_name == 'pull_request' && contains(github.event.pull_request.labels.*.name, 'openapi-breaking-approved')) || (github.event_name == 'push' && contains(github.event.head_commit.message, '[openapi-rebaseline]')) }} + run: | + FAIL_FLAG="--fail-on-incompatible" + if [ "$BREAKING_APPROVED" = "true" ]; then + echo "Breaking-change rejection disabled for this run (approved spec correction)." + FAIL_FLAG="" + fi + for service in user-service game-service matchmaking-service; do + docker run --rm \ + -v "$RUNNER_TEMP/openapi-base:/baseline:ro" \ + -v "$PWD/checkers-contracts/openapi:/current:ro" \ + openapitools/openapi-diff:2.1.0 \ + $FAIL_FLAG "/baseline/$service.json" "/current/$service.json" + done + java-services: name: Java build & tests (${{ matrix.service }}) runs-on: ubuntu-latest diff --git a/README.md b/README.md index 5b3573b..788ced1 100644 --- a/README.md +++ b/README.md @@ -1,306 +1,63 @@ # Checkers -Platforma do gry w warcaby online. +[![CI](https://github.com/TexablePlum/Checkers/actions/workflows/protobuf-contracts.yml/badge.svg?branch=dev)](https://github.com/TexablePlum/Checkers/actions/workflows/protobuf-contracts.yml) +[![Latest release](https://img.shields.io/github/v/release/TexablePlum/Checkers)](https://github.com/TexablePlum/Checkers/releases) -## Serwisy +An online multiplayer draughts platform: Spring Boot microservices behind a single API gateway, +a Python rule engine spoken to over gRPC, real-time play over STOMP WebSockets, and versioned +platform contracts enforced in CI. -| Serwis | Technologia | Odpowiedzialność | Status | -|---|---|---|---| -| `game-engine-service` | Python + gRPC | silnik reguł, walidacja ruchów, AI | zaimplementowany | -| `user-service` | Java + Spring Boot | auth, JWT, profile, ELO | zaimplementowany | -| `game-service` | Java + Spring Boot | stan gry, tury, zegary, persystencja | zaimplementowany (MVP) | -| `matchmaking-service` | Java + Spring Boot | lobby, kolejki, parowanie PvP | zaimplementowany (MVP) | -| `api-gateway` | Java + Spring Cloud Gateway | routing HTTP/WS, edge auth, rate limiting, CORS, kontrakt błędów, zagregowany Swagger | zaimplementowany (MVP) | +## Architecture -`game-service` orkiestruje cykl życia partii (REST + push WebSocket), deleguje reguły do -`game-engine-service` (gRPC) i zgłasza wyniki PvP do `user-service` (`UpdateElo`). Szczegóły: -[services/game-service/README.md](services/game-service/README.md). - -`matchmaking-service` utrzymuje kolejki PvP per wariant i tryb czasowy, dobiera przeciwników po ELO -z rozszerzanym oknem, zleca `game-service` utworzenie wystartowanej partii przez gRPC -`CreateMatchedGame` i wypycha `match-found` przez WebSocket. Szczegóły: -[services/matchmaking-service/README.md](services/matchmaking-service/README.md). - -`api-gateway` jest jedynym publicznym wejściem platformy: routuje ruch HTTP i WebSocket (STOMP) klienta -do serwisów po prefiksie ścieżki, waliduje JWT na brzegu (HS256, pierwsza linia obrony — serwisy -walidują dalej jako backstop), rate-limituje `/api/v1/auth/**` per IP (fail-closed), jest jedynym -właścicielem CORS i zwraca spójny, wersjonowany kontrakt błędów `ApiError` v1. Jego źródłem prawdy -jest [`checkers-contracts/http/v1`](checkers-contracts/http/v1/README.md). Szczegóły: -[services/api-gateway/README.md](services/api-gateway/README.md). - -## Stack - -- **Backend:** Java 21, Spring Boot 3.x, Python 3.x -- **Realtime:** WebSocket (STOMP) -- **Bazy danych:** osobne PostgreSQL (`user-service`, archiwum `game-service`) + Redis (`game-service`) + dedykowany Redis (`matchmaking-service`) -- **Komunikacja:** REST (klient), gRPC (serwis-serwis), WebSocket (push stanu partii) -- **Infrastruktura:** Kubernetes (AKS), Docker +| Service | Tech | Responsibility | +|---|---|---| +| [`api-gateway`](services/api-gateway/README.md) | Spring Cloud Gateway | the only public entry point: HTTP/WS routing, edge JWT + token blacklist, rate limiting, CORS, aggregated Swagger | +| [`user-service`](services/user-service/README.md) | Spring Boot + PostgreSQL | accounts, JWT auth, OAuth2 (Google/Facebook), ELO ratings, audit log | +| [`game-service`](services/game-service/README.md) | Spring Boot + Redis + PostgreSQL | authoritative game state and clocks, turn flow, WebSocket push, durable game archive and history | +| [`matchmaking-service`](services/matchmaking-service/README.md) | Spring Boot + Redis | PvP queues and ELO-based pairing with a widening window | +| [`game-engine-service`](services/game-engine-service/README.md) | Python + gRPC | rules for 8 draughts variants, move validation, AI search | ---- +Clients speak REST and STOMP WebSocket to the gateway; services speak gRPC to each other over an +internal network with per-server auth tokens. Every boundary is a versioned contract in +[`checkers-contracts`](checkers-contracts/README.md) — protobuf (`checkers.v1`, Buf breaking-change +checks), JSON Schema for auth/WS/error payloads, and committed OpenAPI snapshots with REST +compatibility checks. Full design notes live in each service's `README.md`. -## Lokalne uruchomienie (single-instance) +## Running locally ```bash docker compose up --build ``` -Cały backend wstaje na jednej instancji każdego serwisu, na prawdziwej bazie: `user-service` leci na -profilu `prod` (PostgreSQL + migracje Flyway), a `game-service`/`matchmaking-service` na Redisie. -Compose uruchamia zależne kontenery dopiero po przejściu ich sond zdrowia. Trzy Redisy wymagają -jawnych, deweloperskich haseł zapisanych w `docker-compose.yaml`; nie są to dane do użycia na prod. +The whole backend starts single-instance on real stores (PostgreSQL + Flyway, password-protected +Redis), with health-checked startup ordering. The gateway on **`http://localhost:8083`** is the only +public entry point — service and gRPC ports stay on the internal network. Database ports +(`5432`, `5433`) and Redis ports (`6379`–`6381`) are published to the host purely for debugging. -**Jedynym publicznym wejściem jest `api-gateway` na `:8083`** — porty serwisów (8080/8081/8082) i gRPC -(9090/9091/50051) nie są publikowane, ruch chodzi tylko przez bramę po wewnętrznej sieci compose. -Wystawione na host pozostają jeszcze: PostgreSQL użytkowników (`:5432`), PostgreSQL historii gier -(`:5433`) i Redisy -(`:6379` gry, `:6380` matchmakingu, `:6381` `auth-redis` — rate limiting + token blacklist) — -wyłącznie dla wygody debugowania. - -**Dokumentacja API (Swagger)** jest zagregowana na bramie: +Aggregated API docs (Swagger UI for all three REST services, proxied through the gateway): ``` http://localhost:8083/swagger-ui.html ``` -Dropdown w prawym górnym rogu przełącza między `user-service`, `game-service` i `matchmaking-service` -(brama proxuje ich `/v3/api-docs`). „Try it out" leci przez bramę, więc działa mimo zamkniętych portów -— chronione endpointy autoryzuj przyciskiem **Authorize** (wklej `Bearer `). - ---- - -## Checklista przed wdrożeniem produkcyjnym - -> Wszystkie poniższe kroki muszą zostać wykonane zanim serwis będzie gotowy na ruch produkcyjny. -> Elementy oznaczone 🔴 są blokujące — bez nich aplikacja nie zadziała. - -### 1. Baza danych 🔴 - -Domyślnie `user-service` używa bazy H2 in-memory (profil `dev`). Na produkcji wymagany jest PostgreSQL. -Schemat zarządzany przez Flyway — migracje uruchamiają się automatycznie przy starcie. - -```bash -# Zmienne środowiskowe dla user-service (profil prod) -SPRING_PROFILES_ACTIVE=prod -DB_URL=jdbc:postgresql://:/ -DB_USERNAME= -DB_PASSWORD= -``` - ---- - -### 2. Klucz JWT 🔴 - -Domyślny `JWT_SECRET` w `application.yml` to placeholder deweloperski. Na produkcji **musi** zostać zastąpiony losowym kluczem o minimalnej długości 256 bitów (32 bajty). - -```bash -# Generowanie bezpiecznego klucza (Base64) -openssl rand -base64 32 - -# Zmienna środowiskowa -JWT_SECRET= -``` - -Profil `prod` nie ma wartości domyślnej: brak `JWT_SECRET` zatrzymuje start każdego serwisu Java. -Ten sam `JWT_SECRET` musi być ustawiony we wszystkich czterech serwisach Java (**`user-service`**, -**`game-service`**, **`matchmaking-service`**, **`api-gateway`**) — każdy weryfikuje tokeny lokalnie -tym samym kluczem HMAC. Access-token blacklista (logout) jest współdzielona przez `auth-redis`, ale -sprawdzana tylko przez `user-service` (jako wystawca) i `api-gateway` (jedyne publiczne wejście) — -`game-service`/`matchmaking-service` jej nie sprawdzają, bo ich porty są zamknięte. Docelowo walidacja -podpisu przejdzie na klucze asymetryczne (RS256/JWKS). - -Formalny kontrakt [`JWT/blacklist v1`](checkers-contracts/auth/v1/README.md) wymaga jawnego HS256, -sekretu co najmniej 32-bajtowego, UUID w `sub`/`jti`, `iat`/`exp`, stałych `iss`/`aud` -(`checkers-user-service` / `checkers-platform`) i `token_type`. Bearer musi mieć -`token_type=access`, UUID `sid` oraz `username`/`email`; refresh ma `token_type=refresh` i nie może -zostać użyty jako bearer. **Wdrożenie v1 unieważnia wcześniej wydane tokeny bez `token_type`** — to -celowe, jednorazowe zdarzenie wymuszające ponowne zalogowanie. - ---- - -### 3. Social Login — Google 🔴 - -Wymagane tylko jeśli social login ma być aktywny. - -1. Przejdź do [Google Cloud Console](https://console.cloud.google.com/) → **APIs & Services → Credentials** -2. Utwórz **OAuth 2.0 Client ID** (typ: Web application) -3. Dodaj **Authorized redirect URI**: - ``` - https:///login/oauth2/callback/google - ``` -4. Ustaw zmienne środowiskowe w `user-service`: - ```bash - GOOGLE_CLIENT_ID= - GOOGLE_CLIENT_SECRET= - ``` - ---- - -### 4. Social Login — Facebook 🔴 - -Wymagane tylko jeśli social login ma być aktywny. - -1. Przejdź do [Meta for Developers](https://developers.facebook.com/) → **My Apps → Create App** -2. Dodaj produkt **Facebook Login** -3. W ustawieniach Facebook Login dodaj **Valid OAuth Redirect URI**: - ``` - https:///login/oauth2/callback/facebook - ``` -4. Ustaw zmienne środowiskowe w `user-service`: - ```bash - FACEBOOK_CLIENT_ID= - FACEBOOK_CLIENT_SECRET= - ``` - -> **Uwaga:** Facebook wymaga aktywnej domeny HTTPS oraz przejścia przez proces weryfikacji aplikacji przed udostępnieniem logowania zewnętrznym użytkownikom (poza rolą Administrator/Tester w panelu developerskim). - ---- - -### 5. OAuth2 — URL przekierowania klienta 🟡 - -Po udanym OAuth2 backend przekierowuje klienta z krótkotrwałym kodem wymiennym, -który frontend wymienia przez `POST /api/v1/auth/oauth2/token`. URL przekierowania -musi wskazywać na produkcyjną domenę: - -```bash -OAUTH2_FRONTEND_REDIRECT_URL=https:///oauth2/callback -``` - -Domyślna wartość (`http://localhost:3000/oauth2/callback`) działa tylko lokalnie. - ---- - -### 6. Serwis e-mail 🔴 - -Domyślna implementacja `ConsoleEmailService` wypisuje wiadomości na konsolę — na produkcji nie wysyła żadnych e-maili. Wymagana implementacja `IEmailService` korzystająca z prawdziwego dostawcy SMTP (np. SendGrid, AWS SES, Mailgun). - -```java -// Utwórz nową klasę i oznacz @Primary lub @Profile("prod") -@Service -@Profile("prod") -public class SmtpEmailService implements IEmailService { - // Konfiguracja przez spring.mail.* w application-prod.yml -} -``` - -```yaml -# application-prod.yml -spring: - mail: - host: smtp.sendgrid.net - port: 587 - username: apikey - password: ${SENDGRID_API_KEY} -``` - ---- - -### 7. gRPC — tokeny serwis-serwis 🔴 - -Każdy serwer gRPC na platformie (`user-service`, `game-service`, `game-engine-service`) zabezpiecza -swoje metody nagłówkiem `x-service-token`, porównywanym constant-time -(`MessageDigest.isEqual`/`hmac.compare_digest`). **Token jest per serwer, nie współdzielony globalnie** -— kompromitacja jednego serwisu nie otwiera pozostałych interfejsów gRPC: - -| Token (env) | Serwer (weryfikuje) | Klienci (wysyłają) | -|---|---|---| -| `USER_SERVICE_GRPC_AUTH_TOKEN` | `user-service` (`:9090`) | `game-service` (`UpdateElo`), `matchmaking-service` (`GetUser`) | -| `GAME_SERVICE_GRPC_AUTH_TOKEN` | `game-service` (`:9091`) | `matchmaking-service` (`CreateMatchedGame`) | -| `ENGINE_GRPC_AUTH_TOKEN` | `game-engine-service` (`:50051`) | `game-service` | - -```bash -USER_SERVICE_GRPC_AUTH_TOKEN= -GAME_SERVICE_GRPC_AUTH_TOKEN= -ENGINE_GRPC_AUTH_TOKEN= -``` - -Ustaw każdy token identycznie po stronie serwera i wszystkich jego klientów (patrz tabela). Profile -`prod` serwisów Java wymagają odpowiednich tokenów bez wartości domyślnych. Dev-defaulty w -`application.yml`/`config.py` są celowo różne dla różnych serwerów, żeby pomylona zmienna wybuchała -lokalnie, a nie dopiero na produkcji; silnik dodatkowo ostrzega przy starcie z tokenem deweloperskim. - -Standardowy gRPC health check jest dostępny bez tokena przez usługę -`grpc.health.v1.Health` we wszystkich czterech serwerach (w tym silniku), żeby load balancer albo -Kubernetes mógł sprawdzać stan serwisu. - -TLS/mTLS dla gRPC jest świadomie odłożone do fazy K8s (service mesh / cert-manager) — dziś ruch idzie -plaintext po zaufanej sieci compose/K8s. - ---- - -### 8. CORS - -CORS jest w całości własnością `api-gateway` (jedyne publiczne wejście) — serwisy nie wystawiają już -własnych nagłówków CORS. Na produkcji ustaw dozwolone originy zmienną środowiskową gatewaya: - -```bash -GATEWAY_CORS_ALLOWED_ORIGINS=https:// # lista po przecinku -``` - -Profil `prod` gatewaya nie ma domyślnej wartości — brak tej zmiennej zatrzyma start (fail-fast). - ---- - -### 9. Bezpieczeństwo — pozostałe - -| Element | Opis | -|---|---| -| HTTPS | Wymagany dla OAuth2 (Google i Facebook odrzucają HTTP redirect URI na produkcji) | -| Rate limiting | Zaimplementowany w `api-gateway` — token-bucket per-IP (Redis) na `/api/v1/auth/**`, fail-closed przy awarii Redisa | -| Token blacklist | Redis (`auth-redis`, profil `prod`) — `user-service` zapisuje `auth:blacklist:` przy logoucie/zmianie hasła, `api-gateway` czyta ten sam klucz na każdym uwierzytelnionym żądaniu. Zapis fail-closed, odczyt fail-open. Profil `dev`/testowy zostaje na in-memory (`app.token-blacklist=memory`) | -| Logi | Skonfigurować `logback-spring.xml` z poziomem `WARN`/`ERROR` na produkcji; wyłączyć SQL query logging | - ---- - -### 10. game-service — Redis i połączenia serwisowe 🔴 - -`game-service` przechowuje stan partii w Redisie (nie PostgreSQL) i łączy się z silnikiem oraz -`user-service`. Na produkcji wymagane jest dedykowane Redis (np. Azure Cache for Redis z hasłem) -oraz poprawne adresy zależności. - -```bash -SPRING_PROFILES_ACTIVE=prod -REDIS_HOST= # np. Azure Cache for Redis -REDIS_PORT=6379 -REDIS_PASSWORD= # wymagane na prod -ENGINE_GRPC_HOST= # game-engine-service -USER_SERVICE_GRPC_HOST= -GAME_SERVICE_GRPC_AUTH_TOKEN= -USER_SERVICE_GRPC_AUTH_TOKEN= # wysyłany do UpdateElo -ENGINE_GRPC_AUTH_TOKEN= # wysyłany do silnika -JWT_SECRET= -``` - -> W `docker-compose.yaml` (dev) adresy są już ustawione na nazwy usług (`game-engine`, `redis`, -> `user-service`). `game-service` partie PvP zgłasza do `user-service` przez `UpdateElo` — -> bez poprawnego `USER_SERVICE_GRPC_HOST` i poprawnego `USER_SERVICE_GRPC_AUTH_TOKEN` raportowanie -> ELO jest pomijane (logowane, bez przerwania gry). Bez poprawnego `ENGINE_GRPC_AUTH_TOKEN` każdy -> ruch (w tym vs AI) kończy się błędem — silnik jest twardą zależnością partii, nie best-effort. - -Redis trzyma wyłącznie partie żywe i krótko po zakończeniu (TTL); trwała historia partii i -archiwum partii to osobne, przyszłe tematy. +## How this repository is run ---- - -### 11. matchmaking-service — Redis i połączenia serwisowe 🔴 - -`matchmaking-service` przechowuje bilety i indeksy kolejek w osobnym Redisie, odczytuje ELO z -`user-service` i tworzy sparowane gry przez `game-service`. Na produkcji wymagane są dedykowany Redis, -wspólny `JWT_SECRET`, po jednym tokenie gRPC na każdą zależność oraz adresy obu zależności gRPC. - -```bash -SPRING_PROFILES_ACTIVE=prod -REDIS_HOST= -REDIS_PORT=6379 -REDIS_PASSWORD= # wymagane na prod -USER_SERVICE_GRPC_HOST= -GAME_SERVICE_GRPC_HOST= -USER_SERVICE_GRPC_AUTH_TOKEN= # wysyłany do GetUser -GAME_SERVICE_GRPC_AUTH_TOKEN= # wysyłany do CreateMatchedGame -JWT_SECRET= -``` +- `dev` is the integration branch; `main` moves only through release pull requests and is tagged + (see [Releases](https://github.com/TexablePlum/Checkers/releases)). +- Every pull request passes the full pipeline: contract validation (Buf + JSON Schema + OpenAPI + compatibility), build & tests of all four Java services with **100% line and branch coverage + gates**, Python engine tests, all five Docker image builds, and a full-history secret scan. +- Dependencies are managed by Dependabot with grouped weekly updates; deliberately pinned + platforms (Java 21 LTS, the Python/protobuf/grpcio lockstep) are documented in + [`.github/dependabot.yml`](.github/dependabot.yml). +- The roadmap lives in [issues and milestones](https://github.com/TexablePlum/Checkers/milestones). -> W `docker-compose.yaml` (dev) `matchmaking-service` używa osobnego `matchmaking-redis`, woła -> `user-service:9090` po `GetUser` i `game-service:9091` po `CreateMatchedGame`. +## Production notes -MVP działa jako pojedyncza instancja matchera. Skalowanie poziome wymaga przeniesienia claimu pary do -atomowej operacji po stronie Redisa (`Lua` albo `WATCH`/`MULTI`). +The `prod` profiles fail fast: a missing secret, database host, or CORS origin stops startup +instead of falling back to a development default. Per-service configuration (JWT contract, gRPC +token matrix, OAuth2 setup, mail delivery) is documented in each service's `README.md`. Known, +deliberate debt before a production deployment: the shared HS256 JWT secret (asymmetric +signing/JWKS is planned), plaintext internal gRPC (mTLS is deferred to the Kubernetes phase), and +single-instance scaling assumptions tracked in the +[Scale-out & production readiness](https://github.com/TexablePlum/Checkers/milestone/4) milestone. diff --git a/checkers-contracts/README.md b/checkers-contracts/README.md index 2cff866..612b558 100644 --- a/checkers-contracts/README.md +++ b/checkers-contracts/README.md @@ -4,7 +4,9 @@ This directory is the versioned source of truth for platform boundaries: - `*.proto` defines the `checkers.v1` gRPC APIs; - `http/v1/api-error.schema.json` defines the common Java HTTP error body; -- `auth/v1/` defines JWT access/refresh claims and the Redis access-token blacklist protocol. +- `auth/v1/` defines JWT access/refresh claims and the Redis access-token blacklist protocol; +- `ws/v1/` defines server-to-client STOMP payloads and their destinations; +- `openapi/` contains committed REST API snapshots with compatibility checks. The Protobuf API is stable under the `checkers.v1` package. Java code is generated under `com.checkers.contracts.v1`; Python keeps the standard module names generated from each `.proto` file. @@ -33,3 +35,6 @@ Validate every JSON artifact locally with Python: python -m json.tool checkers-contracts/http/v1/api-error.schema.json > /dev/null python -m json.tool checkers-contracts/auth/v1/contract.json > /dev/null ``` + +CI additionally validates schema metaschemas, `auth/v1/contract.json`, the decoded access and refresh +claim vectors, and the `ws/v1` schemas with `check-jsonschema`. diff --git a/checkers-contracts/auth/v1/README.md b/checkers-contracts/auth/v1/README.md index 7767087..844c367 100644 --- a/checkers-contracts/auth/v1/README.md +++ b/checkers-contracts/auth/v1/README.md @@ -17,6 +17,8 @@ The schemas validate decoded payloads. JWT headers additionally require `alg = H present, remains the standard `JWT` header and is not the application token discriminator. `test-vectors.json` contains a long-lived, non-production access token representing issuer output; every Java validator consumes it to prove producer/consumer interoperability and constant parity. +The file also carries explicit access and refresh claim instances. CI verifies that the access +instance equals the compact token payload and validates both instances against their v1 schemas. ## Redis access-token blacklist diff --git a/checkers-contracts/auth/v1/contract.schema.json b/checkers-contracts/auth/v1/contract.schema.json new file mode 100644 index 0000000..eb49512 --- /dev/null +++ b/checkers-contracts/auth/v1/contract.schema.json @@ -0,0 +1,48 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://checkers.example/contracts/auth/v1/contract.schema.json", + "title": "Authentication protocol contract v1", + "type": "object", + "additionalProperties": false, + "required": ["version", "jwt", "blacklist"], + "properties": { + "version": { "const": "v1" }, + "jwt": { + "type": "object", + "additionalProperties": false, + "required": [ + "algorithm", "minimumSecretBytes", "issuer", "audience", "tokenTypeClaim", + "accessTokenType", "refreshTokenType", "jtiFormat", "subjectFormat" + ], + "properties": { + "algorithm": { "const": "HS256" }, + "minimumSecretBytes": { "type": "integer", "minimum": 32 }, + "issuer": { "const": "checkers-user-service" }, + "audience": { "const": "checkers-platform" }, + "tokenTypeClaim": { "const": "token_type" }, + "accessTokenType": { "const": "access" }, + "refreshTokenType": { "const": "refresh" }, + "jtiFormat": { "const": "uuid" }, + "subjectFormat": { "const": "uuid" } + } + }, + "blacklist": { + "type": "object", + "additionalProperties": false, + "required": [ + "keyPrefix", "keyFormat", "value", "ttlUnit", "ttlRounding", "expiredTokenBehavior", + "writeFailurePolicy", "readFailurePolicy" + ], + "properties": { + "keyPrefix": { "const": "auth:blacklist:" }, + "keyFormat": { "const": "auth:blacklist:" }, + "value": { "const": "1" }, + "ttlUnit": { "const": "seconds" }, + "ttlRounding": { "const": "ceiling" }, + "expiredTokenBehavior": { "const": "skip-write" }, + "writeFailurePolicy": { "const": "fail-closed" }, + "readFailurePolicy": { "const": "fail-open" } + } + } + } +} diff --git a/checkers-contracts/auth/v1/test-vectors.json b/checkers-contracts/auth/v1/test-vectors.json index 72073ec..b46c02a 100644 --- a/checkers-contracts/auth/v1/test-vectors.json +++ b/checkers-contracts/auth/v1/test-vectors.json @@ -1,6 +1,27 @@ { "secret": "checkers-contract-test-secret-0123456789-ABCDEFGHIJKLMNOPQRSTUVWXYZ", "accessToken": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJjaGVja2Vycy11c2VyLXNlcnZpY2UiLCJhdWQiOlsiY2hlY2tlcnMtcGxhdGZvcm0iXSwic3ViIjoiMDAwMDAwMDAtMDAwMC0wMDAwLTAwMDAtMDAwMDAwMDAwMDAxIiwianRpIjoiMDAwMDAwMDAtMDAwMC0wMDAwLTAwMDAtMDAwMDAwMDAwMDAyIiwiaWF0IjoxNzA0MDY3MjAwLCJleHAiOjQxMDI0NDQ4MDAsInRva2VuX3R5cGUiOiJhY2Nlc3MiLCJzaWQiOiIwMDAwMDAwMC0wMDAwLTAwMDAtMDAwMC0wMDAwMDAwMDAwMDMiLCJ1c2VybmFtZSI6ImNvbnRyYWN0LXVzZXIiLCJlbWFpbCI6ImNvbnRyYWN0QGV4YW1wbGUuY29tIn0.KTNdSTuuU-xp9qWX5CFWApoZ86MIHfmfas9Tk5-5Fso", + "accessClaims": { + "iss": "checkers-user-service", + "aud": ["checkers-platform"], + "sub": "00000000-0000-0000-0000-000000000001", + "jti": "00000000-0000-0000-0000-000000000002", + "iat": 1704067200, + "exp": 4102444800, + "token_type": "access", + "sid": "00000000-0000-0000-0000-000000000003", + "username": "contract-user", + "email": "contract@example.com" + }, + "refreshClaims": { + "iss": "checkers-user-service", + "aud": ["checkers-platform"], + "sub": "00000000-0000-0000-0000-000000000001", + "jti": "00000000-0000-0000-0000-000000000004", + "iat": 1704067200, + "exp": 4102444800, + "token_type": "refresh" + }, "expectedUserId": "00000000-0000-0000-0000-000000000001", "expectedJti": "00000000-0000-0000-0000-000000000002", "note": "Public interoperability vector only; this secret must never be used outside tests." diff --git a/checkers-contracts/openapi/README.md b/checkers-contracts/openapi/README.md new file mode 100644 index 0000000..d98fd95 --- /dev/null +++ b/checkers-contracts/openapi/README.md @@ -0,0 +1,25 @@ +# OpenAPI snapshots + +This directory contains the committed `/v3/api-docs` output for `user-service`, `game-service` and +`matchmaking-service`. The snapshots are the reviewable REST contract baseline used by CI. + +## Generation + +Each service owns a focused integration test that requests its real `/v3/api-docs` endpoint and writes +the parsed, pretty-printed document here. Test-based generation was chosen over +`springdoc-openapi-maven-plugin` because it reuses the service's existing deterministic test profile +and application context, exercises the actual endpoint and avoids starting and stopping a second +application process on a managed port. + +Regenerate all snapshots from the repository root with JDK 21: + +```powershell +.\services\user-service\mvnw.cmd -f services\user-service\pom.xml -Dtest=OpenApiSnapshotTest test +.\services\game-service\mvnw.cmd -f services\game-service\pom.xml -Dtest=GameControllerMockMvcTest#snapshotsOpenApiContract test +.\services\matchmaking-service\mvnw.cmd -f services\matchmaking-service\pom.xml -Dtest=QueueControllerMockMvcTest#snapshotsOpenApiContract test +``` + +Commit intentional additive changes with the affected service code. CI regenerates the documents and +rejects snapshot drift with `git diff --exit-code`. It also runs `openapi-diff` against the pull +request base commit (or the previous commit on a direct push); incompatible REST changes fail while +compatible additions remain allowed. diff --git a/checkers-contracts/openapi/game-service.json b/checkers-contracts/openapi/game-service.json new file mode 100644 index 0000000..b05a880 --- /dev/null +++ b/checkers-contracts/openapi/game-service.json @@ -0,0 +1,522 @@ +{ + "components" : { + "schemas" : { + "CreateGameRequest" : { + "properties" : { + "aiDifficulty" : { + "format" : "int32", + "maximum" : 10, + "minimum" : 1, + "type" : "integer" + }, + "humanColor" : { + "enum" : [ "WHITE", "BLACK" ], + "type" : "string" + }, + "mode" : { + "enum" : [ "HUMAN", "AI" ], + "type" : "string" + }, + "timeControl" : { + "enum" : [ "BULLET_1_0", "BLITZ_3_0", "BLITZ_5_0", "RAPID_10_0", "CLASSICAL_30_0" ], + "type" : "string" + }, + "variant" : { + "enum" : [ "STANDARD", "AMERICAN", "FRISIAN", "RUSSIAN", "BRAZILIAN", "ANTIDRAUGHTS", "BREAKTHROUGH", "FRYSK" ], + "type" : "string" + } + }, + "required" : [ "mode", "variant" ], + "type" : "object" + }, + "FinishedGameHistoryResponse" : { + "properties" : { + "blackPlayerId" : { + "format" : "uuid", + "type" : "string" + }, + "createdAt" : { + "format" : "date-time", + "type" : "string" + }, + "endReason" : { + "enum" : [ "NORMAL", "RESIGNATION", "TIMEOUT", "ABANDONMENT" ], + "type" : "string" + }, + "finishedAt" : { + "format" : "date-time", + "type" : "string" + }, + "gameId" : { + "format" : "uuid", + "type" : "string" + }, + "moves" : { + "items" : { + "type" : "string" + }, + "type" : "array" + }, + "result" : { + "enum" : [ "WHITE_WON", "BLACK_WON", "DRAW" ], + "type" : "string" + }, + "timeControl" : { + "enum" : [ "BULLET_1_0", "BLITZ_3_0", "BLITZ_5_0", "RAPID_10_0", "CLASSICAL_30_0" ], + "type" : "string" + }, + "variant" : { + "enum" : [ "STANDARD", "AMERICAN", "FRISIAN", "RUSSIAN", "BRAZILIAN", "ANTIDRAUGHTS", "BREAKTHROUGH", "FRYSK" ], + "type" : "string" + }, + "whitePlayerId" : { + "format" : "uuid", + "type" : "string" + } + }, + "type" : "object" + }, + "GameClockResponse" : { + "properties" : { + "blackRemainingMillis" : { + "format" : "int64", + "type" : "integer" + }, + "lastSwitchAt" : { + "format" : "date-time", + "type" : "string" + }, + "whiteRemainingMillis" : { + "format" : "int64", + "type" : "integer" + } + }, + "type" : "object" + }, + "GameHistoryPageResponse" : { + "properties" : { + "content" : { + "items" : { + "$ref" : "#/components/schemas/FinishedGameHistoryResponse" + }, + "type" : "array" + }, + "page" : { + "format" : "int32", + "type" : "integer" + }, + "size" : { + "format" : "int32", + "type" : "integer" + }, + "totalElements" : { + "format" : "int64", + "type" : "integer" + }, + "totalPages" : { + "format" : "int32", + "type" : "integer" + } + }, + "type" : "object" + }, + "GameResponse" : { + "properties" : { + "blackPlayer" : { + "$ref" : "#/components/schemas/PlayerResponse" + }, + "clock" : { + "$ref" : "#/components/schemas/GameClockResponse" + }, + "createdAt" : { + "format" : "date-time", + "type" : "string" + }, + "currentFen" : { + "type" : "string" + }, + "currentTurn" : { + "enum" : [ "WHITE", "BLACK" ], + "type" : "string" + }, + "endReason" : { + "enum" : [ "NORMAL", "RESIGNATION", "TIMEOUT", "ABANDONMENT" ], + "type" : "string" + }, + "finishedAt" : { + "format" : "date-time", + "type" : "string" + }, + "id" : { + "format" : "uuid", + "type" : "string" + }, + "legalMoves" : { + "items" : { + "type" : "string" + }, + "type" : "array" + }, + "moves" : { + "items" : { + "type" : "string" + }, + "type" : "array" + }, + "result" : { + "enum" : [ "WHITE_WON", "BLACK_WON", "DRAW" ], + "type" : "string" + }, + "status" : { + "enum" : [ "WAITING_FOR_OPPONENT", "IN_PROGRESS", "FINISHED" ], + "type" : "string" + }, + "timeControl" : { + "enum" : [ "BULLET_1_0", "BLITZ_3_0", "BLITZ_5_0", "RAPID_10_0", "CLASSICAL_30_0" ], + "type" : "string" + }, + "variant" : { + "enum" : [ "STANDARD", "AMERICAN", "FRISIAN", "RUSSIAN", "BRAZILIAN", "ANTIDRAUGHTS", "BREAKTHROUGH", "FRYSK" ], + "type" : "string" + }, + "whitePlayer" : { + "$ref" : "#/components/schemas/PlayerResponse" + } + }, + "type" : "object" + }, + "GameSummaryResponse" : { + "properties" : { + "blackPlayer" : { + "$ref" : "#/components/schemas/PlayerResponse" + }, + "createdAt" : { + "format" : "date-time", + "type" : "string" + }, + "currentTurn" : { + "enum" : [ "WHITE", "BLACK" ], + "type" : "string" + }, + "finishedAt" : { + "format" : "date-time", + "type" : "string" + }, + "id" : { + "format" : "uuid", + "type" : "string" + }, + "status" : { + "enum" : [ "WAITING_FOR_OPPONENT", "IN_PROGRESS", "FINISHED" ], + "type" : "string" + }, + "timeControl" : { + "enum" : [ "BULLET_1_0", "BLITZ_3_0", "BLITZ_5_0", "RAPID_10_0", "CLASSICAL_30_0" ], + "type" : "string" + }, + "variant" : { + "enum" : [ "STANDARD", "AMERICAN", "FRISIAN", "RUSSIAN", "BRAZILIAN", "ANTIDRAUGHTS", "BREAKTHROUGH", "FRYSK" ], + "type" : "string" + }, + "whitePlayer" : { + "$ref" : "#/components/schemas/PlayerResponse" + } + }, + "type" : "object" + }, + "MoveRequest" : { + "properties" : { + "move" : { + "minLength" : 1, + "type" : "string" + } + }, + "required" : [ "move" ], + "type" : "object" + }, + "PlayerResponse" : { + "properties" : { + "aiDifficulty" : { + "format" : "int32", + "type" : "integer" + }, + "type" : { + "enum" : [ "HUMAN", "AI" ], + "type" : "string" + }, + "userId" : { + "format" : "uuid", + "type" : "string" + } + }, + "type" : "object" + } + }, + "securitySchemes" : { + "bearerAuth" : { + "bearerFormat" : "JWT", + "scheme" : "bearer", + "type" : "http" + } + } + }, + "info" : { + "description" : "Authoritative checkers game lifecycle API", + "title" : "Checkers game-service API", + "version" : "v1" + }, + "openapi" : "3.1.0", + "paths" : { + "/api/v1/games" : { + "get" : { + "operationId" : "listGames", + "parameters" : [ { + "in" : "query", + "name" : "scope", + "required" : false, + "schema" : { + "default" : "MINE", + "enum" : [ "MINE", "WAITING" ], + "type" : "string" + } + }, { + "in" : "query", + "name" : "status", + "required" : false, + "schema" : { + "enum" : [ "WAITING_FOR_OPPONENT", "IN_PROGRESS", "FINISHED" ], + "type" : "string" + } + }, { + "in" : "query", + "name" : "variant", + "required" : false, + "schema" : { + "enum" : [ "STANDARD", "AMERICAN", "FRISIAN", "RUSSIAN", "BRAZILIAN", "ANTIDRAUGHTS", "BREAKTHROUGH", "FRYSK" ], + "type" : "string" + } + } ], + "responses" : { + "200" : { + "content" : { + "*/*" : { + "schema" : { + "items" : { + "$ref" : "#/components/schemas/GameSummaryResponse" + }, + "type" : "array" + } + } + }, + "description" : "OK" + } + }, + "summary" : "List games", + "tags" : [ "Games" ] + }, + "post" : { + "operationId" : "createGame", + "requestBody" : { + "content" : { + "application/json" : { + "schema" : { + "$ref" : "#/components/schemas/CreateGameRequest" + } + } + }, + "required" : true + }, + "responses" : { + "200" : { + "content" : { + "*/*" : { + "schema" : { + "$ref" : "#/components/schemas/GameResponse" + } + } + }, + "description" : "OK" + } + }, + "summary" : "Create game", + "tags" : [ "Games" ] + } + }, + "/api/v1/games/history" : { + "get" : { + "description" : "Returns only archived games in which the authenticated user participated", + "operationId" : "history", + "parameters" : [ { + "description" : "Zero-based page number", + "in" : "query", + "name" : "page", + "required" : false, + "schema" : { + "default" : 0, + "format" : "int32", + "minimum" : 0, + "type" : "integer" + } + }, { + "description" : "Page size from 1 to 100", + "in" : "query", + "name" : "size", + "required" : false, + "schema" : { + "default" : 20, + "format" : "int32", + "maximum" : 100, + "minimum" : 1, + "type" : "integer" + } + } ], + "responses" : { + "200" : { + "content" : { + "*/*" : { + "schema" : { + "$ref" : "#/components/schemas/GameHistoryPageResponse" + } + } + }, + "description" : "OK" + } + }, + "summary" : "Get finished game history", + "tags" : [ "Games" ] + } + }, + "/api/v1/games/{gameId}" : { + "get" : { + "operationId" : "getGame", + "parameters" : [ { + "in" : "path", + "name" : "gameId", + "required" : true, + "schema" : { + "format" : "uuid", + "type" : "string" + } + } ], + "responses" : { + "200" : { + "content" : { + "*/*" : { + "schema" : { + "$ref" : "#/components/schemas/GameResponse" + } + } + }, + "description" : "OK" + } + }, + "summary" : "Get game", + "tags" : [ "Games" ] + } + }, + "/api/v1/games/{gameId}/join" : { + "post" : { + "operationId" : "joinGame", + "parameters" : [ { + "in" : "path", + "name" : "gameId", + "required" : true, + "schema" : { + "format" : "uuid", + "type" : "string" + } + } ], + "responses" : { + "200" : { + "content" : { + "*/*" : { + "schema" : { + "$ref" : "#/components/schemas/GameResponse" + } + } + }, + "description" : "OK" + } + }, + "summary" : "Join game", + "tags" : [ "Games" ] + } + }, + "/api/v1/games/{gameId}/moves" : { + "post" : { + "operationId" : "makeMove", + "parameters" : [ { + "in" : "path", + "name" : "gameId", + "required" : true, + "schema" : { + "format" : "uuid", + "type" : "string" + } + } ], + "requestBody" : { + "content" : { + "application/json" : { + "schema" : { + "$ref" : "#/components/schemas/MoveRequest" + } + } + }, + "required" : true + }, + "responses" : { + "200" : { + "content" : { + "*/*" : { + "schema" : { + "$ref" : "#/components/schemas/GameResponse" + } + } + }, + "description" : "OK" + } + }, + "summary" : "Make move", + "tags" : [ "Games" ] + } + }, + "/api/v1/games/{gameId}/resign" : { + "post" : { + "operationId" : "resignGame", + "parameters" : [ { + "in" : "path", + "name" : "gameId", + "required" : true, + "schema" : { + "format" : "uuid", + "type" : "string" + } + } ], + "responses" : { + "200" : { + "content" : { + "*/*" : { + "schema" : { + "$ref" : "#/components/schemas/GameResponse" + } + } + }, + "description" : "OK" + } + }, + "summary" : "Resign game", + "tags" : [ "Games" ] + } + } + }, + "security" : [ { + "bearerAuth" : [ ] + } ], + "servers" : [ { + "url" : "/" + } ], + "tags" : [ { + "description" : "Checkers game lifecycle endpoints", + "name" : "Games" + } ] +} \ No newline at end of file diff --git a/checkers-contracts/openapi/matchmaking-service.json b/checkers-contracts/openapi/matchmaking-service.json new file mode 100644 index 0000000..e621e46 --- /dev/null +++ b/checkers-contracts/openapi/matchmaking-service.json @@ -0,0 +1,130 @@ +{ + "components" : { + "schemas" : { + "JoinQueueRequest" : { + "properties" : { + "timeControl" : { + "enum" : [ "BULLET_1_0", "BLITZ_3_0", "BLITZ_5_0", "RAPID_10_0", "CLASSICAL_30_0" ], + "type" : "string" + }, + "variant" : { + "enum" : [ "STANDARD", "AMERICAN", "FRISIAN", "RUSSIAN", "BRAZILIAN", "ANTIDRAUGHTS", "BREAKTHROUGH", "FRYSK" ], + "type" : "string" + } + }, + "required" : [ "timeControl", "variant" ], + "type" : "object" + }, + "QueueStatusResponse" : { + "properties" : { + "color" : { + "enum" : [ "WHITE", "BLACK" ], + "type" : "string" + }, + "gameId" : { + "format" : "uuid", + "type" : "string" + }, + "status" : { + "enum" : [ "NONE", "QUEUED", "MATCHED" ], + "type" : "string" + }, + "timeControl" : { + "enum" : [ "BULLET_1_0", "BLITZ_3_0", "BLITZ_5_0", "RAPID_10_0", "CLASSICAL_30_0" ], + "type" : "string" + }, + "variant" : { + "enum" : [ "STANDARD", "AMERICAN", "FRISIAN", "RUSSIAN", "BRAZILIAN", "ANTIDRAUGHTS", "BREAKTHROUGH", "FRYSK" ], + "type" : "string" + }, + "waitSeconds" : { + "format" : "int64", + "type" : "integer" + } + }, + "type" : "object" + } + }, + "securitySchemes" : { + "bearerAuth" : { + "bearerFormat" : "JWT", + "scheme" : "bearer", + "type" : "http" + } + } + }, + "info" : { + "description" : "PvP queueing and matchmaking API", + "title" : "Checkers matchmaking-service API", + "version" : "v1" + }, + "openapi" : "3.1.0", + "paths" : { + "/api/v1/matchmaking/queue" : { + "delete" : { + "operationId" : "leave", + "responses" : { + "200" : { + "description" : "OK" + } + }, + "summary" : "Leave matchmaking queue", + "tags" : [ "Matchmaking queue" ] + }, + "get" : { + "operationId" : "status", + "responses" : { + "200" : { + "content" : { + "*/*" : { + "schema" : { + "$ref" : "#/components/schemas/QueueStatusResponse" + } + } + }, + "description" : "OK" + } + }, + "summary" : "Get matchmaking queue status", + "tags" : [ "Matchmaking queue" ] + }, + "post" : { + "operationId" : "join", + "requestBody" : { + "content" : { + "application/json" : { + "schema" : { + "$ref" : "#/components/schemas/JoinQueueRequest" + } + } + }, + "required" : true + }, + "responses" : { + "200" : { + "content" : { + "*/*" : { + "schema" : { + "$ref" : "#/components/schemas/QueueStatusResponse" + } + } + }, + "description" : "OK" + } + }, + "summary" : "Join matchmaking queue", + "tags" : [ "Matchmaking queue" ] + } + } + }, + "security" : [ { + "bearerAuth" : [ ] + } ], + "servers" : [ { + "url" : "/" + } ], + "tags" : [ { + "description" : "PvP matchmaking queue endpoints", + "name" : "Matchmaking queue" + } ] +} \ No newline at end of file diff --git a/checkers-contracts/openapi/user-service.json b/checkers-contracts/openapi/user-service.json new file mode 100644 index 0000000..79647ce --- /dev/null +++ b/checkers-contracts/openapi/user-service.json @@ -0,0 +1,1188 @@ +{ + "components" : { + "schemas" : { + "AuditEventResponse" : { + "properties" : { + "createdAt" : { + "format" : "date-time", + "type" : "string" + }, + "detail" : { + "type" : "string" + }, + "eventType" : { + "enum" : [ "LOGIN_SUCCESS", "LOGIN_FAILURE", "LOGOUT", "LOGOUT_ALL", "TOKEN_REFRESHED", "TOKEN_REUSE_DETECTED", "ACCOUNT_LOCKED_TEMP", "ACCOUNT_LOCKED_PERM", "ACCOUNT_UNLOCKED", "PASSWORD_CHANGED", "PASSWORD_RESET_REQUESTED", "PASSWORD_RESET", "EMAIL_CHANGE_REQUESTED", "EMAIL_CHANGED", "USERNAME_CHANGED", "ACCOUNT_DELETION_SCHEDULED", "ACCOUNT_DELETION_CANCELLED", "ACCOUNT_ANONYMIZED" ], + "type" : "string" + }, + "id" : { + "format" : "uuid", + "type" : "string" + }, + "ipAddress" : { + "type" : "string" + } + }, + "type" : "object" + }, + "AuthResponse" : { + "properties" : { + "message" : { + "type" : "string" + }, + "userId" : { + "format" : "uuid", + "type" : "string" + } + }, + "type" : "object" + }, + "ChangeEmailRequest" : { + "properties" : { + "currentPassword" : { + "minLength" : 1, + "type" : "string" + }, + "newEmail" : { + "format" : "email", + "maxLength" : 255, + "minLength" : 0, + "type" : "string" + } + }, + "required" : [ "currentPassword", "newEmail" ], + "type" : "object" + }, + "ChangePasswordRequest" : { + "properties" : { + "currentPassword" : { + "minLength" : 1, + "type" : "string" + }, + "logoutOtherDevices" : { + "type" : "boolean" + }, + "newPassword" : { + "maxLength" : 64, + "minLength" : 8, + "pattern" : "^(?=.*[a-z])(?=.*[A-Z])(?=.*\\d)(?=.*[@$!%*?&#+\\-_])[A-Za-z\\d@$!%*?&#+\\-_]{8,}$", + "type" : "string" + } + }, + "required" : [ "currentPassword", "newPassword" ], + "type" : "object" + }, + "ChangeUsernameRequest" : { + "properties" : { + "newUsername" : { + "maxLength" : 20, + "minLength" : 3, + "pattern" : "^[a-zA-Z0-9_]+$", + "type" : "string" + } + }, + "required" : [ "newUsername" ], + "type" : "object" + }, + "ConfirmEmailChangeRequest" : { + "properties" : { + "code" : { + "maxLength" : 6, + "minLength" : 6, + "type" : "string" + } + }, + "required" : [ "code" ], + "type" : "object" + }, + "ForgotPasswordRequest" : { + "properties" : { + "email" : { + "format" : "email", + "minLength" : 1, + "type" : "string" + } + }, + "required" : [ "email" ], + "type" : "object" + }, + "LoginRequest" : { + "properties" : { + "login" : { + "minLength" : 1, + "type" : "string" + }, + "password" : { + "minLength" : 1, + "type" : "string" + } + }, + "required" : [ "login", "password" ], + "type" : "object" + }, + "OAuth2CodeExchangeRequest" : { + "properties" : { + "code" : { + "minLength" : 1, + "type" : "string" + } + }, + "required" : [ "code" ], + "type" : "object" + }, + "PageAuditEventResponse" : { + "properties" : { + "content" : { + "items" : { + "$ref" : "#/components/schemas/AuditEventResponse" + }, + "type" : "array" + }, + "empty" : { + "type" : "boolean" + }, + "first" : { + "type" : "boolean" + }, + "last" : { + "type" : "boolean" + }, + "number" : { + "format" : "int32", + "type" : "integer" + }, + "numberOfElements" : { + "format" : "int32", + "type" : "integer" + }, + "pageable" : { + "$ref" : "#/components/schemas/PageableObject" + }, + "size" : { + "format" : "int32", + "type" : "integer" + }, + "sort" : { + "$ref" : "#/components/schemas/SortObject" + }, + "totalElements" : { + "format" : "int64", + "type" : "integer" + }, + "totalPages" : { + "format" : "int32", + "type" : "integer" + } + }, + "type" : "object" + }, + "PageableObject" : { + "properties" : { + "offset" : { + "format" : "int64", + "type" : "integer" + }, + "pageNumber" : { + "format" : "int32", + "type" : "integer" + }, + "pageSize" : { + "format" : "int32", + "type" : "integer" + }, + "paged" : { + "type" : "boolean" + }, + "sort" : { + "$ref" : "#/components/schemas/SortObject" + }, + "unpaged" : { + "type" : "boolean" + } + }, + "type" : "object" + }, + "PublicUserProfileResponse" : { + "properties" : { + "avatarUrl" : { + "type" : "string" + }, + "country" : { + "enum" : [ "AD", "AE", "AF", "AG", "AI", "AL", "AM", "AO", "AQ", "AR", "AS", "AT", "AU", "AW", "AX", "AZ", "BA", "BB", "BD", "BE", "BF", "BG", "BH", "BI", "BJ", "BL", "BM", "BN", "BO", "BQ", "BR", "BS", "BT", "BV", "BW", "BY", "BZ", "CA", "CC", "CD", "CF", "CG", "CH", "CI", "CK", "CL", "CM", "CN", "CO", "CR", "CU", "CV", "CW", "CX", "CY", "CZ", "DE", "DJ", "DK", "DM", "DO", "DZ", "EC", "EE", "EG", "EH", "ER", "ES", "ET", "FI", "FJ", "FK", "FM", "FO", "FR", "GA", "GB", "GD", "GE", "GF", "GG", "GH", "GI", "GL", "GM", "GN", "GP", "GQ", "GR", "GS", "GT", "GU", "GW", "GY", "HK", "HM", "HN", "HR", "HT", "HU", "ID", "IE", "IL", "IM", "IN", "IO", "IQ", "IR", "IS", "IT", "JE", "JM", "JO", "JP", "KE", "KG", "KH", "KI", "KM", "KN", "KP", "KR", "KW", "KY", "KZ", "LA", "LB", "LC", "LI", "LK", "LR", "LS", "LT", "LU", "LV", "LY", "MA", "MC", "MD", "ME", "MF", "MG", "MH", "MK", "ML", "MM", "MN", "MO", "MP", "MQ", "MR", "MS", "MT", "MU", "MV", "MW", "MX", "MY", "MZ", "NA", "NC", "NE", "NF", "NG", "NI", "NL", "NO", "NP", "NR", "NU", "NZ", "OM", "PA", "PE", "PF", "PG", "PH", "PK", "PL", "PM", "PN", "PR", "PS", "PT", "PW", "PY", "QA", "RE", "RO", "RS", "RU", "RW", "SA", "SB", "SC", "SD", "SE", "SG", "SH", "SI", "SJ", "SK", "SL", "SM", "SN", "SO", "SR", "SS", "ST", "SV", "SX", "SY", "SZ", "TC", "TD", "TF", "TG", "TH", "TJ", "TK", "TL", "TM", "TN", "TO", "TR", "TT", "TV", "TW", "TZ", "UA", "UG", "UM", "US", "UY", "UZ", "VA", "VC", "VE", "VG", "VI", "VN", "VU", "WF", "WS", "YE", "YT", "ZA", "ZM", "ZW" ], + "type" : "string" + }, + "createdAt" : { + "format" : "date-time", + "type" : "string" + }, + "firstName" : { + "type" : "string" + }, + "id" : { + "format" : "uuid", + "type" : "string" + }, + "language" : { + "enum" : [ "AA", "AB", "AE", "AF", "AK", "AM", "AN", "AR", "AS", "AV", "AY", "AZ", "BA", "BE", "BG", "BH", "BI", "BM", "BN", "BO", "BR", "BS", "CA", "CE", "CH", "CO", "CR", "CS", "CU", "CV", "CY", "DA", "DE", "DV", "DZ", "EE", "EL", "EN", "EO", "ES", "ET", "EU", "FA", "FF", "FI", "FJ", "FO", "FR", "FY", "GA", "GD", "GL", "GN", "GU", "GV", "HA", "HE", "HI", "HO", "HR", "HT", "HU", "HY", "HZ", "IA", "ID", "IE", "IG", "II", "IK", "IO", "IS", "IT", "IU", "JA", "JV", "KA", "KG", "KI", "KJ", "KK", "KL", "KM", "KN", "KO", "KR", "KS", "KU", "KV", "KW", "KY", "LA", "LB", "LG", "LI", "LN", "LO", "LT", "LU", "LV", "MG", "MH", "MI", "MK", "ML", "MN", "MR", "MS", "MT", "MY", "NA", "NB", "ND", "NE", "NG", "NL", "NN", "NO", "NR", "NV", "NY", "OC", "OJ", "OM", "OR", "OS", "PA", "PI", "PL", "PS", "PT", "QU", "RM", "RN", "RO", "RU", "RW", "SA", "SC", "SD", "SE", "SG", "SI", "SK", "SL", "SM", "SN", "SO", "SQ", "SR", "SS", "ST", "SU", "SV", "SW", "TA", "TE", "TG", "TH", "TI", "TK", "TL", "TN", "TO", "TR", "TS", "TT", "TW", "TY", "UG", "UK", "UR", "UZ", "VE", "VI", "VO", "WA", "WO", "XH", "YI", "YO", "ZA", "ZH", "ZU" ], + "type" : "string" + }, + "lastName" : { + "type" : "string" + }, + "timezone" : { + "type" : "string" + }, + "username" : { + "type" : "string" + } + }, + "type" : "object" + }, + "RefreshTokenRequest" : { + "properties" : { + "refreshToken" : { + "minLength" : 1, + "type" : "string" + } + }, + "required" : [ "refreshToken" ], + "type" : "object" + }, + "RegisterRequest" : { + "properties" : { + "country" : { + "enum" : [ "AD", "AE", "AF", "AG", "AI", "AL", "AM", "AO", "AQ", "AR", "AS", "AT", "AU", "AW", "AX", "AZ", "BA", "BB", "BD", "BE", "BF", "BG", "BH", "BI", "BJ", "BL", "BM", "BN", "BO", "BQ", "BR", "BS", "BT", "BV", "BW", "BY", "BZ", "CA", "CC", "CD", "CF", "CG", "CH", "CI", "CK", "CL", "CM", "CN", "CO", "CR", "CU", "CV", "CW", "CX", "CY", "CZ", "DE", "DJ", "DK", "DM", "DO", "DZ", "EC", "EE", "EG", "EH", "ER", "ES", "ET", "FI", "FJ", "FK", "FM", "FO", "FR", "GA", "GB", "GD", "GE", "GF", "GG", "GH", "GI", "GL", "GM", "GN", "GP", "GQ", "GR", "GS", "GT", "GU", "GW", "GY", "HK", "HM", "HN", "HR", "HT", "HU", "ID", "IE", "IL", "IM", "IN", "IO", "IQ", "IR", "IS", "IT", "JE", "JM", "JO", "JP", "KE", "KG", "KH", "KI", "KM", "KN", "KP", "KR", "KW", "KY", "KZ", "LA", "LB", "LC", "LI", "LK", "LR", "LS", "LT", "LU", "LV", "LY", "MA", "MC", "MD", "ME", "MF", "MG", "MH", "MK", "ML", "MM", "MN", "MO", "MP", "MQ", "MR", "MS", "MT", "MU", "MV", "MW", "MX", "MY", "MZ", "NA", "NC", "NE", "NF", "NG", "NI", "NL", "NO", "NP", "NR", "NU", "NZ", "OM", "PA", "PE", "PF", "PG", "PH", "PK", "PL", "PM", "PN", "PR", "PS", "PT", "PW", "PY", "QA", "RE", "RO", "RS", "RU", "RW", "SA", "SB", "SC", "SD", "SE", "SG", "SH", "SI", "SJ", "SK", "SL", "SM", "SN", "SO", "SR", "SS", "ST", "SV", "SX", "SY", "SZ", "TC", "TD", "TF", "TG", "TH", "TJ", "TK", "TL", "TM", "TN", "TO", "TR", "TT", "TV", "TW", "TZ", "UA", "UG", "UM", "US", "UY", "UZ", "VA", "VC", "VE", "VG", "VI", "VN", "VU", "WF", "WS", "YE", "YT", "ZA", "ZM", "ZW" ], + "example" : "US", + "type" : "string" + }, + "email" : { + "example" : "john.doe@example.com", + "format" : "email", + "maxLength" : 255, + "minLength" : 0, + "type" : "string" + }, + "firstName" : { + "example" : "John", + "maxLength" : 50, + "minLength" : 0, + "type" : "string" + }, + "language" : { + "enum" : [ "AA", "AB", "AE", "AF", "AK", "AM", "AN", "AR", "AS", "AV", "AY", "AZ", "BA", "BE", "BG", "BH", "BI", "BM", "BN", "BO", "BR", "BS", "CA", "CE", "CH", "CO", "CR", "CS", "CU", "CV", "CY", "DA", "DE", "DV", "DZ", "EE", "EL", "EN", "EO", "ES", "ET", "EU", "FA", "FF", "FI", "FJ", "FO", "FR", "FY", "GA", "GD", "GL", "GN", "GU", "GV", "HA", "HE", "HI", "HO", "HR", "HT", "HU", "HY", "HZ", "IA", "ID", "IE", "IG", "II", "IK", "IO", "IS", "IT", "IU", "JA", "JV", "KA", "KG", "KI", "KJ", "KK", "KL", "KM", "KN", "KO", "KR", "KS", "KU", "KV", "KW", "KY", "LA", "LB", "LG", "LI", "LN", "LO", "LT", "LU", "LV", "MG", "MH", "MI", "MK", "ML", "MN", "MR", "MS", "MT", "MY", "NA", "NB", "ND", "NE", "NG", "NL", "NN", "NO", "NR", "NV", "NY", "OC", "OJ", "OM", "OR", "OS", "PA", "PI", "PL", "PS", "PT", "QU", "RM", "RN", "RO", "RU", "RW", "SA", "SC", "SD", "SE", "SG", "SI", "SK", "SL", "SM", "SN", "SO", "SQ", "SR", "SS", "ST", "SU", "SV", "SW", "TA", "TE", "TG", "TH", "TI", "TK", "TL", "TN", "TO", "TR", "TS", "TT", "TW", "TY", "UG", "UK", "UR", "UZ", "VE", "VI", "VO", "WA", "WO", "XH", "YI", "YO", "ZA", "ZH", "ZU" ], + "example" : "EN", + "type" : "string" + }, + "lastName" : { + "example" : "Doe", + "maxLength" : 50, + "minLength" : 0, + "type" : "string" + }, + "password" : { + "example" : "StrongPassword123!", + "maxLength" : 64, + "minLength" : 8, + "pattern" : "^(?=.*[a-z])(?=.*[A-Z])(?=.*\\d)(?=.*[@$!%*?&#+\\-_])[A-Za-z\\d@$!%*?&#+\\-_]{8,}$", + "type" : "string" + }, + "timezone" : { + "example" : "America/New_York", + "type" : "string" + }, + "username" : { + "example" : "john_doe", + "maxLength" : 20, + "minLength" : 3, + "pattern" : "^[a-zA-Z0-9_]+$", + "type" : "string" + } + }, + "required" : [ "email", "password", "username" ], + "type" : "object" + }, + "ResetPasswordRequest" : { + "properties" : { + "newPassword" : { + "maxLength" : 64, + "minLength" : 8, + "pattern" : "^(?=.*[a-z])(?=.*[A-Z])(?=.*\\d)(?=.*[@$!%*?&#+\\-_])[A-Za-z\\d@$!%*?&#+\\-_]{8,}$", + "type" : "string" + }, + "token" : { + "minLength" : 1, + "type" : "string" + } + }, + "required" : [ "newPassword", "token" ], + "type" : "object" + }, + "SortObject" : { + "properties" : { + "empty" : { + "type" : "boolean" + }, + "sorted" : { + "type" : "boolean" + }, + "unsorted" : { + "type" : "boolean" + } + }, + "type" : "object" + }, + "TokenResponse" : { + "properties" : { + "accessToken" : { + "type" : "string" + }, + "expiresIn" : { + "format" : "int64", + "type" : "integer" + }, + "refreshToken" : { + "type" : "string" + } + }, + "type" : "object" + }, + "UpdateProfileRequest" : { + "properties" : { + "avatarUrl" : { + "example" : "https://cdn.example.com/avatars/john.png", + "maxLength" : 2048, + "minLength" : 0, + "pattern" : "^https?://.+$", + "type" : "string" + }, + "country" : { + "enum" : [ "AD", "AE", "AF", "AG", "AI", "AL", "AM", "AO", "AQ", "AR", "AS", "AT", "AU", "AW", "AX", "AZ", "BA", "BB", "BD", "BE", "BF", "BG", "BH", "BI", "BJ", "BL", "BM", "BN", "BO", "BQ", "BR", "BS", "BT", "BV", "BW", "BY", "BZ", "CA", "CC", "CD", "CF", "CG", "CH", "CI", "CK", "CL", "CM", "CN", "CO", "CR", "CU", "CV", "CW", "CX", "CY", "CZ", "DE", "DJ", "DK", "DM", "DO", "DZ", "EC", "EE", "EG", "EH", "ER", "ES", "ET", "FI", "FJ", "FK", "FM", "FO", "FR", "GA", "GB", "GD", "GE", "GF", "GG", "GH", "GI", "GL", "GM", "GN", "GP", "GQ", "GR", "GS", "GT", "GU", "GW", "GY", "HK", "HM", "HN", "HR", "HT", "HU", "ID", "IE", "IL", "IM", "IN", "IO", "IQ", "IR", "IS", "IT", "JE", "JM", "JO", "JP", "KE", "KG", "KH", "KI", "KM", "KN", "KP", "KR", "KW", "KY", "KZ", "LA", "LB", "LC", "LI", "LK", "LR", "LS", "LT", "LU", "LV", "LY", "MA", "MC", "MD", "ME", "MF", "MG", "MH", "MK", "ML", "MM", "MN", "MO", "MP", "MQ", "MR", "MS", "MT", "MU", "MV", "MW", "MX", "MY", "MZ", "NA", "NC", "NE", "NF", "NG", "NI", "NL", "NO", "NP", "NR", "NU", "NZ", "OM", "PA", "PE", "PF", "PG", "PH", "PK", "PL", "PM", "PN", "PR", "PS", "PT", "PW", "PY", "QA", "RE", "RO", "RS", "RU", "RW", "SA", "SB", "SC", "SD", "SE", "SG", "SH", "SI", "SJ", "SK", "SL", "SM", "SN", "SO", "SR", "SS", "ST", "SV", "SX", "SY", "SZ", "TC", "TD", "TF", "TG", "TH", "TJ", "TK", "TL", "TM", "TN", "TO", "TR", "TT", "TV", "TW", "TZ", "UA", "UG", "UM", "US", "UY", "UZ", "VA", "VC", "VE", "VG", "VI", "VN", "VU", "WF", "WS", "YE", "YT", "ZA", "ZM", "ZW" ], + "example" : "US", + "type" : "string" + }, + "firstName" : { + "example" : "John", + "maxLength" : 50, + "minLength" : 0, + "type" : "string" + }, + "language" : { + "enum" : [ "AA", "AB", "AE", "AF", "AK", "AM", "AN", "AR", "AS", "AV", "AY", "AZ", "BA", "BE", "BG", "BH", "BI", "BM", "BN", "BO", "BR", "BS", "CA", "CE", "CH", "CO", "CR", "CS", "CU", "CV", "CY", "DA", "DE", "DV", "DZ", "EE", "EL", "EN", "EO", "ES", "ET", "EU", "FA", "FF", "FI", "FJ", "FO", "FR", "FY", "GA", "GD", "GL", "GN", "GU", "GV", "HA", "HE", "HI", "HO", "HR", "HT", "HU", "HY", "HZ", "IA", "ID", "IE", "IG", "II", "IK", "IO", "IS", "IT", "IU", "JA", "JV", "KA", "KG", "KI", "KJ", "KK", "KL", "KM", "KN", "KO", "KR", "KS", "KU", "KV", "KW", "KY", "LA", "LB", "LG", "LI", "LN", "LO", "LT", "LU", "LV", "MG", "MH", "MI", "MK", "ML", "MN", "MR", "MS", "MT", "MY", "NA", "NB", "ND", "NE", "NG", "NL", "NN", "NO", "NR", "NV", "NY", "OC", "OJ", "OM", "OR", "OS", "PA", "PI", "PL", "PS", "PT", "QU", "RM", "RN", "RO", "RU", "RW", "SA", "SC", "SD", "SE", "SG", "SI", "SK", "SL", "SM", "SN", "SO", "SQ", "SR", "SS", "ST", "SU", "SV", "SW", "TA", "TE", "TG", "TH", "TI", "TK", "TL", "TN", "TO", "TR", "TS", "TT", "TW", "TY", "UG", "UK", "UR", "UZ", "VE", "VI", "VO", "WA", "WO", "XH", "YI", "YO", "ZA", "ZH", "ZU" ], + "example" : "EN", + "type" : "string" + }, + "lastName" : { + "example" : "Doe", + "maxLength" : 50, + "minLength" : 0, + "type" : "string" + }, + "timezone" : { + "example" : "America/New_York", + "type" : "string" + } + }, + "required" : [ "country", "language", "timezone" ], + "type" : "object" + }, + "UserProfileResponse" : { + "properties" : { + "authProvider" : { + "enum" : [ "LOCAL", "GOOGLE", "FACEBOOK" ], + "type" : "string" + }, + "avatarUrl" : { + "type" : "string" + }, + "country" : { + "enum" : [ "AD", "AE", "AF", "AG", "AI", "AL", "AM", "AO", "AQ", "AR", "AS", "AT", "AU", "AW", "AX", "AZ", "BA", "BB", "BD", "BE", "BF", "BG", "BH", "BI", "BJ", "BL", "BM", "BN", "BO", "BQ", "BR", "BS", "BT", "BV", "BW", "BY", "BZ", "CA", "CC", "CD", "CF", "CG", "CH", "CI", "CK", "CL", "CM", "CN", "CO", "CR", "CU", "CV", "CW", "CX", "CY", "CZ", "DE", "DJ", "DK", "DM", "DO", "DZ", "EC", "EE", "EG", "EH", "ER", "ES", "ET", "FI", "FJ", "FK", "FM", "FO", "FR", "GA", "GB", "GD", "GE", "GF", "GG", "GH", "GI", "GL", "GM", "GN", "GP", "GQ", "GR", "GS", "GT", "GU", "GW", "GY", "HK", "HM", "HN", "HR", "HT", "HU", "ID", "IE", "IL", "IM", "IN", "IO", "IQ", "IR", "IS", "IT", "JE", "JM", "JO", "JP", "KE", "KG", "KH", "KI", "KM", "KN", "KP", "KR", "KW", "KY", "KZ", "LA", "LB", "LC", "LI", "LK", "LR", "LS", "LT", "LU", "LV", "LY", "MA", "MC", "MD", "ME", "MF", "MG", "MH", "MK", "ML", "MM", "MN", "MO", "MP", "MQ", "MR", "MS", "MT", "MU", "MV", "MW", "MX", "MY", "MZ", "NA", "NC", "NE", "NF", "NG", "NI", "NL", "NO", "NP", "NR", "NU", "NZ", "OM", "PA", "PE", "PF", "PG", "PH", "PK", "PL", "PM", "PN", "PR", "PS", "PT", "PW", "PY", "QA", "RE", "RO", "RS", "RU", "RW", "SA", "SB", "SC", "SD", "SE", "SG", "SH", "SI", "SJ", "SK", "SL", "SM", "SN", "SO", "SR", "SS", "ST", "SV", "SX", "SY", "SZ", "TC", "TD", "TF", "TG", "TH", "TJ", "TK", "TL", "TM", "TN", "TO", "TR", "TT", "TV", "TW", "TZ", "UA", "UG", "UM", "US", "UY", "UZ", "VA", "VC", "VE", "VG", "VI", "VN", "VU", "WF", "WS", "YE", "YT", "ZA", "ZM", "ZW" ], + "type" : "string" + }, + "createdAt" : { + "format" : "date-time", + "type" : "string" + }, + "email" : { + "type" : "string" + }, + "emailVerified" : { + "type" : "boolean" + }, + "firstName" : { + "type" : "string" + }, + "id" : { + "format" : "uuid", + "type" : "string" + }, + "language" : { + "enum" : [ "AA", "AB", "AE", "AF", "AK", "AM", "AN", "AR", "AS", "AV", "AY", "AZ", "BA", "BE", "BG", "BH", "BI", "BM", "BN", "BO", "BR", "BS", "CA", "CE", "CH", "CO", "CR", "CS", "CU", "CV", "CY", "DA", "DE", "DV", "DZ", "EE", "EL", "EN", "EO", "ES", "ET", "EU", "FA", "FF", "FI", "FJ", "FO", "FR", "FY", "GA", "GD", "GL", "GN", "GU", "GV", "HA", "HE", "HI", "HO", "HR", "HT", "HU", "HY", "HZ", "IA", "ID", "IE", "IG", "II", "IK", "IO", "IS", "IT", "IU", "JA", "JV", "KA", "KG", "KI", "KJ", "KK", "KL", "KM", "KN", "KO", "KR", "KS", "KU", "KV", "KW", "KY", "LA", "LB", "LG", "LI", "LN", "LO", "LT", "LU", "LV", "MG", "MH", "MI", "MK", "ML", "MN", "MR", "MS", "MT", "MY", "NA", "NB", "ND", "NE", "NG", "NL", "NN", "NO", "NR", "NV", "NY", "OC", "OJ", "OM", "OR", "OS", "PA", "PI", "PL", "PS", "PT", "QU", "RM", "RN", "RO", "RU", "RW", "SA", "SC", "SD", "SE", "SG", "SI", "SK", "SL", "SM", "SN", "SO", "SQ", "SR", "SS", "ST", "SU", "SV", "SW", "TA", "TE", "TG", "TH", "TI", "TK", "TL", "TN", "TO", "TR", "TS", "TT", "TW", "TY", "UG", "UK", "UR", "UZ", "VE", "VI", "VO", "WA", "WO", "XH", "YI", "YO", "ZA", "ZH", "ZU" ], + "type" : "string" + }, + "lastName" : { + "type" : "string" + }, + "scheduledDeletionAt" : { + "format" : "date-time", + "type" : "string" + }, + "timezone" : { + "type" : "string" + }, + "updatedAt" : { + "format" : "date-time", + "type" : "string" + }, + "username" : { + "type" : "string" + } + }, + "type" : "object" + }, + "VerifyEmailRequest" : { + "properties" : { + "code" : { + "maxLength" : 6, + "minLength" : 6, + "type" : "string" + }, + "email" : { + "format" : "email", + "minLength" : 1, + "type" : "string" + } + }, + "required" : [ "code", "email" ], + "type" : "object" + } + }, + "securitySchemes" : { + "bearerAuth" : { + "bearerFormat" : "JWT", + "name" : "bearerAuth", + "scheme" : "bearer", + "type" : "http" + } + } + }, + "info" : { + "description" : "Official API documentation for the Checkers User Service microservice.", + "title" : "Checkers User Service API", + "version" : "v1.0" + }, + "openapi" : "3.1.0", + "paths" : { + "/api/v1/auth/forgot-password" : { + "post" : { + "description" : "Generates a password reset token and sends it to the user's email.", + "operationId" : "forgotPassword", + "requestBody" : { + "content" : { + "application/json" : { + "schema" : { + "$ref" : "#/components/schemas/ForgotPasswordRequest" + } + } + }, + "required" : true + }, + "responses" : { + "200" : { + "description" : "Password reset email sent (if email exists)" + }, + "400" : { + "description" : "Validation error" + } + }, + "summary" : "Forgot password", + "tags" : [ "Auth" ] + } + }, + "/api/v1/auth/login" : { + "post" : { + "description" : "Authenticates with email or username + password. Returns access token (15 min) and refresh token (7 days).", + "operationId" : "login", + "requestBody" : { + "content" : { + "application/json" : { + "schema" : { + "$ref" : "#/components/schemas/LoginRequest" + } + } + }, + "required" : true + }, + "responses" : { + "200" : { + "content" : { + "*/*" : { + "schema" : { + "$ref" : "#/components/schemas/TokenResponse" + } + } + }, + "description" : "Login successful — tokens returned" + }, + "400" : { + "content" : { + "*/*" : { + "schema" : { + "$ref" : "#/components/schemas/TokenResponse" + } + } + }, + "description" : "Validation error — blank fields" + }, + "401" : { + "content" : { + "*/*" : { + "schema" : { + "$ref" : "#/components/schemas/TokenResponse" + } + } + }, + "description" : "Invalid credentials" + }, + "403" : { + "content" : { + "*/*" : { + "schema" : { + "$ref" : "#/components/schemas/TokenResponse" + } + } + }, + "description" : "Account locked" + } + }, + "summary" : "Login", + "tags" : [ "Auth" ] + } + }, + "/api/v1/auth/logout" : { + "post" : { + "description" : "Invalidates the current access token and deletes only the refresh token tied to this session. Other sessions are unaffected. Requires Authorization: Bearer header.", + "operationId" : "logout", + "responses" : { + "204" : { + "description" : "Logged out successfully — current session terminated" + }, + "401" : { + "description" : "Missing or invalid Authorization header" + } + }, + "summary" : "Logout", + "tags" : [ "Auth" ] + } + }, + "/api/v1/auth/logout-all" : { + "post" : { + "description" : "Invalidates the current access token and deletes all refresh tokens for this user, terminating every active session. Requires Authorization: Bearer header.", + "operationId" : "logoutAll", + "responses" : { + "204" : { + "description" : "Logged out successfully — all sessions terminated" + }, + "401" : { + "description" : "Missing or invalid Authorization header" + } + }, + "summary" : "Logout from all sessions", + "tags" : [ "Auth" ] + } + }, + "/api/v1/auth/oauth2/token" : { + "post" : { + "description" : "Exchanges the one-time code from OAuth2 redirect for access and refresh tokens.", + "operationId" : "exchangeOAuth2Code", + "requestBody" : { + "content" : { + "application/json" : { + "schema" : { + "$ref" : "#/components/schemas/OAuth2CodeExchangeRequest" + } + } + }, + "required" : true + }, + "responses" : { + "200" : { + "content" : { + "*/*" : { + "schema" : { + "$ref" : "#/components/schemas/TokenResponse" + } + } + }, + "description" : "OAuth2 code exchanged successfully" + }, + "400" : { + "content" : { + "*/*" : { + "schema" : { + "$ref" : "#/components/schemas/TokenResponse" + } + } + }, + "description" : "Invalid or expired OAuth2 code" + } + }, + "summary" : "Exchange OAuth2 code", + "tags" : [ "Auth" ] + } + }, + "/api/v1/auth/password" : { + "put" : { + "description" : "Changes the user's password. Requires authentication. Can optionally revoke other active sessions. Always returns a new pair of tokens.", + "operationId" : "changePassword", + "requestBody" : { + "content" : { + "application/json" : { + "schema" : { + "$ref" : "#/components/schemas/ChangePasswordRequest" + } + } + }, + "required" : true + }, + "responses" : { + "200" : { + "content" : { + "*/*" : { + "schema" : { + "$ref" : "#/components/schemas/TokenResponse" + } + } + }, + "description" : "Password changed successfully" + }, + "400" : { + "content" : { + "*/*" : { + "schema" : { + "$ref" : "#/components/schemas/TokenResponse" + } + } + }, + "description" : "Validation error" + }, + "401" : { + "content" : { + "*/*" : { + "schema" : { + "$ref" : "#/components/schemas/TokenResponse" + } + } + }, + "description" : "Invalid current password or unauthorized" + } + }, + "summary" : "Change password", + "tags" : [ "Auth" ] + } + }, + "/api/v1/auth/refresh" : { + "post" : { + "description" : "Exchanges a valid refresh token for a new access + refresh token pair. Old refresh token is revoked immediately (rotation).", + "operationId" : "refresh", + "requestBody" : { + "content" : { + "application/json" : { + "schema" : { + "$ref" : "#/components/schemas/RefreshTokenRequest" + } + } + }, + "required" : true + }, + "responses" : { + "200" : { + "content" : { + "*/*" : { + "schema" : { + "$ref" : "#/components/schemas/TokenResponse" + } + } + }, + "description" : "Tokens rotated successfully" + }, + "400" : { + "content" : { + "*/*" : { + "schema" : { + "$ref" : "#/components/schemas/TokenResponse" + } + } + }, + "description" : "Validation error — blank refresh token" + }, + "401" : { + "content" : { + "*/*" : { + "schema" : { + "$ref" : "#/components/schemas/TokenResponse" + } + } + }, + "description" : "Invalid, expired or revoked refresh token" + } + }, + "summary" : "Refresh tokens", + "tags" : [ "Auth" ] + } + }, + "/api/v1/auth/register" : { + "post" : { + "description" : "Creates a new account and sends a verification email", + "operationId" : "register", + "requestBody" : { + "content" : { + "application/json" : { + "schema" : { + "$ref" : "#/components/schemas/RegisterRequest" + } + } + }, + "required" : true + }, + "responses" : { + "201" : { + "content" : { + "*/*" : { + "schema" : { + "$ref" : "#/components/schemas/AuthResponse" + } + } + }, + "description" : "User registered successfully" + }, + "400" : { + "content" : { + "*/*" : { + "schema" : { + "$ref" : "#/components/schemas/AuthResponse" + } + } + }, + "description" : "Validation error" + }, + "409" : { + "content" : { + "*/*" : { + "schema" : { + "$ref" : "#/components/schemas/AuthResponse" + } + } + }, + "description" : "Email or username already exists" + } + }, + "summary" : "Register a new user", + "tags" : [ "Auth" ] + } + }, + "/api/v1/auth/reset-password" : { + "post" : { + "description" : "Resets the user's password using the token sent to their email. Forces logout from all devices.", + "operationId" : "resetPassword", + "requestBody" : { + "content" : { + "application/json" : { + "schema" : { + "$ref" : "#/components/schemas/ResetPasswordRequest" + } + } + }, + "required" : true + }, + "responses" : { + "200" : { + "description" : "Password reset successfully" + }, + "400" : { + "description" : "Invalid or expired token" + } + }, + "summary" : "Reset password", + "tags" : [ "Auth" ] + } + }, + "/api/v1/auth/verify" : { + "post" : { + "description" : "Verifies the user's email address using the 6-digit code sent upon registration.", + "operationId" : "verifyEmail", + "requestBody" : { + "content" : { + "application/json" : { + "schema" : { + "$ref" : "#/components/schemas/VerifyEmailRequest" + } + } + }, + "required" : true + }, + "responses" : { + "200" : { + "description" : "Email verified successfully" + }, + "400" : { + "description" : "Invalid or expired verification code" + } + }, + "summary" : "Verify email", + "tags" : [ "Auth" ] + } + }, + "/api/v1/users/me" : { + "delete" : { + "description" : "Schedules the account for permanent anonymization after a 30-day grace period. Immediately terminates all active sessions. Can be reversed via POST /me/cancel-deletion within the grace window.", + "operationId" : "deleteMyAccount", + "responses" : { + "204" : { + "description" : "Account scheduled for deletion — session terminated" + }, + "401" : { + "description" : "Unauthorized — missing or invalid token" + } + }, + "summary" : "Delete my account", + "tags" : [ "Users" ] + }, + "get" : { + "description" : "Returns the complete private profile of the currently authenticated user, including email and auth provider details.", + "operationId" : "getMyProfile", + "responses" : { + "200" : { + "content" : { + "*/*" : { + "schema" : { + "$ref" : "#/components/schemas/UserProfileResponse" + } + } + }, + "description" : "Profile returned successfully" + }, + "401" : { + "content" : { + "*/*" : { + "schema" : { + "$ref" : "#/components/schemas/UserProfileResponse" + } + } + }, + "description" : "Unauthorized — missing or invalid token" + } + }, + "summary" : "Get my profile", + "tags" : [ "Users" ] + }, + "put" : { + "description" : "Replaces the editable profile fields (firstName, lastName, country, language, timezone, avatarUrl) of the authenticated user. Username and email changes require a separate flow (Phase 5).", + "operationId" : "updateMyProfile", + "requestBody" : { + "content" : { + "application/json" : { + "schema" : { + "$ref" : "#/components/schemas/UpdateProfileRequest" + } + } + }, + "required" : true + }, + "responses" : { + "200" : { + "content" : { + "*/*" : { + "schema" : { + "$ref" : "#/components/schemas/UserProfileResponse" + } + } + }, + "description" : "Profile updated successfully" + }, + "400" : { + "content" : { + "*/*" : { + "schema" : { + "$ref" : "#/components/schemas/UserProfileResponse" + } + } + }, + "description" : "Validation error — invalid field values" + }, + "401" : { + "content" : { + "*/*" : { + "schema" : { + "$ref" : "#/components/schemas/UserProfileResponse" + } + } + }, + "description" : "Unauthorized — missing or invalid token" + } + }, + "summary" : "Update my profile", + "tags" : [ "Users" ] + } + }, + "/api/v1/users/me/audit-log" : { + "get" : { + "description" : "Returns a paginated, time-descending list of security events for the authenticated user's account. Includes login attempts, password changes, e-mail/username changes, and account lifecycle events.", + "operationId" : "getMyAuditLog", + "parameters" : [ { + "in" : "query", + "name" : "page", + "required" : false, + "schema" : { + "default" : 0, + "format" : "int32", + "type" : "integer" + } + }, { + "in" : "query", + "name" : "size", + "required" : false, + "schema" : { + "default" : 20, + "format" : "int32", + "type" : "integer" + } + } ], + "responses" : { + "200" : { + "content" : { + "*/*" : { + "schema" : { + "$ref" : "#/components/schemas/PageAuditEventResponse" + } + } + }, + "description" : "Audit log returned successfully" + }, + "401" : { + "content" : { + "*/*" : { + "schema" : { + "$ref" : "#/components/schemas/PageAuditEventResponse" + } + } + }, + "description" : "Unauthorized — missing or invalid token" + } + }, + "summary" : "Get my audit log", + "tags" : [ "Audit Log" ] + } + }, + "/api/v1/users/me/cancel-deletion" : { + "post" : { + "description" : "Cancels a previously requested account deletion during the 30-day grace period. Restores the account to its normal active state.", + "operationId" : "cancelDeletion", + "responses" : { + "200" : { + "description" : "Account deletion cancelled successfully" + }, + "401" : { + "description" : "Unauthorized — missing or invalid token" + } + }, + "summary" : "Cancel account deletion", + "tags" : [ "Users" ] + } + }, + "/api/v1/users/me/email" : { + "put" : { + "description" : "Stages a new e-mail address and sends a 6-digit confirmation code to it. The change is not applied until confirmed via POST /me/email/confirm. Requires password re-authorization. Not available for OAuth2 accounts.", + "operationId" : "initiateEmailChange", + "requestBody" : { + "content" : { + "application/json" : { + "schema" : { + "$ref" : "#/components/schemas/ChangeEmailRequest" + } + } + }, + "required" : true + }, + "responses" : { + "202" : { + "description" : "Confirmation code sent to the new e-mail address" + }, + "400" : { + "description" : "Validation error" + }, + "401" : { + "description" : "Unauthorized — missing or invalid token, or wrong password" + }, + "409" : { + "description" : "New e-mail address is already in use" + } + }, + "summary" : "Initiate e-mail change", + "tags" : [ "Users" ] + } + }, + "/api/v1/users/me/email/confirm" : { + "post" : { + "description" : "Applies a staged e-mail address change after verifying the 6-digit code sent to the new address. Invalidates all active sessions — re-authentication required.", + "operationId" : "confirmEmailChange", + "requestBody" : { + "content" : { + "application/json" : { + "schema" : { + "$ref" : "#/components/schemas/ConfirmEmailChangeRequest" + } + } + }, + "required" : true + }, + "responses" : { + "204" : { + "description" : "E-mail changed successfully — session invalidated" + }, + "400" : { + "description" : "Invalid or expired confirmation code" + }, + "401" : { + "description" : "Unauthorized — missing or invalid token" + } + }, + "summary" : "Confirm e-mail change", + "tags" : [ "Users" ] + } + }, + "/api/v1/users/me/username" : { + "put" : { + "description" : "Replaces the authenticated user's display name (nickname). No password required — authorized by the JWT. Available for both local and OAuth2 accounts, but limited to one change per cooldown window. Sends a notification e-mail upon success.", + "operationId" : "changeUsername", + "requestBody" : { + "content" : { + "application/json" : { + "schema" : { + "$ref" : "#/components/schemas/ChangeUsernameRequest" + } + } + }, + "required" : true + }, + "responses" : { + "200" : { + "content" : { + "*/*" : { + "schema" : { + "$ref" : "#/components/schemas/UserProfileResponse" + } + } + }, + "description" : "Username changed successfully" + }, + "400" : { + "content" : { + "*/*" : { + "schema" : { + "$ref" : "#/components/schemas/UserProfileResponse" + } + } + }, + "description" : "Validation error" + }, + "401" : { + "content" : { + "*/*" : { + "schema" : { + "$ref" : "#/components/schemas/UserProfileResponse" + } + } + }, + "description" : "Unauthorized — missing or invalid token" + }, + "409" : { + "content" : { + "*/*" : { + "schema" : { + "$ref" : "#/components/schemas/UserProfileResponse" + } + } + }, + "description" : "Requested username is already taken" + }, + "429" : { + "content" : { + "*/*" : { + "schema" : { + "$ref" : "#/components/schemas/UserProfileResponse" + } + } + }, + "description" : "Username changed too recently — see Retry-After header" + } + }, + "summary" : "Change username", + "tags" : [ "Users" ] + } + }, + "/api/v1/users/{id}" : { + "get" : { + "description" : "Returns the public profile of a player by their UUID. Requires authentication to prevent scraping. Omits email, authProvider and emailVerified.", + "operationId" : "getPublicProfile", + "parameters" : [ { + "in" : "path", + "name" : "id", + "required" : true, + "schema" : { + "format" : "uuid", + "type" : "string" + } + } ], + "responses" : { + "200" : { + "content" : { + "*/*" : { + "schema" : { + "$ref" : "#/components/schemas/PublicUserProfileResponse" + } + } + }, + "description" : "Public profile returned successfully" + }, + "401" : { + "content" : { + "*/*" : { + "schema" : { + "$ref" : "#/components/schemas/PublicUserProfileResponse" + } + } + }, + "description" : "Unauthorized — missing or invalid token" + }, + "404" : { + "content" : { + "*/*" : { + "schema" : { + "$ref" : "#/components/schemas/PublicUserProfileResponse" + } + } + }, + "description" : "User not found" + } + }, + "summary" : "Get public profile", + "tags" : [ "Users" ] + } + } + }, + "security" : [ { + "bearerAuth" : [ ] + } ], + "servers" : [ { + "url" : "/" + } ], + "tags" : [ { + "description" : "User profile management endpoints", + "name" : "Users" + }, { + "description" : "Authentication & registration endpoints", + "name" : "Auth" + }, { + "description" : "Security event history for the authenticated user", + "name" : "Audit Log" + } ] +} \ No newline at end of file diff --git a/checkers-contracts/ws/v1/README.md b/checkers-contracts/ws/v1/README.md new file mode 100644 index 0000000..de21379 --- /dev/null +++ b/checkers-contracts/ws/v1/README.md @@ -0,0 +1,25 @@ +# WebSocket contracts v1 + +These JSON Schemas describe every server-to-client STOMP payload in the platform. Both publishers +send application DTOs through Spring's Jackson-backed STOMP message converter. + +| Service | Destination | Direction | Schema | +|---|---|---|---| +| `game-service` | `/topic/games/{gameId}` | server to subscribed client | `game-state.schema.json` | +| `matchmaking-service` | `/user/queue/matchmaking` | server to authenticated user | `match-found.schema.json` | + +`game-state` is the complete authoritative `GameResponse` snapshot. `match-found` is a best-effort, +player-specific notification; clients recover missed delivery through the matchmaking REST status +endpoint. + +## Evolution rules + +- Keep v1 changes additive: existing property names, JSON types, enum values and meanings remain stable. +- Add optional properties when extending a payload so old clients can continue to consume it. +- Update the schema and its service contract test in the same change as an additive DTO extension. +- Publish incompatible replacements under `ws/v2` and on versioned destinations rather than changing v1. +- Treat destination names and routing direction as part of the contract. + +Each publishing service serializes its real DTO with the application `ObjectMapper` and validates the +result against the corresponding schema during `mvn verify`. This catches DTO/schema drift before a +payload reaches clients. diff --git a/checkers-contracts/ws/v1/game-state.schema.json b/checkers-contracts/ws/v1/game-state.schema.json new file mode 100644 index 0000000..8b92732 --- /dev/null +++ b/checkers-contracts/ws/v1/game-state.schema.json @@ -0,0 +1,64 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://checkers.example/contracts/ws/v1/game-state.schema.json", + "title": "Game state v1", + "description": "Authoritative game snapshot published by game-service.", + "type": "object", + "additionalProperties": false, + "required": [ + "id", "variant", "status", "whitePlayer", "blackPlayer", "timeControl", "clock", + "moves", "currentFen", "legalMoves", "currentTurn", "result", "endReason", + "createdAt", "finishedAt" + ], + "properties": { + "id": { "type": "string", "format": "uuid" }, + "variant": { "$ref": "#/$defs/variant" }, + "status": { "enum": ["WAITING_FOR_OPPONENT", "IN_PROGRESS", "FINISHED"] }, + "whitePlayer": { "oneOf": [{ "$ref": "#/$defs/player" }, { "type": "null" }] }, + "blackPlayer": { "oneOf": [{ "$ref": "#/$defs/player" }, { "type": "null" }] }, + "timeControl": { "oneOf": [{ "$ref": "#/$defs/timeControl" }, { "type": "null" }] }, + "clock": { "oneOf": [{ "$ref": "#/$defs/clock" }, { "type": "null" }] }, + "moves": { "type": "array", "items": { "type": "string" } }, + "currentFen": { "type": "string", "minLength": 1 }, + "legalMoves": { "type": "array", "items": { "type": "string" } }, + "currentTurn": { "type": ["string", "null"], "enum": ["WHITE", "BLACK", null] }, + "result": { "type": ["string", "null"], "enum": ["WHITE_WON", "BLACK_WON", "DRAW", null] }, + "endReason": { + "type": ["string", "null"], + "enum": ["NORMAL", "RESIGNATION", "TIMEOUT", "ABANDONMENT", null] + }, + "createdAt": { "type": "string", "format": "date-time" }, + "finishedAt": { "type": ["string", "null"], "format": "date-time" } + }, + "$defs": { + "variant": { + "enum": [ + "STANDARD", "AMERICAN", "FRISIAN", "RUSSIAN", "BRAZILIAN", "ANTIDRAUGHTS", + "BREAKTHROUGH", "FRYSK" + ] + }, + "timeControl": { + "enum": ["BULLET_1_0", "BLITZ_3_0", "BLITZ_5_0", "RAPID_10_0", "CLASSICAL_30_0"] + }, + "player": { + "type": "object", + "additionalProperties": false, + "required": ["type", "userId", "aiDifficulty"], + "properties": { + "type": { "enum": ["HUMAN", "AI"] }, + "userId": { "type": ["string", "null"], "format": "uuid" }, + "aiDifficulty": { "type": ["integer", "null"] } + } + }, + "clock": { + "type": "object", + "additionalProperties": false, + "required": ["whiteRemainingMillis", "blackRemainingMillis", "lastSwitchAt"], + "properties": { + "whiteRemainingMillis": { "type": "integer", "minimum": 0 }, + "blackRemainingMillis": { "type": "integer", "minimum": 0 }, + "lastSwitchAt": { "type": "string", "format": "date-time" } + } + } + } +} diff --git a/checkers-contracts/ws/v1/match-found.schema.json b/checkers-contracts/ws/v1/match-found.schema.json new file mode 100644 index 0000000..66d92c0 --- /dev/null +++ b/checkers-contracts/ws/v1/match-found.schema.json @@ -0,0 +1,23 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://checkers.example/contracts/ws/v1/match-found.schema.json", + "title": "Match found v1", + "description": "Player-specific notification published by matchmaking-service after game creation.", + "type": "object", + "additionalProperties": false, + "required": ["gameId", "color", "opponentId", "variant", "timeControl"], + "properties": { + "gameId": { "type": "string", "format": "uuid" }, + "color": { "enum": ["WHITE", "BLACK"] }, + "opponentId": { "type": "string", "format": "uuid" }, + "variant": { + "enum": [ + "STANDARD", "AMERICAN", "FRISIAN", "RUSSIAN", "BRAZILIAN", "ANTIDRAUGHTS", + "BREAKTHROUGH", "FRYSK" + ] + }, + "timeControl": { + "enum": ["BULLET_1_0", "BLITZ_3_0", "BLITZ_5_0", "RAPID_10_0", "CLASSICAL_30_0"] + } + } +} diff --git a/services/game-service/README.md b/services/game-service/README.md index dff6d11..9277f16 100644 --- a/services/game-service/README.md +++ b/services/game-service/README.md @@ -1003,9 +1003,15 @@ cd services/game-service ./mvnw verify ``` +`GameControllerMockMvcTest#snapshotsOpenApiContract` regenerates +`checkers-contracts/openapi/game-service.json`; CI rejects uncommitted drift and incompatible changes. + The build generates gRPC stubs, runs the suite and enforces **100% line and branch coverage** for handwritten service code via JaCoCo. **Docker must be running** (Testcontainers + embedded WebSocket -server). Layers covered (194 tests): +server). Layers covered (214 tests): + +The suite also serializes the real game-state WebSocket DTO with the application Jackson +configuration and validates it against `checkers-contracts/ws/v1/game-state.schema.json`. - **Unit (Mockito):** game lifecycle, moves and completion, AI turns, ELO reporting, deadline and abandonment managers, the domain model, runtime support (clocks, locks, executors) and the variant diff --git a/services/game-service/pom.xml b/services/game-service/pom.xml index cb9c9db..1112036 100644 --- a/services/game-service/pom.xml +++ b/services/game-service/pom.xml @@ -5,7 +5,7 @@ org.springframework.boot spring-boot-starter-parent - 3.5.11 + 3.5.16 com.checkers @@ -16,7 +16,7 @@ 21 1.82.1 - 4.29.3 + 4.35.1 0.8.15 @@ -43,7 +43,7 @@ org.springdoc springdoc-openapi-starter-webmvc-ui - 2.8.6 + 2.8.17 org.springframework.boot @@ -79,18 +79,18 @@ io.jsonwebtoken jjwt-api - 0.12.6 + 0.13.0 io.jsonwebtoken jjwt-impl - 0.12.6 + 0.13.0 runtime io.jsonwebtoken jjwt-jackson - 0.12.6 + 0.13.0 runtime @@ -128,6 +128,12 @@ spring-boot-starter-test test + + com.networknt + json-schema-validator + 1.5.6 + test + org.springframework.security spring-security-test diff --git a/services/game-service/src/test/java/com/checkers/gameservice/game/controller/GameControllerMockMvcTest.java b/services/game-service/src/test/java/com/checkers/gameservice/game/controller/GameControllerMockMvcTest.java index 2918674..4ee908a 100644 --- a/services/game-service/src/test/java/com/checkers/gameservice/game/controller/GameControllerMockMvcTest.java +++ b/services/game-service/src/test/java/com/checkers/gameservice/game/controller/GameControllerMockMvcTest.java @@ -15,6 +15,10 @@ import com.checkers.gameservice.game.entity.PlayerColor; import com.checkers.gameservice.game.entity.TimeControl; import com.checkers.gameservice.game.service.GameService; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.node.ArrayNode; +import com.fasterxml.jackson.databind.node.ObjectNode; import com.checkers.gameservice.game.archive.FinishedGameHistoryService; import io.jsonwebtoken.Jwts; import io.jsonwebtoken.security.Keys; @@ -27,9 +31,13 @@ import org.springframework.test.web.servlet.MockMvc; import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; import java.time.Instant; import java.util.Date; import java.util.List; +import java.util.Map; +import java.util.TreeMap; import java.util.UUID; import static org.mockito.ArgumentMatchers.any; @@ -50,6 +58,7 @@ class GameControllerMockMvcTest { private static final UUID GAME_ID = UUID.fromString("00000000-0000-0000-0000-000000001301"); @Autowired private MockMvc mockMvc; + @Autowired private ObjectMapper objectMapper; @MockitoBean private GameService gameService; @MockitoBean private GameResponseMapper responseMapper; @MockitoBean private FinishedGameHistoryService historyService; @@ -100,6 +109,40 @@ void servesOpenApiWithoutAuthenticationAndDocumentsBearerSecurity() throws Excep .andExpect(jsonPath("$['paths']['/api/v1/games/history']['get']").exists()); } + @Test + void snapshotsOpenApiContract() throws Exception { + String document = mockMvc.perform(get("/v3/api-docs")) + .andExpect(status().isOk()) + .andReturn().getResponse().getContentAsString(); + + Files.createDirectories(openApiSnapshotPath().getParent()); + objectMapper.writerWithDefaultPrettyPrinter() + .writeValue(openApiSnapshotPath().toFile(), canonicalize(objectMapper.readTree(document))); + } + + private JsonNode canonicalize(JsonNode node) { + if (node.isObject()) { + Map fields = new TreeMap<>(); + node.properties().forEach(field -> fields.put(field.getKey(), field.getValue())); + ObjectNode result = objectMapper.createObjectNode(); + fields.forEach((name, value) -> result.set(name, canonicalize(value))); + return result; + } + if (node.isArray()) { + ArrayNode result = objectMapper.createArrayNode(); + node.forEach(value -> result.add(canonicalize(value))); + return result; + } + return node; + } + + private static Path openApiSnapshotPath() { + Path fromRoot = Path.of("checkers-contracts/openapi/game-service.json"); + return Files.exists(Path.of("checkers-contracts")) + ? fromRoot + : Path.of("../../checkers-contracts/openapi/game-service.json"); + } + @Test void delegatesAllLifecycleRequestsWithAuthenticatedIdentity() throws Exception { Game game = org.mockito.Mockito.mock(Game.class); diff --git a/services/game-service/src/test/java/com/checkers/gameservice/game/websocket/GameStateContractTest.java b/services/game-service/src/test/java/com/checkers/gameservice/game/websocket/GameStateContractTest.java new file mode 100644 index 0000000..0959546 --- /dev/null +++ b/services/game-service/src/test/java/com/checkers/gameservice/game/websocket/GameStateContractTest.java @@ -0,0 +1,75 @@ +package com.checkers.gameservice.game.websocket; + +import com.checkers.gameservice.game.dto.GameClockResponse; +import com.checkers.gameservice.game.dto.GameResponse; +import com.checkers.gameservice.game.dto.PlayerResponse; +import com.checkers.gameservice.game.dto.PlayerType; +import com.checkers.gameservice.game.entity.GameStatus; +import com.checkers.gameservice.game.entity.GameVariant; +import com.checkers.gameservice.game.entity.PlayerColor; +import com.checkers.gameservice.game.entity.TimeControl; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.networknt.schema.JsonSchema; +import com.networknt.schema.JsonSchemaFactory; +import com.networknt.schema.SpecVersion; +import com.networknt.schema.ValidationMessage; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.json.JsonTest; + +import java.nio.file.Files; +import java.nio.file.Path; +import java.time.Instant; +import java.util.List; +import java.util.Set; +import java.util.UUID; + +import static org.junit.jupiter.api.Assertions.assertTrue; + +@JsonTest +class GameStateContractTest { + + @Autowired + private ObjectMapper objectMapper; + + @Test + void applicationSerializationMatchesGameStateV1Schema() throws Exception { + Instant now = Instant.parse("2026-07-06T10:15:30Z"); + GameResponse payload = new GameResponse( + UUID.fromString("00000000-0000-0000-0000-000000000001"), + GameVariant.STANDARD, + GameStatus.IN_PROGRESS, + new PlayerResponse( + PlayerType.HUMAN, + UUID.fromString("00000000-0000-0000-0000-000000000002"), + null), + new PlayerResponse(PlayerType.AI, null, 3), + TimeControl.BLITZ_5_0, + new GameClockResponse(299_000, 300_000, now), + List.of("31-27"), + "W:W27,32:B1,6", + List.of("27-23", "27-24"), + PlayerColor.WHITE, + null, + null, + now, + null); + + Set errors = schema().validate(objectMapper.valueToTree(payload)); + + assertTrue(errors.isEmpty(), () -> "game-state schema violations: " + errors); + } + + private JsonSchema schema() throws Exception { + JsonNode schemaNode = objectMapper.readTree(Files.readString(contractPath())); + return JsonSchemaFactory.getInstance(SpecVersion.VersionFlag.V202012).getSchema(schemaNode); + } + + private static Path contractPath() { + Path fromRoot = Path.of("checkers-contracts/ws/v1/game-state.schema.json"); + return Files.exists(fromRoot) + ? fromRoot + : Path.of("../../checkers-contracts/ws/v1/game-state.schema.json"); + } +} diff --git a/services/matchmaking-service/README.md b/services/matchmaking-service/README.md index a90cf15..415d1ce 100644 --- a/services/matchmaking-service/README.md +++ b/services/matchmaking-service/README.md @@ -671,9 +671,16 @@ cd services/matchmaking-service ./mvnw verify ``` +`QueueControllerMockMvcTest#snapshotsOpenApiContract` regenerates +`checkers-contracts/openapi/matchmaking-service.json`; CI rejects uncommitted drift and incompatible +changes. + The build generates gRPC stubs, runs the suite and enforces **100% line and branch coverage** for handwritten service code via JaCoCo. Docker must be running for Redis Testcontainers. Layers covered -(102 tests): +(105 tests): + +The suite also serializes the real match-found WebSocket DTO with the application Jackson +configuration and validates it against `checkers-contracts/ws/v1/match-found.schema.json`. - **Unit (Mockito):** queue service, ticket invariants, ELO window policy, matcher decisions, scheduler error isolation, color assignment, presence registry/listener and disconnect grace. diff --git a/services/matchmaking-service/pom.xml b/services/matchmaking-service/pom.xml index f25d130..132d290 100644 --- a/services/matchmaking-service/pom.xml +++ b/services/matchmaking-service/pom.xml @@ -5,7 +5,7 @@ org.springframework.boot spring-boot-starter-parent - 3.5.11 + 3.5.16 com.checkers @@ -17,7 +17,7 @@ 21 1.82.1 4.35.1 - 0.8.13 + 0.8.15 com.checkers @@ -29,7 +29,7 @@ 21 1.82.1 - 4.29.3 + 4.35.1 0.8.15 @@ -73,7 +73,7 @@ org.springdoc springdoc-openapi-starter-webmvc-ui - 2.8.6 + 2.8.17 @@ -128,18 +128,18 @@ io.jsonwebtoken jjwt-api - 0.12.6 + 0.13.0 io.jsonwebtoken jjwt-impl - 0.12.6 + 0.13.0 runtime io.jsonwebtoken jjwt-jackson - 0.12.6 + 0.13.0 runtime diff --git a/services/user-service/src/test/java/com/checkers/userservice/contract/OpenApiSnapshotTest.java b/services/user-service/src/test/java/com/checkers/userservice/contract/OpenApiSnapshotTest.java new file mode 100644 index 0000000..bd3720b --- /dev/null +++ b/services/user-service/src/test/java/com/checkers/userservice/contract/OpenApiSnapshotTest.java @@ -0,0 +1,63 @@ +package com.checkers.userservice.contract; + +import com.checkers.userservice.AbstractIntegrationTest; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.node.ArrayNode; +import com.fasterxml.jackson.databind.node.ObjectNode; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.web.client.TestRestTemplate; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; + +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.Map; +import java.util.TreeMap; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; + +class OpenApiSnapshotTest extends AbstractIntegrationTest { + + @Autowired + private TestRestTemplate restTemplate; + + @Autowired + private ObjectMapper objectMapper; + + @Test + void snapshotsOpenApiContract() throws Exception { + ResponseEntity response = restTemplate.getForEntity("/v3/api-docs", String.class); + + assertEquals(HttpStatus.OK, response.getStatusCode()); + assertNotNull(response.getBody()); + Files.createDirectories(openApiSnapshotPath().getParent()); + objectMapper.writerWithDefaultPrettyPrinter().writeValue( + openApiSnapshotPath().toFile(), canonicalize(objectMapper.readTree(response.getBody()))); + } + + private JsonNode canonicalize(JsonNode node) { + if (node.isObject()) { + Map fields = new TreeMap<>(); + node.properties().forEach(field -> fields.put(field.getKey(), field.getValue())); + ObjectNode result = objectMapper.createObjectNode(); + fields.forEach((name, value) -> result.set(name, canonicalize(value))); + return result; + } + if (node.isArray()) { + ArrayNode result = objectMapper.createArrayNode(); + node.forEach(value -> result.add(canonicalize(value))); + return result; + } + return node; + } + + private static Path openApiSnapshotPath() { + Path fromRoot = Path.of("checkers-contracts/openapi/user-service.json"); + return Files.exists(Path.of("checkers-contracts")) + ? fromRoot + : Path.of("../../checkers-contracts/openapi/user-service.json"); + } +}