Files
XpenselyServer/docs/API.md
T

16 KiB
Raw Blame History

Xpensely Server — API Reference

Last updated: 2026-05-09 · Branch: feature/security-hardening

Table of Contents

  1. Overview
  2. Authentication
  3. Rate Limiting
  4. Endpoints
  5. Data Models
  6. Error Handling
  7. Recent Changes — feature/security-hardening

1. Overview

Xpensely Server is a Spring Boot REST API that manages shared expense lists for pairs of users. It uses Google OAuth2 JWT tokens for authentication. All protected endpoints require a valid Bearer token in the Authorization header.

Base URL (local dev): http://localhost:8080

Content-Type: application/json for all request and response bodies.

Public endpoints (no auth required):

Method Path Description
GET / Health check — returns "Welcome"
POST /api/users/createUser Register a new user
GET /api/users/byName Look up a user by username

All other endpoints require authentication (see Section 2).

2. Authentication

The server uses OAuth2 Resource Server authentication with Google ID JWT tokens.

How it works

  1. The client authenticates with Google and receives a Google ID JWT.
  2. Every protected API request must include this token in the header:
Authorization: Bearer <google-id-jwt>
  1. The server validates the JWT signature and extracts the sub claim (Google User ID).
  2. The sub value is used to look up the registered AppUser in the database via AuthenticatedUserResolver.
  3. If no AppUser exists for that Google ID, the request is rejected with 403 Forbidden.

Test profile

When the application runs under the test Spring profile (-Dspring.profiles.active=test), all security is disabled — every endpoint is accessible without a token. This is used for automated tests only.

User registration flow

Before a user can call any protected endpoint they must first be registered:

  1. Authenticate with Google to obtain a Google ID JWT.
  2. Call POST /api/users/createUser with the JWT's sub value as googleId and a chosen username.
  3. All subsequent protected calls use the same JWT — the server resolves the caller automatically.

3. Rate Limiting

All requests pass through a RateLimitFilter (implemented with Bucket4j).

Setting Value
Limit 60 requests per minute
Window Rolling 1-minute bucket
Key (authenticated) JWT sub claim (Google User ID)
Key (unauthenticated) X-Forwarded-For header, falling back to remote IP

When the limit is exceeded the server responds with:

HTTP 429 Too Many Requests

No Retry-After header is currently returned. Clients should back off and retry after 60 seconds.

Note: Rate limiting applies in the !test profile only. Tests run without rate limiting.

4. Endpoints

4.1 Home

GET /

Health check. No authentication required.

Response: 200 OK

Welcome

4.2 Users

Base path: /api/users


POST /api/users/createUser — Register a user

Auth required: No

Request body:

{
  "username": "alice",
  "googleId": "118400012345678901234"
}
Field Type Constraints
username String Required. 330 chars. Pattern: ^[a-zA-Z0-9_.\-]+$
googleId String Required. Non-blank. Must match the JWT sub from Google.

Success response: 200 OK — returns the created AppUser object.

Error responses:

Status Condition
400 Validation failure (field errors returned as {"fieldName": "message"})
409 username already taken

GET /api/users — Get user by ID

Auth required: Yes

Query params:

Param Type Required Description
id Long Yes Database ID of the user

Success response: 200 OK — returns AppUser.

Error responses:

Status Condition
404 No user found for id

GET /api/users/byName — Get user by username

Auth required: No

Query params:

Param Type Required Description
username String Yes Exact username (case-sensitive)

Success response: 200 OK — returns AppUser.

Error responses:

Status Condition
404 No user found for username

GET /api/users/byGoogleId — Get user by Google ID

Auth required: Yes

Query params:

Param Type Required Description
id String Yes Google sub claim

Success response: 200 OK — returns AppUser.

Error responses:

Status Condition
404 No user found for that Google ID

DELETE /api/users — Delete a user

Auth required: Yes

Query params:

Param Type Required Description
id Long Yes Database ID of the user to delete

Success response: 200 OK — returns the deleted AppUser.

Error responses:

Status Condition
404 No user found for id

4.3 Expense Lists

Base path: /api/expenselist

All endpoints in this group require authentication. The authenticated caller is resolved from the JWT and used for ownership checks.


GET /api/expenselist/mine — Get caller's expense lists

Returns all expense lists where the caller is the owner or has been shared the list.

Auth required: Yes

Request body: None

Success response: 200 OK — array of ExpenseList.

[
  {
    "id": 1,
    "name": "Holiday Trip",
    "inviteCode": null,
    "owner": { "id": 1, "username": "alice" },
    "sharedWith": null,
    "expenses": [],
    "customCategories": []
  }
]

GET /api/expenselist/byId — Get expense list by ID

Auth required: Yes

Query params:

Param Type Required Description
id Long Yes Expense list database ID

The caller must be the owner or the shared user of the list.

Success response: 200 OK — returns ExpenseList.

Error responses:

Status Condition
403 Caller is neither owner nor shared user
404 No list found for id

POST /api/expenselist/create — Create an expense list

Auth required: Yes

Request body:

{
  "name": "Road Trip 2026"
}
Field Type Constraints
name String Required. Max 100 chars.

The authenticated caller becomes the owner of the new list. The list is initialised with the default Xpensely standard categories.

Success response: 201 Created — returns the created ExpenseList.

Error responses:

Status Condition
400 Validation failure

DELETE /api/expenselist/{id} — Delete an expense list

Auth required: Yes

Path params:

Param Type Description
id Long Expense list database ID

Only the owner may delete a list. Deleting a list cascades to all its expenses and custom categories.

Success response: 204 No Content

Error responses:

Status Condition
403 Caller is not the owner
404 No list found for id

POST /api/expenselist/{id}/add — Add an expense to a list

Auth required: Yes

Path params:

Param Type Description
id Long Expense list database ID

Request body:

{
  "title": "Dinner",
  "owner": "alice",
  "amount": 42.50,
  "personalUseAmount": 21.25,
  "otherPersonAmount": 21.25,
  "date": "2026-05-09",
  "category": "Food"
}
Field Type Constraints
title String Required. Max 100 chars.
owner String Required. Username of the person who paid.
amount Double Required. Min 0.01.
personalUseAmount Double Optional. Caller's share.
otherPersonAmount Double Optional. Other person's share.
date String (ISO-8601) Required. Format: YYYY-MM-DD.
category String Required. Non-blank category name.

Success response: 200 OK — returns the created Expense.

Error responses:

Status Condition
400 Validation failure
403 Caller is not a member (owner or sharedWith) of the list
404 List not found

PUT /api/expenselist/{id}/update — Update an expense

Auth required: Yes

Path params:

Param Type Description
id Long Expense list database ID

Request body:

{
  "id": 7,
  "title": "Dinner (updated)",
  "ownerName": "alice",
  "amount": 50.00,
  "personalUseAmount": 25.00,
  "otherPersonAmount": 25.00,
  "date": "2026-05-09",
  "category": "Food"
}
Field Type Constraints
id Long Required. ID of the expense to update.
title String Required. Max 100 chars.
ownerName String Required. Username of the payer.
amount Double Required. Min 0.01.
personalUseAmount Double Optional.
otherPersonAmount Double Optional.
date String (ISO-8601) Required. YYYY-MM-DD.
category String Required.

Caller must be a member of the list. Expense must belong to the specified list.

Success response: 200 OK — returns the updated Expense.

Error responses:

Status Condition
400 Validation failure or expense does not belong to this list
403 Caller is not a member of the list
404 List or expense not found

DELETE /api/expenselist/{id}/delete — Remove an expense from a list

Auth required: Yes

Path params:

Param Type Description
id Long Expense list database ID

Query params:

Param Type Required Description
expenseId Long Yes ID of the expense to remove

Caller must be a member of the list.

Success response: 200 OK — returns the deleted Expense.

Error responses:

Status Condition
403 Caller is not a member of the list
404 List or expense not found

POST /api/expenselist/{listId}/invite — Generate an invite code

Generates (or refreshes) a 6-character uppercase invite code for the list, valid for 1 week.

Auth required: Yes

Path params:

Param Type Description
listId Long Expense list database ID

Caller must be the owner of the list.

Success response: 200 OK — returns the invite code as a plain string.

AB3X7Q

Error responses:

Status Condition
403 Caller is not the owner
404 List not found

POST /api/expenselist/accept-invite — Accept an invite

Joins the caller to a shared expense list using an invite code.

Auth required: Yes

Request body:

{
  "inviteCode": "AB3X7Q"
}
Field Type Constraints
inviteCode String Required. Exactly 6 characters.

Success response: 200 OK — returns the ExpenseList the caller joined.

Error responses:

Status Condition
400 Validation failure or invite code not found / expired
403 Caller is already the owner of this list

5. Data Models

AppUser

Returned by all /api/users endpoints. Sensitive fields (googleId, createdAt) are hidden from API responses via @JsonIgnore.

{
  "id": 1,
  "username": "alice"
}
Field Type Notes
id Long Auto-generated primary key
username String Unique. 330 chars.

ExpenseList

{
  "id": 1,
  "name": "Road Trip",
  "inviteCode": "AB3X7Q",
  "owner": { "id": 1, "username": "alice" },
  "sharedWith": { "id": 2, "username": "bob" },
  "xpenselyStandardCategories": {
    "id": 1,
    "categories": [
      { "id": 1, "name": "Food", "colorCode": "#FF5733" }
    ]
  },
  "customCategories": [],
  "expenses": []
}
Field Type Notes
id Long Auto-generated primary key
name String Display name of the list
inviteCode String | null 6-char code; null if not yet generated or expired
owner AppUser User who created the list
sharedWith AppUser | null Second member of the list; null until an invite is accepted
xpenselyStandardCategories XpenselyStandardCategories Default category set assigned at creation
customCategories XpenselyCustomCategory[] User-defined categories, ordered A→Z
expenses Expense[] All expenses, ordered by date ASC then id ASC

inviteCodeExpiration is hidden from API responses (@JsonIgnore).


Expense

{
  "id": 7,
  "title": "Dinner",
  "owner": { "id": 1, "username": "alice" },
  "amount": 42.50,
  "personalUseAmount": 21.25,
  "otherPersonAmount": 21.25,
  "category": "Food",
  "date": "2026-05-09"
}
Field Type Notes
id Long Auto-generated
title String Description of the expense
owner AppUser Who paid
amount Double Total amount. Min 0.01.
personalUseAmount Double | null Caller's share
otherPersonAmount Double | null Other member's share
category String Category name (free text — must match a category on the list)
date String (ISO-8601) YYYY-MM-DD

The expenseList back-reference is excluded via @JsonBackReference to prevent circular serialisation.


XpenselyStandardCategories

A named group of standard categories assigned to every new expense list.

{
  "id": 1,
  "categories": [
    { "id": 1, "name": "Food", "colorCode": "#FF5733" },
    { "id": 2, "name": "Transport", "colorCode": "#3498DB" }
  ]
}

XpenselyCustomCategory

A user-defined category attached to a specific expense list.

{
  "id": 5,
  "name": "Souvenirs",
  "colorCode": "#9B59B6"
}
Field Type Notes
id Long Auto-generated
name String Category label
colorCode String 7-char hex string e.g. #RRGGBB

The expenseList back-reference is excluded via @JsonBackReference.

6. Error Handling

All errors are returned as JSON. The GlobalExceptionHandler (@RestControllerAdvice) maps exceptions to HTTP status codes consistently across every endpoint.

Error response format

Most errors:

{
  "error": "Human-readable message"
}

Validation errors (400):

{
  "username": "size must be between 3 and 30",
  "googleId": "must not be blank"
}

Status code reference

HTTP Status Condition Source
400 Bad Request Input validation failed (missing/invalid fields) MethodArgumentNotValidException via @Valid
400 Bad Request Business rule violation (e.g. expense not in this list) IllegalArgumentException
403 Forbidden User not registered in the system AuthenticatedUserResolverResponseStatusException(FORBIDDEN)
403 Forbidden Ownership check failed (e.g. deleting someone else's list) ResponseStatusException(FORBIDDEN) in controller
404 Not Found Entity does not exist (user, list, expense) ResourceNotFoundException
409 Conflict Username already taken UsernameAlreadyExistsException
429 Too Many Requests Rate limit exceeded RateLimitFilter (returned directly, not via exception handler)
500 Internal Server Error Unexpected runtime or generic exception RuntimeException / Exception — message is hidden from client

7. Recent Changes — feature/security-hardening

TODO