GraphQL API Guide
Reference for the additive GraphQL layer at
POST /graphqlon the api-gateway. REST endpoints are untouched — GraphQL sits alongside them. See the "GraphQL Adoption Plan" for the rollout background.🇻🇳 Vietnamese version: GRAPHQL_API_GUIDE_VI.md
1. Getting started
Everything goes through a single endpoint:
POST /graphql
Content-Type: application/json
Authorization: Bearer <keycloak-jwt> (omit for public queries)
Interactive explorer:
GET /graphql (opens GraphQL Playground in a browser — non-production only)
Static, Swagger-style reference (generated from the schema):
GET /graphql/docs (non-production only — run `yarn docs:graphql` first to generate it)
Getting a token for manual testing (dev realm only):
curl -s -X POST "http://localhost:8080/realms/football-booking/protocol/openid-connect/token" \
-H "Content-Type: application/x-www-form-urlencoded" \
-d "grant_type=password" -d "client_id=api-gateway" -d "client_secret=api-gateway-secret-key-12345" \
-d "username=customer1" -d "password=customer123"
2. How to read the tables
| Column | Meaning |
|---|---|
| Auth | Public = no token needed. Auth = any valid JWT. ADMIN / FIELD_OWNER, ADMIN = role required (FORBIDDEN if the role doesn't match, UNAUTHENTICATED if no token at all). |
| Args | GraphQL argument name and type. ! means required. |
| Returns | GraphQL return type. [X!]! = a non-null list of non-null X. A bare X (no !) is nullable — typically returned as null on a downstream 404 alongside a NOT_FOUND error. |
3. Root Query reference
3.1 Booking
| Query | Args | Returns | Auth | Description |
|---|---|---|---|---|
booking |
id: ID! |
Booking |
Auth | Get one booking by id, with field/user/payment resolved on demand. |
myBookings |
status: String, page: Int, limit: Int |
[Booking!]! |
Auth | The current user's own bookings. Replaces GET /bookings/me. |
bookingsAdmin |
status: String, page: Int, limit: Int |
[Booking!]! |
ADMIN | All bookings across all users. Replaces GET /bookings and its alias GET /admin/bookings. |
3.2 Field
| Query | Args | Returns | Auth | Description |
|---|---|---|---|---|
field |
id: ID! |
Field |
Public | Get one field by id, with owner/reviews/images/scheduleGrid/availability/bookingStats resolved on demand. |
fields |
filter: FieldFilterInput |
[Field!]! |
Public | Search/list active fields — mirrors GET /fields (no filter) and GET /fields/search (with filter): name, address, geo radius, price range, rating, amenities, sort, paging. |
chatbotSearchFields |
filter: ChatbotFieldFilterInput |
[Field!]! |
Public | Separate, uncached, word-by-word search tuned for chatbot tool-calls. Replaces GET /fields/chatbot/search. |
pendingFields |
page: Int, limit: Int |
[Field!]! |
ADMIN | Fields awaiting admin approval. Replaces GET /admin/fields/pending. |
Field's nested fields:
| Field | Args | Returns | Auth | Description |
|---|---|---|---|---|
owner |
— | User |
Public | The field's owner. |
reviews |
page: Int, limit: Int |
[FieldReview!]! |
Public | Customer reviews for this field. Replaces GET /fields/:id/reviews (not previously proxied by the gateway at all — new capability). |
images |
— | [FieldImage!]! |
Public | Field photos in display order. Replaces GET /fields/:id/images. |
scheduleGrid |
date: String!, openTime: String, closeTime: String, slotMinutes: Int |
ScheduleGrid |
Public | Slot-level booked/available grid for one date. Replaces GET /bookings/fields/:id/schedule-grid. |
availability |
date: String!, openTime: String, closeTime: String |
FieldAvailability |
Public | Available time slots for one date. Replaces GET /bookings/fields/:id/availability. |
bookingStats |
startDate: String!, endDate: String! |
FieldBookingStats |
Auth | Booking/revenue statistics for this field over a date range. Replaces GET /bookings/fields/:id/statistics. |
3.3 Admin dashboard
| Query | Args | Returns | Auth | Description |
|---|---|---|---|---|
adminDashboard |
— | AdminDashboard! |
ADMIN | Root marker object — pendingFieldApprovals/today/users below each resolve (and fail) independently. Replaces GET /admin/dashboard. |
AdminDashboard's nested fields:
| Field | Returns | Description |
|---|---|---|
pendingFieldApprovals |
Float |
Count of fields awaiting approval (count only — use pendingFields above for the full list). |
today |
AdminDashboardToday |
Today's booking count, revenue, cancellation rate. |
users |
AdminDashboardUsers |
Total users, breakdown by role/status. |
3.4 Owner dashboard
| Query | Args | Returns | Auth | Description |
|---|---|---|---|---|
ownerDashboard |
— | OwnerDashboard! |
FIELD_OWNER, ADMIN | Fetches the caller's field ids once; bookings/revenue below reuse them instead of each re-fetching. Replaces the GET /bookings/owner/bookings + GET /bookings/owner/revenue waterfall. |
OwnerDashboard's nested fields:
| Field | Args | Returns | Description |
|---|---|---|---|
fields |
— | [OwnerFieldSummary!]! |
The caller's own fields (lightweight: id/name/status only). |
bookings |
status: String, page: Int, limit: Int |
[Booking!]! |
Bookings across all the caller's fields. |
revenue |
startDate: String!, endDate: String! |
OwnerRevenue |
Revenue + monthly breakdown across all the caller's fields. |
3.5 Payment
| Query | Args | Returns | Auth | Description |
|---|---|---|---|---|
payments |
page: Int, limit: Int |
[Payment!]! |
ADMIN | All payments across all users. Replaces GET /payments. |
payment |
id: ID! |
Payment |
ADMIN | One payment by id. Replaces GET /payments/:id. |
Note — REST documents a 403-ADMIN response on
GET /payments/GET /payments/:idbut never actually enforces it (a pre-existing gap). GraphQL enforcesADMINhere on purpose rather than carrying that gap forward. A customer's own payment is already reachable viaBooking.payment.
Payment's nested fields:
| Field | Returns | Description |
|---|---|---|
transactions |
[PaymentTransaction!]! |
Provider webhook/audit trail for this payment. Replaces GET /payments/:id/transactions. |
3.6 Owner applications
| Query | Args | Returns | Auth | Description |
|---|---|---|---|---|
myOwnerApplications |
— | [OwnerApplication!]! |
Auth | The current user's own field-owner applications. Replaces GET /owner-applications/my. |
ownerApplications |
— | [OwnerApplication!]! |
ADMIN | All applications, with the applicant's user resolved. Replaces GET /owner-applications. |
OwnerApplication's nested fields:
| Field | Returns | Description |
|---|---|---|
user |
User |
The applicant. Batched by internal user id (not Keycloak id — see §5). |
3.7 Admin user directory
| Query | Args | Returns | Auth | Description |
|---|---|---|---|---|
users |
filter: UserFilterInput (role, status, search, page, limit) |
[User!]! |
ADMIN | Admin user directory with role/status/search filters. Replaces GET /admin/users. |
user |
id: ID! |
User |
ADMIN | One user by internal id. Replaces GET /admin/users/:id. |
3.8 Locations (reference data)
| Query | Args | Returns | Auth | Description |
|---|---|---|---|---|
provinces |
— | [Province!]! |
Public | All 34 provinces/cities (post-1/7/2025 merger). Replaces GET /locations/provinces. |
districts |
provinceCode: String! |
[District!]! |
Public | Districts within a province. Replaces GET /locations/provinces/:code/districts. |
3.9 Health
| Query | Args | Returns | Auth | Description |
|---|---|---|---|---|
health |
— | String! |
Public | Liveness check for the GraphQL layer itself — not a REST replacement. |
4. Example queries
Booking detail in one round trip:
{
booking(id: "a1b2c3d4-e5f6-7890-abcd-ef1234567890") {
id
status
totalPrice
field {
name
address
hourlyRate
}
user {
name
email
}
payment {
status
paymentMethod
}
}
}
Field detail with photos, reviews and today's schedule:
{
field(id: "b2c3d4e5-f6a7-8901-bcde-f12345678901") {
name
address
hourlyRate
images {
url
isPrimary
}
reviews(limit: 5) {
rating
comment
userName
}
availability(date: "2026-08-01") {
availableSlots {
startTime
endTime
}
}
}
}
Owner dashboard, one request instead of two:
{
ownerDashboard {
fields {
id
name
status
}
bookings(status: "confirmed", limit: 10) {
id
startTime
totalPrice
}
revenue(startDate: "2026-01-01", endDate: "2026-12-31") {
totalRevenue
monthly {
month
revenue
bookings
}
}
}
}
Admin dashboard with partial-failure tolerance:
{
adminDashboard {
pendingFieldApprovals
today {
bookings
revenue
cancellationRate
}
users {
total
byRole
byStatus
}
}
}
If the users service is down, adminDashboard.users comes back null with its own error entry — pendingFieldApprovals/today still resolve.
5. Notes worth knowing
Booking.userIdandField.ownerIdare Keycloak subject ids;OwnerApplication.userIdis user-service's own internal primary key. Theiruser/ownerfields batch through two different DataLoaders accordingly — this is an implementation detail, not something callers need to think about, but it explains why a rawuserIdwon't match user-service's/users/:idREST path for a booking/field.- Every list-nested lookup (
Booking.field/user/payment,Field.owner) goes through a per-request DataLoader — asking forfield { name }on 50 bookings costs one batched downstream call, not 50. - A
NOT_FOUNDGraphQL error alongside anullvalue means "this id genuinely doesn't exist" (REST 404 parity) — it is not a bug and not the same as an unrequested/absent field.
6. Error codes
extensions.code |
Meaning |
|---|---|
UNAUTHENTICATED |
No/invalid JWT. |
FORBIDDEN |
Valid JWT, wrong role. |
NOT_FOUND |
Downstream returned 404 — the id doesn't exist. |
BAD_USER_INPUT |
Downstream returned 400 — invalid argument value. |
SERVICE_UNAVAILABLE |
A downstream service is unreachable (connection refused). |
DOWNSTREAM_ERROR |
Downstream returned some other non-2xx status. |
7. What's intentionally REST-only
Auth flows (/auth/*), payment provider webhooks (/webhooks/*), file uploads (avatar, field images, review photos), health probes, and rate-limit admin tooling stay REST forever — see "GraphQL Adoption Plan" for why.