This document describes the RESTful API endpoints provided by the User Service of the Pong application. The service is built using Fastify and provides user management functionalities including registration, authentication, profile management, and user listing.
Tip
For frontend integration, the base URL to use is https://api:3000/. Also, container authentication is done via API key in the x-internal-api-key header. Such key is set in the environment variable INTERNAL_API_KEY.
Tip
All endpoints except /api/users/register and /api/users/login require user authentication via JWT in the Authorization header as Bearer {token}. Such token is obtained upon successful login.
Request body:
{
"username": "string",
"password": "string"
}Response body:
{
"id": "number",
"username": "string",
"created_at": "string"
}Error responses:
409: Username already exists
Request body:
{
"username": "string",
"password": "string"
}Response body:
{
"token": "string"
}Error responses:
401: Password not valid404: User not found
Response body:
{
"message": "Logged out successfully"
}Error responses:
401: Invalid token header
Response body:
[
{
"id": "number",
"username": "string",
"display_name": "string",
"avatar_url": "string",
"bio": "string",
"created_at": "string",
"is_active": "boolean"
}
]Error responses:
401: Invalid token header
Request body:
{
"tournament_id": "number",
"match_id": "number",
"match_date": "string",
"a_participant_id": "number",
"b_participant_id": "number",
"a_participant_alias": "string",
"b_participant_alias": "string",
"a_participant_score": "number",
"b_participant_score": "number",
"winner_id": "number",
"loser_id": "number"
}Response body:
{
"tournament_id": "number",
"match_id": "number",
"match_date": "string",
"a_participant_id": "number",
"b_participant_id": "number",
"a_participant_alias": "string",
"b_participant_alias": "string",
"a_participant_score": "number",
"b_participant_score": "number",
"winner_id": "number",
"loser_id": "number"
}Error responses:
401: Invalid token header400: Bad request304: Both participants are bots
Response body:
{
"username": "string",
"display_name": "string",
"avatar_url": "string",
"bio": "string",
"created_at": "string",
"is_active": "boolean"
}Error responses:
401: Invalid token header404: User not found
Request body:
{
"display_name": "string",
"avatar_url": "string",
"bio": "string"
}Response body:
{
"username": "string",
"display_name": "string",
"avatar_url": "string",
"bio": "string",
"created_at": "string"
}Error responses:
401: Invalid token header404: User not found403: User not authorized
Response body:
{
"message": "User deleted"
}Error responses:
401: Invalid token header404: User not found403: User not authorized
Response body:
{
"total_games": "number",
"wins": "number",
"losses": "number"
}Error responses:
401: Invalid token header404: User not found
Query parameter filter:
confirmed: returns only confirmed friendspending: returns only pending friend requestsrequested: returns only sent friend requests
[!TIP]
Use the
filterquery parameter adding?filter=<value>to the endpoint URL.
Response body:
[
{
"id": "number",
"username": "string",
"display_name": "string",
"avatar_url": "string",
"confirmed": "boolean",
"requested_by_id": "number"
}
]Error responses:
401: Invalid token header404: User not found
Response body:
[
{
"tournament_id": "number",
"match_id": "number",
"match_date": "string",
"opponent_username": "string",
"user_score": "string",
"opponent_score": "string",
"result": "win|loss",
}
]Error responses:
401: Invalid token header404: User not found
Manages friend requests.
Query parameter action:
add: send or accept a friend requestremove: remove a friend or cancel a pending friend request
Response body:
{
"message": "string"
}Error responses:
401: Invalid token header400: Invalid action400: Cannot send friend request to oneself400: Cannot remove oneself from friends400: Users are already friends409: Friend request already sent400: Cannot remove a friend who is not in the friend list404: User not found
Base (via API Gateway):
https://localhost/api/tournaments/*
Auth:Authorization: Bearer <jwt>(end‑user) orx-internal-api-key: <INTERNAL_API_KEY>(service-to-service).
CORS:OPTIONS /api/tournaments/*is handled at the gateway (no auth,204with CORS headers). Services do not handle CORS.
MVP note: Participant count must be a power of two before
start(e.g., 2/4/8/16…). Otherwise400withparticipants_not_power_of_two.
POST /api/tournaments
Creates a tournament in draft status.
Request body
{
"mode": "single_elimination",
"points_to_win": 11,
"owner_user_id": null
}mode— currently only"single_elimination"points_to_win— integer1..21(inclusive)owner_user_id— integer ID ornull(no cross‑DB FK)
Responses
201{ "id": 1 }400— validation error
GET /api/tournaments/{id}
Responses
200{ "id": 1, "owner_user_id": null, "mode": "single_elimination", "points_to_win": 11, "status": "draft", "created_at": "2025-10-10T00:00:00.000Z" }404{ "status": "not_found" }
Params
{id}— integer>= 1(invalid returns400)
Aliases let humans or bots join a draft tournament. Display names are unique per tournament.
POST /api/tournaments/{id}/participants
Request body
{
"display_name": "Alice",
"is_bot": false
}display_name— non-empty string, max 64is_bot— boolean (defaultfalse)
Responses
201{ "id": 7 }404{ "status": "not_found" }— unknown tournament409{ "status": "conflict", "message": "alias already joined" }
GET /api/tournaments/{id}/participants
Responses
200[ { "id": 7, "tournament_id": 1, "display_name": "Alice", "joined_at": "2025-10-11T00:00:00.000Z", "is_bot": false } ]404{ "status": "not_found" }
DELETE /api/tournaments/{id}/participants/{participant_id}
Responses
204(no body) — removed404{ "status": "not_found" }— participant not in this tournament
Params
{id},{participant_id}— integers>= 1(invalid returns400)
POST /api/tournaments/{id}/start
Transitions a draft tournament to active and creates round 1 matches with deterministic seeding (join order).
Responses
200{ "status": "active", "rounds": 2, "matches_created": 2 }400or{ "status": "bad_request", "message": "need_at_least_2_participants" }{ "status": "bad_request", "message": "participants_not_power_of_two" }404{ "status": "not_found" }— unknown tournament409{ "status": "conflict", "message": "already_started" }— not indraft
GET /api/tournaments/{id}/matches?round={n}
Responses
200[ { "id": 12, "tournament_id": 1, "round": 1, "order_index": 0, "a_participant_id": 3, "b_participant_id": 4, "status": "scheduled", "score_a": null, "score_b": null, "winner_participant_id": null, "updated_at": "2025-10-11T13:10:00.000Z" } ]404{ "status": "not_found" }— unknown tournament
Params
{id}— integer>= 1round— optional integer>= 1(invalid returns400)
POST /api/tournaments/{id}/matches/{mid}/score
Finishes a scheduled/in_progress match, records the score, places the winner into the next round’s container match (creating it if needed), and completes the tournament when the final ends.
Request body
{ "score_a": 11, "score_b": 7 }Responses
200— the finished match entity (see above)400— bad request, e.g.{ "status":"bad_request","message":"tie_not_allowed" }or tournament not active404— unknown tournament or match{ "status":"not_found" }409— match already finished{ "status":"conflict","message":"match_already_finished" }
Params
{id},{mid}— integers>= 1
On successful scoring, the service optionally reports the result to the Users domain (via the API Gateway) depending on the participants and the REPORT_GUEST_AS_ZERO flag (set both in the .env and in the docker-compose.tournaments.yml):
| Scenario | Reporting behavior |
|---|---|
User vs User (both sides have real user IDs > 0) |
Report: includes winner_user_id and loser_user_id (both real) |
User vs Guest (exactly one side has real user ID > 0) & REPORT_GUEST_AS_ZERO = true |
Report: includes the real user; the guest is sent as 0 |
User vs Guest & REPORT_GUEST_AS_ZERO = false |
No report |
| Guest vs Guest (no real user IDs) | No report |
A “real user” means a participant that resolves to a numeric user ID > 0. Participants mapped to
0are considered guests.
With this approach, the Users domain can safely ignore any payloads where user_id is 0, without modifying its existing schema or business logic.
If you need to change this behavior later, set REPORT_GUEST_AS_ZERO=false to stop emitting reports for user-vs-guest matches (if zero as user ID becomes unacceptable).
GET /api/tournaments/{id}/next
Responses
200— a single scheduled match entity (earliest byround, order_index, id)204— no scheduled matches remain404—{ "status": "not_found" }
Set in .env:
BLOCKCHAIN_REPORT_ENABLED=true
When enabled, the tournaments service reports the final result (when the bracket completes) to the blockchain service via: POST /api/blockchain/finals (using the internal gateway API key)
Errors are swallowed; tournament scoring is never blocked by reporting failures.
Timeouts & retries: The reporter is non-blocking and uses short HTTP timeouts and bounded retries when calling /api/blockchain/finals. Failures are logged and swallowed; tournament scoring still returns 200/204.
Points to win: The reporter forwards the tournament’s configured points_to_win together with the final score/winner.
Exposed for internal checks; through the gateway they’re protected by the same auth as other endpoints.
GET https://localhost/api/tournaments/health→{"status":"ok"}GET https://localhost/api/tournaments/health/db→ DB connectivityOPTIONS https://localhost/api/tournaments/*→204+ CORS headers (no auth)
- SQLite via better-sqlite3 (
PRAGMA foreign_keys=ON); timestamps are ISO TEXT. - No CORS handling inside services; the API Gateway owns preflight and headers.
- No auth enforced inside service business routes; the Gateway owns auth.
- Status codes:
201creates,204deletes,200for OK,400/404/409for errors (JSON),500unexpected.
# Create a draft tournament
curl -sk -H "x-internal-api-key: $INTERNAL_API_KEY" -H 'content-type: application/json' -X POST https://localhost/api/tournaments --data '{"mode":"single_elimination","points_to_win":11}'
# Join with an alias
curl -sk -H "x-internal-api-key: $INTERNAL_API_KEY" -H 'content-type: application/json' -X POST https://localhost/api/tournaments/1/participants --data '{"display_name":"Alice"}'
# List participants
curl -sk -H "x-internal-api-key: $INTERNAL_API_KEY" https://localhost/api/tournaments/1/participants
# Remove a participant
curl -sk -H "x-internal-api-key: $INTERNAL_API_KEY" -X DELETE https://localhost/api/tournaments/1/participants/7
# Start a tournament
curl -sk -H "x-internal-api-key: $INTERNAL_API_KEY" -X POST https://localhost/api/tournaments/1/start
# List round 1 matches
curl -sk -H "x-internal-api-key: $INTERNAL_API_KEY" "https://localhost/api/tournaments/1/matches?round=1"
# Score a match
curl -sk -H "x-internal-api-key: $INTERNAL_API_KEY" -H 'content-type: application/json' -X POST https://localhost/api/tournaments/1/matches/12/score --data '{"score_a":11,"score_b":7}'
# Next scheduled match
curl -sk -H "x-internal-api-key: $INTERNAL_API_KEY" https://localhost/api/tournaments/1/nexthttps://localhost/#/tournament-dev
1
Health checks
Click GET /api/tournaments/health --- exepct HTTP 200 {"status":"ok"}
Click GET /api/tournaments/health/db --- expect HTTP 200 {"status":"ok"}
2
Create a tournament
Click POST /api/tournaments
Expect HTTP 201 { "id": <N> }
Copy <N> into the Tournament id inptu
3
Join participants (x4)
In Participant alias, enter A --- POST /:id/participants --- expect HTTP 201 { "id": <pidA> }
Repeat for B, C, D
Click GET /:id/participants --- expect 4 rows (A,B,C,D with their ids)
4
Start the tournament
Click POST /:id/start
Expect HTTP 200 { "status":"active", "rounds": 2, "matches_created": 2 }
5
List round 1 matches
In Round, eneter 1
Click GET /:id/matches?round=N
Expect 2 matches (both status: "scheduled"). Note their match ids (e.g., 5 and 6)
6
Next scheduled
Click GET /:id/next
Expect one match (a round-1 scheuled match)
7
Score round 1
In Match id (mid), enter the first R1 id (e.g., 5)
Set Score A/B (e.g., 11 and 7)
Click POST /:id/matches/:mid/score --- expect HTTP 200 and the finished match (with winner_participant_id)
Repeat for second R1 match (e.g., 6)
8
List the final (round 2)
Set Round = 2
GET /:id/matches?round=N --- expect 1 final match (scheduled)
9
Next scheduled (final)
GET /:id/next --- returns that final match
10
Score the final
Put final mid in Match id
Enter scores (no ties; e.g., 11 vs 9)
POST /:id/matches/:mid/score --- expect HTTP 200 (now tournament may compelete)
11
Tha’s it, no matches left
GET /:id/next --- expect HTTP 204
GET /:id --- expect status: "completed"
12
You can also try accessing a match not started, add duplicates etc to test for possible errors.
It is all done in the test suite but thgere’s no harm in trying ))
Base: https://localhost/api/blockchain/*
Auth: required — Authorization: Bearer <jwt> OR x-internal-api-key: <key>.
CORS:
OPTIONS /api/blockchain/*is handled at the API Gateway (no auth,204with CORS headers). Services do not handle CORS. Rate limit: Standard gateway rate limiting applies to/api/blockchain/*(tests tolerate either429or403when the limiter is tripped).
GET /health → 200 {"status":"ok"}
GET /abi/TournamentRegistry → 200 [ ...abi items... ]
Example
curl -sk -H "x-internal-api-key: $INTERNAL_API_KEY"
https://localhost/api/blockchain/abi/TournamentRegistry | jq '.[0]'
POST /finals
Body:
{
"tournament_id": 42,
"winner_alias": "alice",
"score_a": 3,
"score_b": 1,
"points_to_win": 3
}
All fields are required; tournament_id must be a positive integer.
Responses:
201 {"txHash":"0xmock_42_<timestamp>"}on success400invalid body401missing/invalid auth409 {"error":"already_recorded"}when the final for thetournament_idwas already stored (idempotency)
Idempotency example (second call → 409)
# first call succeeds
curl -sk -H "x-internal-api-key: $INTERNAL_API_KEY" \
-H "content-type: application/json" \
-d '{"tournament_id":42,"winner_alias":"alice","score_a":3,"score_b":1,"points_to_win":3}' \
https://localhost/api/blockchain/finals
# second call with the same tournament_id returns 409
curl -sk -H "x-internal-api-key: $INTERNAL_API_KEY" \
-H "content-type: application/json" \
-d '{"tournament_id":42,"winner_alias":"alice","score_a":3,"score_b":1,"points_to_win":3}' \
-w '\\nHTTP %{http_code}\\n' \
https://localhost/api/blockchain/finalsExample
curl -sk -H "x-internal-api-key: $INTERNAL_API_KEY"
-H "content-type: application/json"
-d '{"tournament_id":42,"winner_alias":"alice","score_a":3,"score_b":1,"points_to_win":3}'
https://localhost/api/blockchain/finals
GET /finals/:tournament_id →
200 {"winner_alias":"alice","score_a":3,"score_b":1,"points_to_win":3,"exists":true}401missing/invalid auth404if not recorded
GET /config → returns blockchain feature/connection diagnostics.
Successful response:
{
"enabled": false,
"mode": "mock",
"ready": false,
"network": null,
"registryAddress": null
}enabled: reflects.envBLOCKCHAIN_ENABLED === "true"mode:"mock"when not ready, otherwise"real"ready:trueonly whenenabledand all ofRPC_URL,PRIVATE_KEY, andREGISTRY_ADDRESSare setnetwork: when ready, typically"fuji"; otherwisenull(or the value fromBLOCKCHAIN_NETWORKif set)registryAddress: public contract address (string) ornull
Example (ready/real mode):
{
"enabled": true,
"mode": "real",
"ready": true,
"network": "fuji",
"registryAddress": "0x0000000000000000000000000000000000000001"
}Curl examples
# Inspect blockchain service configuration via the gateway
curl -sk -H "x-internal-api-key: $INTERNAL_API_KEY" \
https://localhost/api/blockchain/config | jq .All services expose a health check endpoint at /health that can be used to verify that the service is running and healthy.
Response body:
{
"status": "ok",
}-
Direct service (internal):
GET https://tournaments:${TOURNAMENTS_PORT}/health→{"status":"ok"}GET https://tournaments:${TOURNAMENTS_PORT}/health/db→ DB connectivity check
-
Via API Gateway (protected):
GET https://api:3000/api/tournaments/health→ requires JWT orx-internal-api-keyGET https://api:3000/api/tournaments/health/db→ requires JWT orx-internal-api-keyOPTIONS https://api:3000/api/tournaments/*→ returns204with CORS headers (no auth)